Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 41 additions & 16 deletions src/app/(app)/w/[slug]/settings/runtimes/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)}
/>
<span className="min-w-0">
<span className="block font-medium text-foreground">Local workspace tools enabled</span>
Expand Down Expand Up @@ -1167,6 +1191,7 @@ function RuntimeToolPolicyFields({
className="font-mono"
/>
</label>
{confirmElement}
</div>
);
}
Expand Down
80 changes: 80 additions & 0 deletions src/server/routers/__tests__/runtime-admin-gating.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
10 changes: 8 additions & 2 deletions src/server/routers/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -395,7 +401,7 @@ export const runtimeRouter = router({
return withRuntimeHealth(row);
}),

update: workspaceProcedure
update: adminProcedure
.input(
z.object({
id: runtimeId,
Expand Down
Loading