diff --git a/CHANGELOG.md b/CHANGELOG.md index 28805c0..8b51b94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,9 +18,12 @@ date, grouped by `Added` / `Changed` / `Fixed` / `Removed`. ### Changed - **The dashboard cockpit now adapts to your screen width.** The two-column layout only splits into a work column + rail once there's genuinely room for it, so a laptop-width window doesn't squeeze your issue cards uncomfortably narrow — it stays single-column a little longer instead. The rail's widgets also use the space better on medium-width screens instead of stacking one-per-row. +- **Configuring a runtime now requires workspace admin.** Creating or editing a runtime in Settings → Runtimes — including its host tool permissions (whether an agent may use terminal / filesystem / git on the host) — is now restricted to workspace owners and admins, matching the existing admin-only gate on that runtime's secrets and repositories. Regular members could previously change it. ### Fixed +- **Turning off a runtime's "Local workspace tools" no longer silently wipes its per-mode tool grants.** That toggle clears the runtime's declared tools and every per-mode allowlist (including Research/Review read access), so it now asks you to confirm first — a stray click can't quietly erase the configuration. + - **A Research/Review/Discuss-mode agent no longer gets marked "stalled" after it replies.** Those modes are designed to never move the issue's status — only to comment — but the stalled-work check was watching for a status change regardless, so a well-behaved research reply looked identical to a dead assignment once enough time passed. It could even repeat indefinitely on workspaces with auto-redispatch on. Fixed to skip the stalled check entirely for non-Execute dispatches, matching the documented behavior. ## [2026-07-07] — v0.8.0 · Dashboard redesign, cross-workspace moves & agent-runtime hardening diff --git a/DEVLOG.md b/DEVLOG.md index 09539b2..f5cf8b7 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,39 @@ > Append-only session log. Read at session start. Update at session end. +## 2026-07-08 — Runtime settings: admin-gate config writes + confirm destructive tool-reset + +Follow-up from the `rt_hermes_gateway` RESEARCH/REVIEW tool-profile fix (Victor +couldn't inspect the repo during research because those mode profiles were +explicit empty `[]`, which Hermes reads as "disable all host toolsets"). While +auditing the runtimes settings surface, found two rough edges — both fixed here. +Forge-side only; **holding for deploy** (more changes coming). + +- **Gating asymmetry (privilege boundary).** `runtime.create` / `runtime.update` + were `workspaceProcedure` (any member), but they write `config` — which + carries `modeToolProfiles` / `localWorkspaceTools`, i.e. the host tool policy + that decides whether an agent gets terminal/filesystem/git on the host. The + same runtime's secrets/repos/githubApp and the MCP `runtimes.configure` + mirror are all `adminProcedure`/ADMIN, and the `/settings` layout has no role + gate — so a non-admin member could reach Settings → Runtimes and widen host + access. Fixed: `create`/`update` → `adminProcedure`. Daemon self-registration + is unaffected (uses `register`, still `workspaceProcedure`); read/diagnostic + paths (list, heartbeat, verifyConnection, runSelfTest) unchanged. New + regression `runtime-admin-gating.test.ts`: OWNER updates; MEMBER gets + FORBIDDEN on create + update; row untouched. +- **Destructive master-toggle footgun.** In `RuntimeToolPolicyFields`, the + "Local workspace tools enabled" checkbox, when switched OFF, wiped + `toolCapabilities` → `[]` and reset every `modeToolProfiles` entry to empty — + silently dropping RESEARCH/REVIEW read grants with no confirm. Wrapped the + off-path in `useConfirm()` (destructive variant) when there's a non-empty + policy to lose; on-path unchanged. Controlled checkbox reverts on cancel. + +Verification: typecheck + lint clean; `runtime-admin-gating` (2) + +`runtime-secrets` (5) + `runtime-github-app` (8) + `runtime-dispatch-contract` +(10) + `action-request-accept` (15) + `mcp` (120) all green. UI confirm is +typecheck-verified + follows the established `useConfirm` pattern; not +live-clicked (no prod login from this runtime; dev:local boot deferred). + ## 2026-07-07 — Auto-transition to review on EXECUTE completion (`reviewStatusId`) Follow-up to today's "does a successful run properly resolve the issue" gap diff --git a/src/app/(app)/w/[slug]/settings/runtimes/page.tsx b/src/app/(app)/w/[slug]/settings/runtimes/page.tsx index 56a8072..5508a71 100644 --- a/src/app/(app)/w/[slug]/settings/runtimes/page.tsx +++ b/src/app/(app)/w/[slug]/settings/runtimes/page.tsx @@ -26,7 +26,7 @@ import { Topbar } from "@/components/topbar"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Avatar } from "@/components/ui/avatar"; -import { Confirm, QuickForm } from "@/components/ui/modal"; +import { Confirm, QuickForm, useConfirm } from "@/components/ui/modal"; import { Combobox } from "@/components/ui/combobox"; import { Card } from "@/components/settings/card"; import { EmptyState } from "@/components/settings/empty-state"; @@ -985,6 +985,44 @@ function RuntimeToolPolicyFields({ onChange: (v: RuntimeToolPolicy) => void; }) { const declaredTools = new Set(value.toolCapabilities); + const { confirm, confirmElement } = useConfirm(); + + // Toggling the master "local workspace tools" flag OFF clears declared tools + // AND every per-mode allowlist — a destructive reset that silently drops any + // RESEARCH/REVIEW read grants. Confirm before wiping a non-empty policy. + async function handleLocalWorkspaceToggle(checked: boolean) { + if (checked) { + onChange({ + ...value, + localWorkspaceTools: true, + toolCapabilities: [...RUNTIME_TOOL_CAPABILITIES], + modeToolProfiles: { + ...value.modeToolProfiles, + EXECUTE: [...RUNTIME_TOOL_CAPABILITIES], + }, + }); + return; + } + const hasPolicy = + value.toolCapabilities.length > 0 || + RUNTIME_ENGAGEMENT_MODES.some((mode) => (value.modeToolProfiles[mode] ?? []).length > 0); + if (hasPolicy) { + const ok = await confirm({ + title: "Disable local workspace tools?", + description: + "This clears the runtime's declared tools and every per-mode allowlist — including RESEARCH/REVIEW read access. You'll have to re-grant them.", + primaryLabel: "Disable & clear", + variant: "destructive", + }); + if (!ok) return; + } + onChange({ + ...value, + localWorkspaceTools: false, + toolCapabilities: [], + modeToolProfiles: emptyModeToolProfiles(), + }); + } function toggleTool(tool: RuntimeToolCapability, checked: boolean) { const next = new Set(value.toolCapabilities); @@ -1041,21 +1079,7 @@ function RuntimeToolPolicyFields({ type="checkbox" className="mt-0.5" checked={value.localWorkspaceTools} - onChange={(e) => - onChange({ - ...value, - localWorkspaceTools: e.target.checked, - toolCapabilities: e.target.checked - ? [...RUNTIME_TOOL_CAPABILITIES] - : [], - modeToolProfiles: e.target.checked - ? { - ...value.modeToolProfiles, - EXECUTE: [...RUNTIME_TOOL_CAPABILITIES], - } - : emptyModeToolProfiles(), - }) - } + onChange={(e) => void handleLocalWorkspaceToggle(e.target.checked)} /> Local workspace tools enabled @@ -1167,6 +1191,7 @@ function RuntimeToolPolicyFields({ className="font-mono" /> + {confirmElement} ); } diff --git a/src/server/routers/__tests__/runtime-admin-gating.test.ts b/src/server/routers/__tests__/runtime-admin-gating.test.ts new file mode 100644 index 0000000..b5ad37e --- /dev/null +++ b/src/server/routers/__tests__/runtime-admin-gating.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, afterEach, afterAll } from "vitest"; +import { RuntimeKind } from "@prisma/client"; +import { runtimeRouter } from "@/server/routers/runtime"; +import { + buildContext, + createWorkspaceFixture, + disconnectPrisma, + getPrisma, + type TestFixture, +} from "./helpers"; + +// runtime.create/update write `config` (the host tool policy that decides +// whether an agent gets terminal/filesystem/git on the host), so both are +// admin-gated — matching the runtime's secrets/repos and the MCP +// `runtimes.configure` mirror. A plain workspace MEMBER must not be able to +// widen an agent's host access from the settings UI. + +const fixtures: TestFixture[] = []; + +afterEach(async () => { + while (fixtures.length) { + await fixtures.pop()!.cleanup(); + } +}); + +afterAll(async () => { + await disconnectPrisma(); +}); + +async function seedRuntime(fixture: TestFixture) { + return getPrisma().runtime.create({ + data: { + workspaceId: fixture.workspace.id, + ownerId: fixture.user.id, + name: "seed runtime", + kind: RuntimeKind.REMOTE_HTTP, + adapterKey: "codex-app-server", + }, + select: { id: true }, + }); +} + +describe("runtime create/update admin gating", () => { + it("lets an OWNER update a runtime", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "RGA" }); + fixtures.push(fixture); + const runtime = await seedRuntime(fixture); + + const ownerCaller = runtimeRouter.createCaller(await buildContext(fixture)); + const updated = await ownerCaller.update({ id: runtime.id, name: "renamed by owner" }); + expect(updated.id).toBe(runtime.id); + expect(updated.name).toBe("renamed by owner"); + }); + + it("forbids a non-admin MEMBER from creating or updating a runtime", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "RGB" }); + fixtures.push(fixture); + const runtime = await seedRuntime(fixture); + + // secondUser is seeded as a MEMBER (not OWNER/ADMIN) by the fixture. + const memberCaller = runtimeRouter.createCaller( + await buildContext(fixture, { asUserId: fixture.secondUser.id }), + ); + + await expect( + memberCaller.create({ adapterKey: "codex-app-server", name: "member runtime" }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + + await expect( + memberCaller.update({ id: runtime.id, name: "member rename" }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + + // The runtime is untouched. + const row = await getPrisma().runtime.findUniqueOrThrow({ + where: { id: runtime.id }, + select: { name: true }, + }); + expect(row.name).toBe("seed runtime"); + }); +}); diff --git a/src/server/routers/runtime.ts b/src/server/routers/runtime.ts index b3d09bd..f43aaf6 100644 --- a/src/server/routers/runtime.ts +++ b/src/server/routers/runtime.ts @@ -358,7 +358,13 @@ export const runtimeRouter = router({ * operator picks "what manages this" rather than a low-level kind. The * `forge` daemon still self-registers LOCAL_DAEMON rows via `register`. */ - create: workspaceProcedure + // Admin-gated: create/update write `config`, which carries the host tool + // policy (whether an agent gets terminal/filesystem/git on the host). That's + // a privilege-granting control, so it matches the ADMIN gate on this + // runtime's secrets/repos and the MCP `runtimes.configure` mirror — rather + // than the member-level `workspaceProcedure` used for daemon self-registration + // (`register`) and read/diagnostic paths. + create: adminProcedure .input( z.object({ adapterKey: z.string().min(1).max(60), @@ -395,7 +401,7 @@ export const runtimeRouter = router({ return withRuntimeHealth(row); }), - update: workspaceProcedure + update: adminProcedure .input( z.object({ id: runtimeId,