@skyhook-io/radar-app 1.8.13 → 1.9.1

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.
@@ -639,13 +639,14 @@ function AppDetailRoute({
639
639
  history={historyQuery.data}
640
640
  historyLoading={historyQuery.isLoading}
641
641
  historyItems={historyItems}
642
- historyRuntimeLoading={historyTimelineQuery.isFetching}
642
+ historyRuntimeLoading={historyTimelineQuery.isLoading}
643
643
  historyRuntimeError={historyTimelineQuery.isError}
644
644
  historyMode={timelineSource.capabilities.mode}
645
645
  historyRange={historyRange}
646
646
  historyRangeOptions={historyRangeOptions}
647
647
  historyCoverageRecordCount={historyTimelineQuery.coverage?.length ?? 0}
648
648
  historyRuntimeLimited={historyRuntimeLimited}
649
+ historyRingTruncated={historyTimelineQuery.truncated ?? false}
649
650
  onHistoryRangeChange={setRetainedHistoryRange}
650
651
  onOpenTimeline={openApplicationTimeline}
651
652
  onOpenSource={openSource}
@@ -5,12 +5,16 @@
5
5
  // Settings tabs' layout — this renders only the controls (no card, no heading).
6
6
  import { useState } from "react";
7
7
  import { Trash2 } from "lucide-react";
8
- import { clearHistory, type AgentInfo } from "../../api/diagnose";
8
+ import {
9
+ clearHistory,
10
+ type AgentInfo,
11
+ type ExecutionProfile,
12
+ } from "../../api/diagnose";
9
13
  import { AgentControls } from "./parts";
10
14
 
11
15
  export interface AIDraft {
12
16
  agent: string;
13
- isolated: boolean;
17
+ profile: ExecutionProfile;
14
18
  model: string;
15
19
  effort: string;
16
20
  }
@@ -120,17 +124,27 @@ export function AISettingsSection({
120
124
  // The agent, its model, and how it runs are all fixed by the host — none
121
125
  // of the local BYO-agent knobs apply, so there's nothing to configure.
122
126
  <p className="text-xs leading-snug text-theme-text-tertiary">
123
- {agentLabel} manages the model and how it runs — there&apos;s nothing to
124
- configure here.
127
+ {agentLabel} manages the model and how it runs — there&apos;s nothing
128
+ to configure here.
125
129
  </p>
126
130
  ) : (
127
131
  <AgentControls
128
132
  agents={agents}
129
133
  selectedAgent={draft.agent}
130
134
  // Model + effort are agent-specific; reset them when the agent changes.
131
- onSelectAgent={(a) => onChange({ agent: a, model: "", effort: "" })}
132
- isolated={draft.isolated}
133
- onSetIsolated={(v) => onChange({ isolated: v })}
135
+ onSelectAgent={(a) => {
136
+ const nextProfile = agents.find(
137
+ (agent) => agent.name === a,
138
+ )?.profiles?.[0];
139
+ onChange({
140
+ agent: a,
141
+ profile: nextProfile ?? draft.profile,
142
+ model: "",
143
+ effort: "",
144
+ });
145
+ }}
146
+ profile={draft.profile}
147
+ onSetProfile={(v) => onChange({ profile: v })}
134
148
  model={draft.model}
135
149
  onSetModel={(v) => onChange({ model: v })}
136
150
  effort={draft.effort}
@@ -22,7 +22,11 @@ import {
22
22
  recordConsent,
23
23
  DiagnoseError,
24
24
  } from "../../api/diagnose";
25
- import { type RunSummary, type AgentInfo } from "../../api/diagnose";
25
+ import {
26
+ type RunSummary,
27
+ type AgentInfo,
28
+ type ExecutionProfile,
29
+ } from "../../api/diagnose";
26
30
 
27
31
  export interface Target {
28
32
  kind: string;
@@ -38,8 +42,8 @@ interface DiagnoseCtx {
38
42
  agents: AgentInfo[]; // supported agents detected on PATH (for the picker)
39
43
  selectedAgent: string; // name of the chosen backend ("claude"/"codex")
40
44
  setSelectedAgent: (name: string) => void;
41
- isolated: boolean; // run the agent without the user's own CLI config
42
- setIsolated: (v: boolean) => void;
45
+ profile: ExecutionProfile;
46
+ setProfile: (v: ExecutionProfile) => void;
43
47
  model: string; // optional model override ("" = the agent's own default)
44
48
  setModel: (v: string) => void;
45
49
  effort: string; // optional Codex reasoning effort ("" = default)
@@ -108,15 +112,10 @@ const WIDTH_KEY = "radar-ai-panel-width";
108
112
  // Consent is machine-scoped and lives server-side (~/.radar): it gates a
109
113
  // machine-scoped action (spawn this machine's agent CLI, persist transcripts to
110
114
  // this machine's disk), so one acknowledgment covers this panel AND the
111
- // `radar diagnose` CLI. Cursor gets its own surface its trust model is
112
- // materially different (the user's global MCP servers can't be excluded), so
113
- // approving Claude/Codex never bypasses Cursor's distinct disclosure.
114
- type ConsentSurface = "standard" | "cursor";
115
- function consentSurfaceFor(agent: string): ConsentSurface {
116
- return agent === "cursor-agent" ? "cursor" : "standard";
117
- }
115
+ // `radar diagnose` CLI. The execution profile selects the disclosure because
116
+ // it determines the actual access available to the agent process.
118
117
  const AGENT_KEY = "radar-ai-agent";
119
- const ISOLATED_KEY = "radar-ai-isolated";
118
+ const PROFILE_KEY = "radar-ai-profile";
120
119
  const MODEL_KEY = "radar-ai-model";
121
120
  const EFFORT_KEY = "radar-ai-effort";
122
121
  // Push (reflow the app left) only while the app keeps at least this much width to
@@ -140,7 +139,9 @@ export function agentLabelFor(name: string, fallbackLabel?: string): string {
140
139
  // openDiagnoseSettings opens the Settings dialog (App.tsx listens for this DOM
141
140
  // event) — the canonical home for AI-diagnosis config.
142
141
  export function openDiagnoseSettings() {
143
- window.dispatchEvent(new CustomEvent("radar:open-settings"));
142
+ window.dispatchEvent(
143
+ new CustomEvent("radar:open-settings", { detail: { section: "ai" } }),
144
+ );
144
145
  }
145
146
 
146
147
  function readStored(key: string): string | null {
@@ -161,14 +162,12 @@ function writeStored(key: string, value: string) {
161
162
  export function DiagnoseProvider({ children }: { children: ReactNode }) {
162
163
  const [available, setAvailable] = useState(false);
163
164
  const [agents, setAgents] = useState<AgentInfo[]>([]);
164
- const [consented, setConsented] = useState<
165
- Record<ConsentSurface, boolean>
166
- >({ standard: false, cursor: false });
165
+ const [consented, setConsented] = useState<Record<string, boolean>>({});
167
166
  const [selectedAgent, setSelectedAgentState] = useState<string>(
168
167
  () => readStored(AGENT_KEY) || "",
169
168
  );
170
- const [isolated, setIsolatedState] = useState<boolean>(
171
- () => readStored(ISOLATED_KEY) !== "0", // default isolated
169
+ const [profile, setProfileState] = useState<ExecutionProfile>(
170
+ () => (readStored(PROFILE_KEY) as ExecutionProfile) || "safeguarded",
172
171
  );
173
172
  const [model, setModelState] = useState<string>(
174
173
  () => readStored(MODEL_KEY) || "",
@@ -205,12 +204,15 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
205
204
  fetchAgents()
206
205
  .then((r) => {
207
206
  if (!live) return;
208
- setAvailable(r.enabled);
209
- setConsented({
210
- standard: !!r.consented?.standard,
211
- cursor: !!r.consented?.cursor,
212
- });
213
- const supported = r.agents.filter((a) => a.supported);
207
+ setConsented(r.consented ?? {});
208
+ const supported = r.agents.filter(
209
+ (a) =>
210
+ a.supported &&
211
+ (a.hosted ||
212
+ (!!a.profiles?.length &&
213
+ a.profiles.every((p) => !!a.consentSurfaces?.[p]))),
214
+ );
215
+ setAvailable(r.enabled && supported.length > 0);
214
216
  setAgents(supported);
215
217
  // Keep the stored pick only if it's still installed; else default to the
216
218
  // first supported agent (matches the server's default selection).
@@ -220,6 +222,13 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
220
222
  ? stored
221
223
  : (supported[0]?.name ?? "");
222
224
  setSelectedAgentState(next);
225
+ const profiles = supported.find((a) => a.name === next)?.profiles ?? [];
226
+ const storedProfile =
227
+ (readStored(PROFILE_KEY) as ExecutionProfile) || "safeguarded";
228
+ if (!profiles.includes(storedProfile) && profiles[0]) {
229
+ setProfileState(profiles[0]);
230
+ writeStored(PROFILE_KEY, profiles[0]);
231
+ }
223
232
  // Model/effort are agent-specific; if the stored agent is gone, its values
224
233
  // don't apply to the fallback agent (e.g. a Codex slug under Claude) — drop them.
225
234
  if (next !== stored) {
@@ -247,23 +256,35 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
247
256
  (name: string) => {
248
257
  setSelectedAgentState(name);
249
258
  writeStored(AGENT_KEY, name);
259
+ const nextProfile = agents.find((agent) => agent.name === name)?.profiles?.[0];
260
+ if (nextProfile) {
261
+ setProfileState(nextProfile);
262
+ writeStored(PROFILE_KEY, nextProfile);
263
+ }
250
264
  // Model + effort are agent-specific (Claude aliases vs Codex slugs); reset
251
265
  // to the new agent's default rather than carry an invalid value across.
252
266
  setModel("");
253
267
  setEffort("");
254
268
  },
255
- [setModel, setEffort],
269
+ [agents, setModel, setEffort],
256
270
  );
257
- const setIsolated = useCallback((v: boolean) => {
258
- setIsolatedState(v);
259
- writeStored(ISOLATED_KEY, v ? "1" : "0");
271
+ const setProfile = useCallback((v: ExecutionProfile) => {
272
+ setProfileState(v);
273
+ writeStored(PROFILE_KEY, v);
260
274
  }, []);
261
275
 
262
- const agentLabel = agentLabelFor(
263
- selectedAgent,
264
- agents.find((a) => a.name === selectedAgent)?.label,
265
- );
266
- const hosted = !!agents.find((a) => a.name === selectedAgent)?.hosted;
276
+ const selectedAgentInfo = agents.find((a) => a.name === selectedAgent);
277
+ const effectiveProfile =
278
+ selectedAgentInfo?.profiles?.includes(profile)
279
+ ? profile
280
+ : (selectedAgentInfo?.profiles?.[0] ?? profile);
281
+ const agentLabel = agentLabelFor(selectedAgent, selectedAgentInfo?.label);
282
+ const hosted = !!selectedAgentInfo?.hosted;
283
+ // Hosted Radar uses its existing per-user disclosure surface and supplies its
284
+ // own copy. Local agents use the execution profile as the consent contract.
285
+ const consentSurface = hosted
286
+ ? "standard"
287
+ : (selectedAgentInfo?.consentSurfaces?.[effectiveProfile] ?? "");
267
288
 
268
289
  useEffect(() => {
269
290
  const onResize = () => setViewportW(window.innerWidth);
@@ -314,10 +335,6 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
314
335
  return () => clearInterval(t);
315
336
  }, [open, available, hasRunning, refreshRuns]);
316
337
 
317
- // Keep the live selected agent reachable from the [] -dep callbacks below
318
- // (consent is agent-specific, so they must read the CURRENT pick, not a closure).
319
- const selectedAgentRef = useRef(selectedAgent);
320
- selectedAgentRef.current = selectedAgent;
321
338
  const consentedRef = useRef(consented);
322
339
  consentedRef.current = consented;
323
340
 
@@ -329,7 +346,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
329
346
  const seq = ++startSeqRef.current;
330
347
  createRun(t, {
331
348
  agent: selectedAgent || undefined,
332
- isolated,
349
+ profile: hosted ? undefined : effectiveProfile,
333
350
  model: model || undefined,
334
351
  effort: effort || undefined,
335
352
  })
@@ -351,17 +368,26 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
351
368
  });
352
369
  };
353
370
 
354
- const openInvestigation = useCallback((t: Target) => {
355
- setStartError(null);
356
- setOpen(true);
357
- if (!consentedRef.current[consentSurfaceFor(selectedAgentRef.current)]) {
358
- setPendingTarget(t);
371
+ const openInvestigation = useCallback(
372
+ (t: Target) => {
373
+ setStartError(null);
374
+ setOpen(true);
375
+ if (!hosted && !consentSurface) {
376
+ setPendingTarget(null);
377
+ setStartError("Radar can’t run this agent with a verified execution profile.");
378
+ setView("investigation");
379
+ return;
380
+ }
381
+ if (!consentedRef.current[consentSurface]) {
382
+ setPendingTarget(t);
383
+ setView("investigation");
384
+ return;
385
+ }
359
386
  setView("investigation");
360
- return;
361
- }
362
- setView("investigation");
363
- startRunRef.current(t);
364
- }, []);
387
+ startRunRef.current(t);
388
+ },
389
+ [consentSurface, hosted],
390
+ );
365
391
  const openRun = useCallback((id: string) => {
366
392
  setStartError(null);
367
393
  setActiveRunId(id);
@@ -401,15 +427,18 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
401
427
  const consentBusyRef = useRef(false);
402
428
  const approveConsent = useCallback(() => {
403
429
  if (consentBusyRef.current) return;
430
+ if (!consentSurface) {
431
+ setStartError("Radar can’t record consent for this agent.");
432
+ return;
433
+ }
404
434
  consentBusyRef.current = true;
405
435
  setStartError(null);
406
- const surface = consentSurfaceFor(selectedAgentRef.current);
407
436
  const t = pendingTarget;
408
437
  // The server ENFORCES consent at start, so the acknowledgment must land
409
438
  // before the run request — awaiting also makes it durable for the CLI.
410
- recordConsent(surface)
439
+ recordConsent(consentSurface)
411
440
  .then(() => {
412
- setConsented((prev) => ({ ...prev, [surface]: true }));
441
+ setConsented((prev) => ({ ...prev, [consentSurface]: true }));
413
442
  // Cleared only on success: a failed write keeps the consent card up
414
443
  // (needsConsent = !!pendingTarget) so "try again" works in place.
415
444
  setPendingTarget(null);
@@ -421,7 +450,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
421
450
  .finally(() => {
422
451
  consentBusyRef.current = false;
423
452
  });
424
- }, [pendingTarget]);
453
+ }, [consentSurface, pendingTarget]);
425
454
  const cancelConsent = useCallback(() => {
426
455
  setPendingTarget(null);
427
456
  setOpen(false);
@@ -440,8 +469,8 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
440
469
  agents,
441
470
  selectedAgent,
442
471
  setSelectedAgent,
443
- isolated,
444
- setIsolated,
472
+ profile: effectiveProfile,
473
+ setProfile,
445
474
  model,
446
475
  setModel,
447
476
  effort,
@@ -28,25 +28,29 @@ import { InvestigationView } from "./InvestigationView";
28
28
  import { RecentList } from "./Home";
29
29
  import { ConsentCard } from "./parts";
30
30
  import { buildLaunchCommand, launchAgentLabel, openInTerminal } from "./launch";
31
- import { type RunSummary } from "../../api/diagnose";
31
+ import { type RunSummary, type ExecutionProfile } from "../../api/diagnose";
32
32
 
33
33
  function capWord(s: string): string {
34
34
  return s ? s[0].toUpperCase() + s.slice(1) : s;
35
35
  }
36
36
 
37
37
  // buildConfigLine renders the active AI config as the header subtitle. Codex shows
38
- // its isolation mode + effective reasoning effort (Default → medium); a model
38
+ // its execution profile + effective reasoning effort (Default → medium); a model
39
39
  // override is shown for either agent. Reflects a run's recorded settings, or the
40
40
  // current defaults on Home.
41
41
  function buildConfigLine(cfg: {
42
42
  agent?: string;
43
- isolated?: boolean;
43
+ profile?: ExecutionProfile;
44
44
  model?: string;
45
45
  effort?: string;
46
46
  }): string {
47
47
  const parts = [agentLabelFor(cfg.agent ?? "")];
48
+ if (cfg.profile) {
49
+ parts.push(
50
+ cfg.profile === "full-local" ? "Your agent setup" : "Radar safeguards",
51
+ );
52
+ }
48
53
  if (cfg.agent === "codex") {
49
- parts.push(cfg.isolated === false ? "My setup" : "Isolated");
50
54
  parts.push(`${capWord(cfg.effort || "medium")} effort`);
51
55
  }
52
56
  if (cfg.model) parts.push(capWord(cfg.model));
@@ -139,7 +143,8 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
139
143
  const d = useDiagnose();
140
144
  // Injected settings action: undefined = Radar's own Settings dialog;
141
145
  // null = hide the gear + links.
142
- const { consentCopy, onOpenSettings: hostOpenSettings } = useDiagnoseCustomization();
146
+ const { consentCopy, onOpenSettings: hostOpenSettings } =
147
+ useDiagnoseCustomization();
143
148
  const openSettings =
144
149
  hostOpenSettings === undefined ? openDiagnoseSettings : hostOpenSettings;
145
150
  const {
@@ -180,12 +185,12 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
180
185
  ? agentLabelFor(activeRun.agent)
181
186
  : d.agentLabel;
182
187
  // Header subtitle: the config a focused run actually used (it records agent /
183
- // isolation / model / effort), or the current defaults on Home. Codex shows mode
188
+ // profile / model / effort), or the current defaults on Home. Codex shows mode
184
189
  // + reasoning effort; model is shown only when overridden. Clicking opens Settings.
185
190
  const configLine = buildConfigLine(
186
191
  activeRun ?? {
187
192
  agent: d.selectedAgent,
188
- isolated: d.isolated,
193
+ profile: d.hosted ? undefined : d.profile,
189
194
  model: d.model,
190
195
  effort: d.effort,
191
196
  },
@@ -208,7 +213,7 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
208
213
  <ConsentCard
209
214
  agentName={d.agentLabel}
210
215
  agent={d.selectedAgent}
211
- isolated={d.isolated}
216
+ profile={d.profile}
212
217
  copy={consentCopy}
213
218
  onOpenSettings={openSettings ?? undefined}
214
219
  onApprove={d.approveConsent}
@@ -0,0 +1,125 @@
1
+ import { renderToStaticMarkup } from "react-dom/server";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { AgentControls, ConsentCard } from "./parts";
4
+ import type { AgentInfo, ExecutionProfile } from "../../api/diagnose";
5
+
6
+ const noop = vi.fn();
7
+
8
+ function renderAgent(
9
+ agent: string,
10
+ profiles: ExecutionProfile[],
11
+ profile: ExecutionProfile,
12
+ ) {
13
+ const agents: AgentInfo[] = [
14
+ {
15
+ name: agent,
16
+ label: agent,
17
+ path: agent,
18
+ version: "",
19
+ present: true,
20
+ supported: true,
21
+ profiles,
22
+ },
23
+ ];
24
+ return renderToStaticMarkup(
25
+ <AgentControls
26
+ agents={agents}
27
+ selectedAgent={agent}
28
+ onSelectAgent={noop}
29
+ profile={profile}
30
+ onSetProfile={noop}
31
+ model=""
32
+ onSetModel={noop}
33
+ effort=""
34
+ onSetEffort={noop}
35
+ />,
36
+ );
37
+ }
38
+
39
+ describe("AgentControls execution profile explanation", () => {
40
+ it("shows Cursor's fixed full-local warning even when the stored profile is stale", () => {
41
+ const html = renderAgent("cursor-agent", ["full-local"], "safeguarded");
42
+ expect(html).toContain("must use this agent");
43
+ expect(html).toContain("normal setup");
44
+ expect(html).toContain("always loads your global MCP servers");
45
+ expect(html).toContain("still enables the agent CLI");
46
+ expect(html).toContain("does not constrain external MCP servers");
47
+ expect(html).not.toContain("always runs this agent with safeguards");
48
+ });
49
+
50
+ it("discloses the Claude configuration that Radar cannot exclude", () => {
51
+ const html = renderAgent(
52
+ "claude",
53
+ ["safeguarded", "full-local"],
54
+ "safeguarded",
55
+ );
56
+ expect(html).toContain("built-in tools are disabled");
57
+ expect(html).toContain("settings, hooks, and CLAUDE.md instructions still apply");
58
+ expect(html).toContain("Your claude setup");
59
+ expect(html).not.toContain("other agent configuration is excluded");
60
+ });
61
+
62
+ it("explains that Claude full-local inherits the user's permissions", () => {
63
+ const html = renderAgent(
64
+ "claude",
65
+ ["safeguarded", "full-local"],
66
+ "full-local",
67
+ );
68
+ expect(html).toContain("permissions from your setup");
69
+ expect(html).toContain("Radar does not override them");
70
+ expect(html).not.toContain("Radar still enables the agent CLI");
71
+ });
72
+
73
+ it("does not describe an unknown safeguarded agent as Codex", () => {
74
+ const html = renderAgent("future-agent", ["safeguarded"], "safeguarded");
75
+ expect(html).toContain("documented restrictions");
76
+ expect(html).not.toContain("Codex");
77
+ expect(html).not.toContain("built-in tools are disabled");
78
+ });
79
+
80
+ it("labels the selectable full-local profile as the user's agent setup", () => {
81
+ const html = renderAgent(
82
+ "codex",
83
+ ["safeguarded", "full-local"],
84
+ "safeguarded",
85
+ );
86
+ expect(html).toContain("Your codex setup");
87
+ expect(html).not.toContain("Full local setup");
88
+ });
89
+ });
90
+
91
+ describe("ConsentCard execution profile treatment", () => {
92
+ it("uses warning chrome and explicit consequences for the user's agent setup", () => {
93
+ const html = renderToStaticMarkup(
94
+ <ConsentCard
95
+ agentName="Cursor Agent"
96
+ agent="cursor-agent"
97
+ profile="full-local"
98
+ onApprove={noop}
99
+ onCancel={noop}
100
+ />,
101
+ );
102
+ expect(html).toContain("Run using your Cursor Agent setup?");
103
+ expect(html).toContain("Continue with my agent setup");
104
+ expect(html).toContain("border-amber-500/40");
105
+ expect(html).toContain("text-amber-500");
106
+ expect(html).toContain("Radar cannot constrain");
107
+ expect(html).toContain("always loads your global MCP servers");
108
+ expect(html).not.toContain("text-accent");
109
+ });
110
+
111
+ it("does not claim Radar enables a sandbox for Claude's normal setup", () => {
112
+ const html = renderToStaticMarkup(
113
+ <ConsentCard
114
+ agentName="Claude Code"
115
+ agent="claude"
116
+ profile="full-local"
117
+ onApprove={noop}
118
+ onCancel={noop}
119
+ />,
120
+ );
121
+ expect(html).toContain("permissions from your setup");
122
+ expect(html).toContain("Radar does not override them");
123
+ expect(html).not.toContain("Radar still enables the agent CLI");
124
+ });
125
+ });