@saadnvd1/agent-os 0.3.551 → 0.3.553
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 +1 -0
- package/app/api/approvals/hook/route.ts +34 -0
- package/app/api/approvals/route.ts +52 -0
- package/app/api/devices/network/route.ts +2 -2
- package/app/api/sessions/[id]/summarize/route.ts +2 -1
- package/app/page.tsx +11 -2
- package/app/pair/page.tsx +3 -2
- package/components/Approvals/NoApprovalsBadge.tsx +22 -0
- package/components/Chat/AccessPicker.tsx +13 -1
- package/components/SessionList/SidebarHeader.tsx +2 -0
- package/components/Settings/NoApprovalsSection.test.ts +115 -0
- package/components/Settings/NoApprovalsSection.tsx +103 -0
- package/components/Settings/SettingsDialog.test.ts +3 -0
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/data/approvals/index.ts +56 -0
- package/lib/approvals/enable.ts +64 -0
- package/lib/approvals/no-approvals.ts +119 -0
- package/lib/chat/events.ts +2 -0
- package/lib/chat/settings.ts +17 -2
- package/lib/chat/worker/host.ts +49 -4
- package/lib/db/migrations.ts +18 -0
- package/lib/models/plan-step.ts +36 -7
- package/lib/orchestrator/asks.ts +42 -5
- package/lib/orchestrator/brakes.ts +11 -0
- package/lib/orchestrator/brief.ts +7 -1
- package/lib/orchestrator/merge-approvals.ts +10 -1
- package/lib/orchestrator/presence-binding.ts +3 -0
- package/lib/orchestrator/review.ts +12 -1
- package/lib/orchestrator/signoff.ts +15 -1
- package/lib/security/network-state.ts +2 -2
- package/lib/security/presence.ts +1 -0
- package/lib/settings-url.ts +1 -0
- package/package.json +1 -1
- package/scripts/claude-status-hook.sh +25 -1
package/README.md
CHANGED
|
@@ -26,6 +26,7 @@ and check on all of it from your phone.
|
|
|
26
26
|
- **Machine load and status.** A load gauge, each session's share of CPU and memory, and Needs you, Working, In review and Done as it changes. [More](docs/guides/sessions.md)
|
|
27
27
|
- **Workspaces.** Group projects, finish work with Done, and get a log of every merged task. [More](docs/reference/workspaces.md)
|
|
28
28
|
- **LumifyHub (optional).** Boards, cards and docs for a workspace, if you want them. [More](docs/lumifyhub.md)
|
|
29
|
+
- **No approvals mode.** One switch in Settings that approves every ask, lets brakes and plan-only tasks through, and starts every agent with its permission prompts off; each approval it makes is logged. Off by default. [More](docs/reference/orchestrator.md#no-approvals-mode)
|
|
29
30
|
- **Also:** a ⌘K palette, code search, a file picker, a git panel, dev servers, up to four panes, and dictation.
|
|
30
31
|
|
|
31
32
|
## Documentation
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { NextRequest } from "next/server";
|
|
2
|
+
import {
|
|
3
|
+
noApprovalsOn,
|
|
4
|
+
recordAutoApproval,
|
|
5
|
+
} from "@/lib/approvals/no-approvals";
|
|
6
|
+
|
|
7
|
+
// The Claude status hook asks here when a terminal Claude wants a tool
|
|
8
|
+
// permission. While No approvals mode is on the answer is Claude's own
|
|
9
|
+
// "allow" decision; otherwise nothing, and the prompt shows as usual. A
|
|
10
|
+
// question isn't a permission and is never answered. Never fails the hook.
|
|
11
|
+
export async function POST(request: NextRequest) {
|
|
12
|
+
try {
|
|
13
|
+
if (!noApprovalsOn()) return new Response("");
|
|
14
|
+
const input = (await request.json()) as { tool_name?: unknown };
|
|
15
|
+
const tool = typeof input?.tool_name === "string" ? input.tool_name : "";
|
|
16
|
+
if (!tool || tool === "AskUserQuestion") return new Response("");
|
|
17
|
+
recordAutoApproval({
|
|
18
|
+
source: "terminal",
|
|
19
|
+
sessionId: request.nextUrl.searchParams.get("session") || null,
|
|
20
|
+
subject: `${tool} in a terminal session`,
|
|
21
|
+
});
|
|
22
|
+
return new Response(
|
|
23
|
+
JSON.stringify({
|
|
24
|
+
hookSpecificOutput: {
|
|
25
|
+
hookEventName: "PermissionRequest",
|
|
26
|
+
decision: { behavior: "allow" },
|
|
27
|
+
},
|
|
28
|
+
}),
|
|
29
|
+
{ headers: { "Content-Type": "application/json" } }
|
|
30
|
+
);
|
|
31
|
+
} catch {
|
|
32
|
+
return new Response("");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import type { AuthenticationResponseJSON } from "@simplewebauthn/server";
|
|
3
|
+
import { switchNoApprovals } from "@/lib/approvals/enable";
|
|
4
|
+
import { listAutoApprovals, noApprovalsOn } from "@/lib/approvals/no-approvals";
|
|
5
|
+
import { NO_APPROVALS_ON_PRESENCE } from "@/lib/orchestrator/presence-binding";
|
|
6
|
+
import { requireApprover } from "@/lib/security/approver";
|
|
7
|
+
import { relyingParty, verifyPresence } from "@/lib/security/presence";
|
|
8
|
+
import { refusalResponse } from "@/lib/security/presence-http";
|
|
9
|
+
import { requireLocalTrust } from "@/lib/security/route-guard";
|
|
10
|
+
|
|
11
|
+
const view = () => ({ on: noApprovalsOn(), audit: listAutoApprovals(20) });
|
|
12
|
+
|
|
13
|
+
// GET /api/approvals - whether No approvals mode is on, and its latest audit lines
|
|
14
|
+
export function GET() {
|
|
15
|
+
return NextResponse.json(view());
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// PUT /api/approvals {on, assertion?} - turning it on is deliberate: only
|
|
19
|
+
// this machine or the tailnet, and with Saad's passkey, since it switches
|
|
20
|
+
// off every approval. Turning it off only needs a place that may approve.
|
|
21
|
+
export async function PUT(request: NextRequest) {
|
|
22
|
+
const body = (await request.json().catch(() => ({}))) as {
|
|
23
|
+
on?: unknown;
|
|
24
|
+
assertion?: AuthenticationResponseJSON;
|
|
25
|
+
};
|
|
26
|
+
if (typeof body.on !== "boolean")
|
|
27
|
+
return NextResponse.json(
|
|
28
|
+
{ error: "on must be true or false" },
|
|
29
|
+
{ status: 400 }
|
|
30
|
+
);
|
|
31
|
+
if (body.on) {
|
|
32
|
+
const refused = requireLocalTrust(request);
|
|
33
|
+
if (refused) return refused;
|
|
34
|
+
if (!noApprovalsOn()) {
|
|
35
|
+
try {
|
|
36
|
+
await verifyPresence(
|
|
37
|
+
relyingParty(request.headers),
|
|
38
|
+
body.assertion,
|
|
39
|
+
"no-approvals-on",
|
|
40
|
+
NO_APPROVALS_ON_PRESENCE
|
|
41
|
+
);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
return refusalResponse(error);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
} else {
|
|
47
|
+
const gate = requireApprover(request);
|
|
48
|
+
if (!gate.ok) return gate.response;
|
|
49
|
+
}
|
|
50
|
+
await switchNoApprovals(body.on);
|
|
51
|
+
return NextResponse.json(view());
|
|
52
|
+
}
|
|
@@ -5,7 +5,7 @@ import { requireLocalTrust } from "@/lib/security/route-guard";
|
|
|
5
5
|
import { setConnectEnabled } from "@/lib/connect/config";
|
|
6
6
|
import type { AuthenticationResponseJSON } from "@simplewebauthn/server";
|
|
7
7
|
import {
|
|
8
|
-
|
|
8
|
+
mergeApprovalsSetting,
|
|
9
9
|
setMergeApprovals,
|
|
10
10
|
} from "@/lib/orchestrator/merge-approvals";
|
|
11
11
|
import { APPROVALS_OFF_PRESENCE } from "@/lib/orchestrator/presence-binding";
|
|
@@ -37,7 +37,7 @@ export async function PUT(request: NextRequest) {
|
|
|
37
37
|
};
|
|
38
38
|
// Switching Saad's merge approvals off lets sensitive PRs merge without
|
|
39
39
|
// him, so it needs his passkey: anything on this machine could ask.
|
|
40
|
-
if (body.mergeApprovals === false &&
|
|
40
|
+
if (body.mergeApprovals === false && mergeApprovalsSetting()) {
|
|
41
41
|
try {
|
|
42
42
|
await verifyPresence(
|
|
43
43
|
relyingParty(request.headers),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { effectiveAutoApprove } from "@/lib/approvals/no-approvals";
|
|
1
2
|
import { routedRun } from "@/lib/models/launch";
|
|
2
3
|
import { NextRequest, NextResponse } from "next/server";
|
|
3
4
|
import { isRemoteHost } from "@/lib/hosts";
|
|
@@ -309,7 +310,7 @@ export async function POST(
|
|
|
309
310
|
// Start new tmux session with Claude directly
|
|
310
311
|
const isRoot = process.getuid?.() === 0;
|
|
311
312
|
const envPrefix = isRoot ? "IS_SANDBOX=1 " : "";
|
|
312
|
-
const claudeCmd = session.auto_approve
|
|
313
|
+
const claudeCmd = effectiveAutoApprove(session.auto_approve)
|
|
313
314
|
? `${envPrefix}claude --dangerously-skip-permissions`
|
|
314
315
|
: "claude";
|
|
315
316
|
|
package/app/page.tsx
CHANGED
|
@@ -59,6 +59,8 @@ import { useMoveCommands } from "@/components/Tasks/useMoveCommands";
|
|
|
59
59
|
import dynamic from "next/dynamic";
|
|
60
60
|
import { chatMetaActions } from "@/stores/chatMeta";
|
|
61
61
|
import { useDemoMode } from "@/data/demo";
|
|
62
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
63
|
+
import { approvalsModeQuery } from "@/data/approvals";
|
|
62
64
|
|
|
63
65
|
// The app's dialogs open from their stores, so none is needed for the first
|
|
64
66
|
// paint: their code loads right after it.
|
|
@@ -159,6 +161,7 @@ function HomeContent() {
|
|
|
159
161
|
const focusedActiveTab = getActiveTab(focusedPaneId);
|
|
160
162
|
const { isMobile, isHydrated } = useViewport();
|
|
161
163
|
const demo = useDemoMode();
|
|
164
|
+
const queryClient = useQueryClient();
|
|
162
165
|
|
|
163
166
|
// Data hooks
|
|
164
167
|
const {
|
|
@@ -288,10 +291,16 @@ function HomeContent() {
|
|
|
288
291
|
clearPendingPrompt(session.id);
|
|
289
292
|
}
|
|
290
293
|
|
|
294
|
+
// No approvals mode launches every agent with its permission prompts
|
|
295
|
+
// off; a failed read launches as the session was set.
|
|
296
|
+
const noApprovals = await queryClient
|
|
297
|
+
.fetchQuery(approvalsModeQuery)
|
|
298
|
+
.then((m) => m.on)
|
|
299
|
+
.catch(() => false);
|
|
291
300
|
const flags = provider.buildFlags({
|
|
292
301
|
sessionId: session.claude_session_id,
|
|
293
302
|
parentSessionId,
|
|
294
|
-
autoApprove: session.auto_approve,
|
|
303
|
+
autoApprove: session.auto_approve || noApprovals,
|
|
295
304
|
model: session.model,
|
|
296
305
|
initialPrompt: initialPrompt || undefined,
|
|
297
306
|
});
|
|
@@ -313,7 +322,7 @@ function HomeContent() {
|
|
|
313
322
|
|
|
314
323
|
return { sessionName, cwd, command, hostId, sessionId };
|
|
315
324
|
},
|
|
316
|
-
[sessions, getInitScriptCommand]
|
|
325
|
+
[sessions, getInitScriptCommand, queryClient]
|
|
317
326
|
);
|
|
318
327
|
|
|
319
328
|
// Attach a session to a terminal
|
package/app/pair/page.tsx
CHANGED
|
@@ -85,8 +85,9 @@ export default function PairPage() {
|
|
|
85
85
|
<Logo />
|
|
86
86
|
<h1 className="text-xl font-semibold">Pair this device</h1>
|
|
87
87
|
<p className="text-muted-foreground text-sm">
|
|
88
|
-
On a device that already uses AgentOS, open
|
|
89
|
-
<span className="text-foreground">
|
|
88
|
+
On a device that already uses AgentOS, open{" "}
|
|
89
|
+
<span className="text-foreground">Settings</span>, choose{" "}
|
|
90
|
+
<span className="text-foreground">Devices & access</span>, then{" "}
|
|
90
91
|
<span className="text-foreground">Add a device</span>.
|
|
91
92
|
</p>
|
|
92
93
|
</div>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ShieldOff } from "lucide-react";
|
|
4
|
+
import { useApprovalsModeQuery } from "@/data/approvals";
|
|
5
|
+
import { settingsUiActions } from "@/stores/settingsUi";
|
|
6
|
+
|
|
7
|
+
// On screen for as long as No approvals mode is on; opens its Settings.
|
|
8
|
+
export function NoApprovalsBadge() {
|
|
9
|
+
const { data } = useApprovalsModeQuery();
|
|
10
|
+
if (!data?.on) return null;
|
|
11
|
+
return (
|
|
12
|
+
<button
|
|
13
|
+
type="button"
|
|
14
|
+
onClick={() => settingsUiActions.open("approvals")}
|
|
15
|
+
title="No approvals mode is on: nothing waits for a person. Open settings."
|
|
16
|
+
className="flex h-8 shrink-0 items-center gap-1 rounded-md bg-amber-500/20 px-2 text-xs font-medium text-amber-500 ring-1 ring-amber-500/50"
|
|
17
|
+
>
|
|
18
|
+
<ShieldOff className="h-3.5 w-3.5" />
|
|
19
|
+
No approvals
|
|
20
|
+
</button>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { useApprovalsModeQuery } from "@/data/approvals";
|
|
3
4
|
import {
|
|
4
5
|
Check,
|
|
5
6
|
ChevronDown,
|
|
@@ -44,8 +45,19 @@ export function AccessPicker({
|
|
|
44
45
|
access: ChatAccess;
|
|
45
46
|
onPick: (access: ChatAccess) => void;
|
|
46
47
|
}) {
|
|
47
|
-
const
|
|
48
|
+
const noApprovals = !!useApprovalsModeQuery().data?.on;
|
|
49
|
+
const current = LEVELS[noApprovals ? "full" : access] ?? LEVELS.full;
|
|
48
50
|
const Icon = current.icon;
|
|
51
|
+
if (noApprovals)
|
|
52
|
+
return (
|
|
53
|
+
<span
|
|
54
|
+
title="No approvals mode is on: every agent runs with full access"
|
|
55
|
+
className="text-muted-foreground flex h-10 shrink-0 items-center gap-1 px-2 text-xs"
|
|
56
|
+
>
|
|
57
|
+
<Icon className="h-3.5 w-3.5" />
|
|
58
|
+
<span className="hidden sm:inline">{current.label}</span>
|
|
59
|
+
</span>
|
|
60
|
+
);
|
|
49
61
|
return (
|
|
50
62
|
<DropdownMenu>
|
|
51
63
|
<DropdownMenuTrigger asChild>
|
|
@@ -39,6 +39,7 @@ import { usageUiActions } from "@/stores/usageUi";
|
|
|
39
39
|
import { WorkspaceSwitcher } from "./WorkspaceSwitcher";
|
|
40
40
|
import { LoadGauge } from "./LoadGauge";
|
|
41
41
|
import { AwayToggle } from "@/components/Away/AwayToggle";
|
|
42
|
+
import { NoApprovalsBadge } from "@/components/Approvals/NoApprovalsBadge";
|
|
42
43
|
import { useSchedulesGlance } from "./useSchedulesGlance";
|
|
43
44
|
import { cn } from "@/lib/utils";
|
|
44
45
|
|
|
@@ -71,6 +72,7 @@ export function SidebarHeader(props: SidebarHeaderProps) {
|
|
|
71
72
|
<LoadGauge />
|
|
72
73
|
</div>
|
|
73
74
|
<div className="ml-auto flex shrink-0 items-center gap-0.5">
|
|
75
|
+
<NoApprovalsBadge />
|
|
74
76
|
<AwayToggle />
|
|
75
77
|
<ADropdownMenu
|
|
76
78
|
trigger={
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import "@/components/Chat/composer/test-dom";
|
|
2
|
+
import { act, createElement } from "react";
|
|
3
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
// Radix dispatches its own events; they must be jsdom's, not Node's.
|
|
7
|
+
for (const key of ["Event", "CustomEvent", "FocusEvent", "HTMLButtonElement"])
|
|
8
|
+
Object.defineProperty(globalThis, key, {
|
|
9
|
+
value: (window as unknown as Record<string, unknown>)[key],
|
|
10
|
+
configurable: true,
|
|
11
|
+
writable: true,
|
|
12
|
+
});
|
|
13
|
+
(
|
|
14
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
15
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
16
|
+
|
|
17
|
+
const state = vi.hoisted(() => ({
|
|
18
|
+
on: false,
|
|
19
|
+
audit: [] as object[],
|
|
20
|
+
mutate: vi.fn(),
|
|
21
|
+
}));
|
|
22
|
+
vi.mock("@/data/approvals", () => ({
|
|
23
|
+
useApprovalsModeQuery: () => ({
|
|
24
|
+
isPending: false,
|
|
25
|
+
isError: false,
|
|
26
|
+
data: { on: state.on, audit: state.audit },
|
|
27
|
+
}),
|
|
28
|
+
useSetApprovalsMode: () => ({
|
|
29
|
+
mutate: state.mutate,
|
|
30
|
+
isPending: false,
|
|
31
|
+
error: null,
|
|
32
|
+
}),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
const { NoApprovalsSection } = await import("./NoApprovalsSection");
|
|
36
|
+
const { NoApprovalsBadge } =
|
|
37
|
+
await import("@/components/Approvals/NoApprovalsBadge");
|
|
38
|
+
|
|
39
|
+
let root: Root;
|
|
40
|
+
let host: HTMLDivElement;
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
state.on = false;
|
|
43
|
+
state.audit = [];
|
|
44
|
+
state.mutate.mockReset();
|
|
45
|
+
host = document.createElement("div");
|
|
46
|
+
document.body.appendChild(host);
|
|
47
|
+
root = createRoot(host);
|
|
48
|
+
});
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
act(() => root.unmount());
|
|
51
|
+
host.remove();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const render = (el: ReturnType<typeof createElement>) =>
|
|
55
|
+
act(() => root.render(el));
|
|
56
|
+
const click = (el: Element | null) =>
|
|
57
|
+
act(() => {
|
|
58
|
+
el?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
|
59
|
+
});
|
|
60
|
+
const byText = (text: string) =>
|
|
61
|
+
[...host.querySelectorAll("button")].find((b) =>
|
|
62
|
+
b.textContent?.includes(text)
|
|
63
|
+
) ?? null;
|
|
64
|
+
|
|
65
|
+
describe("the No approvals toggle", () => {
|
|
66
|
+
it("is off, and flipping it warns first without switching anything", () => {
|
|
67
|
+
render(createElement(NoApprovalsSection));
|
|
68
|
+
const toggle = host.querySelector('[role="switch"]');
|
|
69
|
+
expect(toggle?.getAttribute("aria-checked")).toBe("false");
|
|
70
|
+
expect(host.querySelector('[role="alertdialog"]')).toBeNull();
|
|
71
|
+
click(toggle!);
|
|
72
|
+
const warning = host.querySelector('[role="alertdialog"]');
|
|
73
|
+
expect(warning?.textContent).toMatch(/hard lines included/);
|
|
74
|
+
expect(warning?.textContent).toMatch(/permission prompts off/);
|
|
75
|
+
expect(state.mutate).not.toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("switches on only when the warning is confirmed, and cancel backs out", () => {
|
|
79
|
+
render(createElement(NoApprovalsSection));
|
|
80
|
+
click(host.querySelector('[role="switch"]'));
|
|
81
|
+
click(byText("Cancel"));
|
|
82
|
+
expect(host.querySelector('[role="alertdialog"]')).toBeNull();
|
|
83
|
+
expect(state.mutate).not.toHaveBeenCalled();
|
|
84
|
+
click(host.querySelector('[role="switch"]'));
|
|
85
|
+
click(byText("Turn on No approvals"));
|
|
86
|
+
expect(state.mutate).toHaveBeenCalledWith(true, expect.any(Object));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("switches off at once, and lists what was approved for you", () => {
|
|
90
|
+
state.on = true;
|
|
91
|
+
state.audit = [
|
|
92
|
+
{
|
|
93
|
+
id: 1,
|
|
94
|
+
at: "2026-10-13 10:00:00",
|
|
95
|
+
source: "ask",
|
|
96
|
+
subject: 'money ask "Pay?"',
|
|
97
|
+
detail: "",
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
render(createElement(NoApprovalsSection));
|
|
101
|
+
expect(host.textContent).toContain('money ask "Pay?"');
|
|
102
|
+
click(host.querySelector('[role="switch"]'));
|
|
103
|
+
expect(state.mutate).toHaveBeenCalledWith(false);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("the badge", () => {
|
|
108
|
+
it("shows only while the mode is on", () => {
|
|
109
|
+
render(createElement(NoApprovalsBadge));
|
|
110
|
+
expect(host.textContent).toBe("");
|
|
111
|
+
state.on = true;
|
|
112
|
+
render(createElement(NoApprovalsBadge));
|
|
113
|
+
expect(host.textContent).toContain("No approvals");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { SettingToggle } from "@/components/Settings/SettingToggle";
|
|
6
|
+
import { useApprovalsModeQuery, useSetApprovalsMode } from "@/data/approvals";
|
|
7
|
+
|
|
8
|
+
// What it switches off, said plainly before it can be switched on.
|
|
9
|
+
export const NO_APPROVALS_WARNING = [
|
|
10
|
+
"Every ask is approved the moment it is raised, hard lines included: public posts and messages, money, irreversible or destructive changes, credentials.",
|
|
11
|
+
"A PR that failed its gates twice, has no CI, or is too big to review still merges.",
|
|
12
|
+
"Brakes stop holding new starts, and plan-only tasks go on to implement without waiting for a plan approval.",
|
|
13
|
+
"Every agent AgentOS starts runs with its permission prompts off, and prompts already waiting are allowed.",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export function NoApprovalsSection() {
|
|
17
|
+
const { data, isPending, isError, error } = useApprovalsModeQuery();
|
|
18
|
+
const set = useSetApprovalsMode();
|
|
19
|
+
const [confirming, setConfirming] = useState(false);
|
|
20
|
+
|
|
21
|
+
if (isPending)
|
|
22
|
+
return <div className="bg-muted/40 h-16 animate-pulse rounded-lg" />;
|
|
23
|
+
if (isError)
|
|
24
|
+
return <p className="text-destructive text-sm">{error.message}</p>;
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className="space-y-3">
|
|
28
|
+
<SettingToggle
|
|
29
|
+
title="No approvals"
|
|
30
|
+
detail="Shuts off every approval AgentOS would otherwise wait on, and every agent's permission prompts. Off by default."
|
|
31
|
+
checked={data.on}
|
|
32
|
+
onChange={(on) => {
|
|
33
|
+
if (on) setConfirming(true);
|
|
34
|
+
else set.mutate(false);
|
|
35
|
+
}}
|
|
36
|
+
/>
|
|
37
|
+
{confirming && !data.on && (
|
|
38
|
+
<div
|
|
39
|
+
role="alertdialog"
|
|
40
|
+
aria-label="Turn on No approvals"
|
|
41
|
+
className="rounded-lg border border-red-500/20 bg-red-500/10 p-3"
|
|
42
|
+
>
|
|
43
|
+
<p className="mb-2 text-sm font-medium text-red-400">
|
|
44
|
+
Turn on No approvals?
|
|
45
|
+
</p>
|
|
46
|
+
<ul className="text-muted-foreground mb-3 list-disc space-y-1 pl-5 text-xs">
|
|
47
|
+
{NO_APPROVALS_WARNING.map((line) => (
|
|
48
|
+
<li key={line}>{line}</li>
|
|
49
|
+
))}
|
|
50
|
+
<li>
|
|
51
|
+
Terminals already running in an agent other than Claude keep
|
|
52
|
+
asking until they are restarted. Each approval made for you is
|
|
53
|
+
logged below.
|
|
54
|
+
</li>
|
|
55
|
+
</ul>
|
|
56
|
+
<div className="flex gap-2">
|
|
57
|
+
<Button
|
|
58
|
+
size="sm"
|
|
59
|
+
variant="destructive"
|
|
60
|
+
disabled={set.isPending}
|
|
61
|
+
onClick={() =>
|
|
62
|
+
set.mutate(true, { onSuccess: () => setConfirming(false) })
|
|
63
|
+
}
|
|
64
|
+
>
|
|
65
|
+
{set.isPending ? "Turning on..." : "Turn on No approvals"}
|
|
66
|
+
</Button>
|
|
67
|
+
<Button
|
|
68
|
+
size="sm"
|
|
69
|
+
variant="ghost"
|
|
70
|
+
disabled={set.isPending}
|
|
71
|
+
onClick={() => setConfirming(false)}
|
|
72
|
+
>
|
|
73
|
+
Cancel
|
|
74
|
+
</Button>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
)}
|
|
78
|
+
{set.error && (
|
|
79
|
+
<p className="text-destructive text-xs">{set.error.message}</p>
|
|
80
|
+
)}
|
|
81
|
+
<div>
|
|
82
|
+
<p className="mb-1 text-sm font-medium">Approved for you</p>
|
|
83
|
+
{data.audit.length === 0 ? (
|
|
84
|
+
<p className="text-muted-foreground text-xs">Nothing yet.</p>
|
|
85
|
+
) : (
|
|
86
|
+
<ul className="space-y-1">
|
|
87
|
+
{data.audit.map((row) => (
|
|
88
|
+
<li key={row.id} className="text-xs">
|
|
89
|
+
<span className="text-muted-foreground">
|
|
90
|
+
{row.at} · {row.source} ·{" "}
|
|
91
|
+
</span>
|
|
92
|
+
{row.subject}
|
|
93
|
+
{row.detail ? (
|
|
94
|
+
<span className="text-muted-foreground"> ({row.detail})</span>
|
|
95
|
+
) : null}
|
|
96
|
+
</li>
|
|
97
|
+
))}
|
|
98
|
+
</ul>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -28,6 +28,9 @@ const stub = (name: string) =>
|
|
|
28
28
|
vi.mock("@/components/Merge/GlobalMergeSection", () => ({
|
|
29
29
|
GlobalMergeSection: stub("merging"),
|
|
30
30
|
}));
|
|
31
|
+
vi.mock("./NoApprovalsSection", () => ({
|
|
32
|
+
NoApprovalsSection: stub("approvals"),
|
|
33
|
+
}));
|
|
31
34
|
vi.mock("@/components/Workspaces", () => ({
|
|
32
35
|
WorkspaceSettings: stub("workspaces"),
|
|
33
36
|
}));
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
GitMerge,
|
|
14
14
|
LayoutGrid,
|
|
15
15
|
ListChecks,
|
|
16
|
+
ShieldOff,
|
|
16
17
|
Smartphone,
|
|
17
18
|
X,
|
|
18
19
|
type LucideIcon,
|
|
@@ -38,6 +39,7 @@ import { schedulesUiActions } from "@/stores/schedulesUi";
|
|
|
38
39
|
import { RecipesPanel } from "@/components/Recipes";
|
|
39
40
|
import { settingsUi, settingsUiActions } from "@/stores/settingsUi";
|
|
40
41
|
import { SetupSection } from "./SetupSection";
|
|
42
|
+
import { NoApprovalsSection } from "./NoApprovalsSection";
|
|
41
43
|
import {
|
|
42
44
|
NotificationsSection,
|
|
43
45
|
type BrowserNotifications,
|
|
@@ -52,6 +54,11 @@ export const SECTION_INFO: Record<
|
|
|
52
54
|
hint: "Approval, merge method, branch clean-up",
|
|
53
55
|
icon: GitMerge,
|
|
54
56
|
},
|
|
57
|
+
approvals: {
|
|
58
|
+
label: "No approvals",
|
|
59
|
+
hint: "Switch off every approval and permission prompt",
|
|
60
|
+
icon: ShieldOff,
|
|
61
|
+
},
|
|
55
62
|
workspaces: {
|
|
56
63
|
label: "Workspaces",
|
|
57
64
|
hint: "Running task limit",
|
|
@@ -104,6 +111,8 @@ function SectionBody({
|
|
|
104
111
|
switch (section) {
|
|
105
112
|
case "merging":
|
|
106
113
|
return <GlobalMergeSection />;
|
|
114
|
+
case "approvals":
|
|
115
|
+
return <NoApprovalsSection />;
|
|
107
116
|
case "workspaces":
|
|
108
117
|
return <WorkspaceSettings />;
|
|
109
118
|
case "models":
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type { AuditRow } from "@/lib/approvals/no-approvals";
|
|
3
|
+
import { provePresence } from "../presence";
|
|
4
|
+
import { usePollWhenOffline } from "../push/connection";
|
|
5
|
+
|
|
6
|
+
export interface ApprovalsMode {
|
|
7
|
+
on: boolean;
|
|
8
|
+
audit: AuditRow[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const approvalsKeys = { all: ["approvals"] as const };
|
|
12
|
+
|
|
13
|
+
async function json(res: Response): Promise<ApprovalsMode> {
|
|
14
|
+
const data = await res.json();
|
|
15
|
+
if (!res.ok) throw new Error(data.error || "Request failed");
|
|
16
|
+
return data;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Shared with the code that builds a terminal's launch command.
|
|
20
|
+
export const approvalsModeQuery = {
|
|
21
|
+
queryKey: approvalsKeys.all,
|
|
22
|
+
queryFn: async () => json(await fetch("/api/approvals")),
|
|
23
|
+
staleTime: 10_000,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function useApprovalsModeQuery() {
|
|
27
|
+
return useQuery({
|
|
28
|
+
...approvalsModeQuery,
|
|
29
|
+
refetchInterval: usePollWhenOffline(60_000),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Turning it on needs a passkey; turning it off doesn't.
|
|
34
|
+
export function useSetApprovalsMode() {
|
|
35
|
+
const queryClient = useQueryClient();
|
|
36
|
+
return useMutation({
|
|
37
|
+
mutationFn: async (on: boolean) =>
|
|
38
|
+
json(
|
|
39
|
+
await fetch("/api/approvals", {
|
|
40
|
+
method: "PUT",
|
|
41
|
+
headers: { "Content-Type": "application/json" },
|
|
42
|
+
body: JSON.stringify({
|
|
43
|
+
on,
|
|
44
|
+
...(on
|
|
45
|
+
? {
|
|
46
|
+
assertion: await provePresence({
|
|
47
|
+
purpose: "no-approvals-on",
|
|
48
|
+
}),
|
|
49
|
+
}
|
|
50
|
+
: {}),
|
|
51
|
+
}),
|
|
52
|
+
})
|
|
53
|
+
),
|
|
54
|
+
onSuccess: (data) => queryClient.setQueryData(approvalsKeys.all, data),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Switching No approvals mode: the one place that turns it on or off and
|
|
2
|
+
// brings what already exists in line with it.
|
|
3
|
+
|
|
4
|
+
import { db } from "../db";
|
|
5
|
+
import { reapplyChatModes } from "../chat/settings";
|
|
6
|
+
import { approvePlanNow } from "../models/plan-step";
|
|
7
|
+
import { autoApproveAsk, type AskRow } from "../orchestrator/asks";
|
|
8
|
+
import { addNote } from "../orchestrator/notes";
|
|
9
|
+
import { listOrchestrators } from "../orchestrator/home";
|
|
10
|
+
import {
|
|
11
|
+
noApprovalsOn,
|
|
12
|
+
recordAutoApproval,
|
|
13
|
+
setNoApprovals,
|
|
14
|
+
} from "./no-approvals";
|
|
15
|
+
|
|
16
|
+
// Everything already waiting when the mode goes on is approved: open asks,
|
|
17
|
+
// plan-only tasks at their plan, tool cards in live chats.
|
|
18
|
+
async function sweep(): Promise<void> {
|
|
19
|
+
const asks = db
|
|
20
|
+
.prepare(`SELECT * FROM orchestrator_asks WHERE status = 'open'`)
|
|
21
|
+
.all() as AskRow[];
|
|
22
|
+
for (const ask of asks) {
|
|
23
|
+
try {
|
|
24
|
+
autoApproveAsk(ask.workspace_id, ask);
|
|
25
|
+
} catch (e) {
|
|
26
|
+
console.error(`auto-approving ask ${ask.id} failed:`, e);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const plans = db
|
|
30
|
+
.prepare(
|
|
31
|
+
`SELECT id FROM sessions WHERE plan_phase = 'awaiting' AND task_status = 'running'`
|
|
32
|
+
)
|
|
33
|
+
.all() as { id: string }[];
|
|
34
|
+
for (const p of plans) {
|
|
35
|
+
try {
|
|
36
|
+
await approvePlanNow(p.id, undefined, "no-approvals");
|
|
37
|
+
} catch (e) {
|
|
38
|
+
console.error(`auto-approving plan of ${p.id} failed:`, e);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function switchNoApprovals(on: boolean): Promise<void> {
|
|
44
|
+
if (on === noApprovalsOn()) return;
|
|
45
|
+
setNoApprovals(on);
|
|
46
|
+
recordAutoApproval({
|
|
47
|
+
source: "mode",
|
|
48
|
+
subject: on
|
|
49
|
+
? "No approvals mode turned on"
|
|
50
|
+
: "No approvals mode turned off",
|
|
51
|
+
});
|
|
52
|
+
for (const o of listOrchestrators()) {
|
|
53
|
+
if (!o.workspace_id) continue;
|
|
54
|
+
addNote(
|
|
55
|
+
o.workspace_id,
|
|
56
|
+
on
|
|
57
|
+
? "No approvals mode is on: every ask is approved as it's raised, and agents run without permission prompts."
|
|
58
|
+
: "No approvals mode is off: asks wait for Saad again.",
|
|
59
|
+
"ask"
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (on) await sweep();
|
|
63
|
+
reapplyChatModes();
|
|
64
|
+
}
|