Skip to content
This repository was archived by the owner on Sep 17, 2026. It is now read-only.
Merged
25 changes: 24 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,13 @@ src/
│ │ ├── pipeline-railroad.tsx # Visual pipeline step list
│ │ └── studio-editor-panel.tsx # In-place editor for selected stage
│ ├── layout/ # Sidebar, top-bar, theme-provider
│ ├── operator/ # Platform Operator (activation, chat, status)
│ ├── shared/ # Reusable shared components (command palette, view toggle, etc.)
│ └── ui/ # Low-level UI primitives (button, badge, dialog, etc.)
├── hooks/ # TanStack Query hooks
├── lib/
│ ├── api/ # API modules (agents.ts, resources.ts, backup.ts, etc.)
│ ├── operator/ # Operator tool allow-list + system prompt
│ ├── api-client.ts # Base fetch wrapper with auth header injection
│ └── constants.ts # Shared constants (ENVIRONMENTS, etc.)
├── i18n/locales/ # 11 locale JSON files
Expand Down Expand Up @@ -171,7 +173,28 @@ All 9 resource types are defined in `src/lib/api/resources.ts` as `RESOURCE_TYPE
- **Always add to `en.json` first**, then propagate to all 10 other locale files
- Use inline fallbacks: `t("key", "Fallback")`

#### 5. Tests
#### 5. Platform Operator

An opt-in, admin-activated agent that inspects this EDDI deployment and explains
what it finds. Off by default. Worth knowing before touching it:

- **It is a real EDDI agent**, provisioned through `setup-api` from EDDI's own
OpenAPI spec. It shows up in the Agents list with an "Operator" badge; editing
or deleting it there breaks the operator screen.
- **Its capability boundary is the allow-list** in `src/lib/operator/tool-scopes.ts`
— an allow-list, never a deny-list, because a deny-list silently grants any
endpoint the backend adds later. Writes are unreachable until an approval
handler exists (`isWriteScopeAvailable`).
- **Config is one atomic JSON blob** in the `platform.operator` global variable.
Activation writes several values that must land together and the variable
store has no transaction.
- **`authMode: "caller-identity"`** makes tool calls run as the signed-in user
via the backend's `${caller:token}` resolver (EDDI 6.2.0+). `"none"` is
blocked at activation when OIDC is on, because every tool call would 401.
- **Activation runs a canary** — one probe read counting tool calls — because a
READY deployment badge says nothing about whether the tools can authenticate.

#### 6. Tests

- Unit tests in `src/pages/__tests__/` — naming: `resource-detail-{type}.test.tsx`
- Use `renderPage(type)` helper with `MemoryRouter` + `QueryClient` + `ThemeProvider`
Expand Down
11 changes: 8 additions & 3 deletions HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,17 @@
- Boardroom files: `src/pages/boardroom/`, `src/components/boardroom/`, `src/styles/advisory.css`

### Test Counts
- 277 test files passing (EDDI-Manager)
- 4013 Tests passing (`npm run test`)
- 281 test files passing (EDDI-Manager)
- 4127 Tests passing (`npm run test`)
- 112 Backend tenancy tests passing (`mvn test`)

### Last Commit Focus
- Frontend: `feat: fix live logs — REST seeding, SSE resilience, better empty states` on `fix/group-chat-defensive-rendering` (`e5303c5e`)
- Frontend: `feat(operator): add the Platform Operator agent (P1, read-only)` on `feat/platform-operator-agent`
- Opt-in, admin-activated agent that inspects this EDDI deployment through its own REST API, exposed as tools via `setup-api`. Read-only allow-list (`src/lib/operator/tool-scopes.ts`), non-editable safety preamble, single-blob `platform.operator` config, activation flow with a post-provision spec check, operator screen with a live tool-activity trace, dashboard discovery card, kill switch, `operator.*` i18n across 11 locales.
- **Design correction:** the design assumed EDDI forwards the caller's token to an API agent's tool calls. It did not. That gap is now closed in the backend (labsai/EDDI#613) by a `${caller:token}` resolver, and the operator's `authMode: "caller-identity"` uses it: EDDI substitutes the token while building the request, releasing it only for a same-origin call, only into a header, and never persisting it. `"none"` remains the default and is blocked at activation when OIDC is on, since every tool call would 401.
- **Review pass:** added the post-deploy canary the design asked for (one probe read; it counts tool calls and detects 401s, since a READY badge proves nothing about whether the generated tools can authenticate), made activation honour setup-api's `deployed`/`deploymentStatus` and reject the `"unknown"` agent-id fallback, added a redeploy-in-place path so re-enabling a paused operator no longer rebuilds it, fixed the activation form's accessibility (not one control had an accessible name), and stopped `{{count}}` triggering i18next pluralization.
- **Contract bug fixed:** `createApiAgent` sent `name`; the backend requires `agentName` and rejects a blank one, so the wizard's API-agent path was broken. Fixed in the type, the wizard, the MSW mock (now rejects blank, as the backend does) and three tests that had been passing vacuously.
- Previous frontend: `feat: fix live logs — REST seeding, SSE resilience, better empty states` on `fix/group-chat-defensive-rendering` (`e5303c5e`)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- REST API seeding on first SSE connect (session-log-store.ts), BearerEventSource \r\n handling + 45s inactivity timeout, `seeded` state exposed through use-logs hook, 3-state empty view (loading/no-activity/connecting), 3 new i18n keys across 11 locales, 3 new tests
- Backend: `feat: add SSE heartbeat to log stream endpoint` on `feat/v6.2.0-prep` (`cd0551925`)
- 15s heartbeat comment events in RestLogAdmin.streamLogs(), AtomicLong lastEventTime tracking, 3 new Mockito tests (idle heartbeat, recent-event suppression, closed-sink stop)
2 changes: 2 additions & 0 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { ChannelsPage } from "@/pages/channels";
import { ChannelDetailPage } from "@/pages/channel-detail";
import { ApprovalsPage } from "@/pages/approvals";
import { LandingPage } from "@/pages/landing-page";
import { OperatorPage } from "@/pages/operator";

import { WorkforceLayout } from "@/components/workforce/workforce-layout";
import { WorkforceDashboard } from "@/pages/workforce/workforce-dashboard";
Expand Down Expand Up @@ -92,6 +93,7 @@ export function App() {
<Route path="/manage/workflowview/:id" element={<WorkflowDetailPage />} />
<Route path="/manage/conversations" element={<ConversationsPage />} />
<Route path="/manage/conversations/monitoring" element={<ConversationMonitoringPage />} />
<Route path="/manage/operator" element={<OperatorPage />} />
<Route path="/manage/coordinator" element={<CoordinatorPage />} />
<Route path="/manage/schedules" element={<SchedulesPage />} />
<Route path="/manage/logs" element={<LogsPage />} />
Expand Down
18 changes: 18 additions & 0 deletions src/components/agents/agent-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ import {
ExternalLink,
Download,
MessageSquare,
Sparkles,
} from "lucide-react";
import { cn, formatRelativeTime } from "@/lib/utils";
import { useDeploymentStatus, useDeployAgent, useUndeployAgent } from "@/hooks/use-agents";

import { useChatDrawerStore } from "@/hooks/use-chat-drawer";
import { useChatStore, useStartConversation } from "@/hooks/use-chat";
import { useOperatorConfig } from "@/hooks/use-operator";
import { getErrorMessage } from "@/lib/api-client";
import type { AgentDescriptor } from "@/lib/api/agents";
import { useState, useCallback, useRef, useEffect } from "react";
Expand All @@ -38,6 +40,8 @@ const statusIcons = {
};

export function AgentCard({ agent, onDuplicate, onDelete, onExport }: AgentCardProps) {
const { data: operatorConfig } = useOperatorConfig();
const isOperatorAgent = Boolean(operatorConfig?.agentId && operatorConfig.agentId === agent.id);
const { t } = useTranslation();
const [menuOpen, setMenuOpen] = useState(false);
const menuTriggerRef = useRef<HTMLButtonElement>(null);
Expand Down Expand Up @@ -113,6 +117,20 @@ export function AgentCard({ agent, onDuplicate, onDelete, onExport }: AgentCardP
{statusLabel}
</div>

{/* The Platform Operator agent is provisioned and owned by the operator
screen. Editing or deleting it here leaves that screen pointing at
nothing, so say who owns it. */}
{isOperatorAgent && (
<span
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary"
data-testid={`agent-managed-${agent.id}`}
title={t("operator.managedAgentHint", "Provisioned and managed by the Platform Operator screen. Editing or deleting it here will break that screen.")}
>
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
{t("operator.managedAgentBadge", "Operator")}
</span>
)}

{/* Context menu */}
<div className="relative">
<button
Expand Down
2 changes: 2 additions & 0 deletions src/components/layout/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
Cable,
Variable,
HandMetal,
Sparkles,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useAuth } from "@/hooks/use-auth";
Expand All @@ -49,6 +50,7 @@ const navSections = [
labelKey: "nav.sectionCore",
items: [
{ path: "/manage", icon: LayoutDashboard, labelKey: "nav.dashboard" },
{ path: "/manage/operator", icon: Sparkles, labelKey: "nav.operator" },
{ path: "/manage/agents", icon: Bot, labelKey: "nav.agents" },
{ path: "/manage/workflows", icon: Workflow, labelKey: "nav.packages" },
{ path: "/manage/groups", icon: Boxes, labelKey: "nav.groups" },
Expand Down
252 changes: 252 additions & 0 deletions src/components/operator/__tests__/operator-activation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import { renderWithProviders, userEvent } from "@/test/test-utils";
import { http, HttpResponse } from "msw";
import { server } from "@/test/mocks/server";
import { OperatorActivation } from "../operator-activation";
import { extractVaultKeyName } from "@/lib/operator/vault-ref";
import { defaultOperatorConfig } from "@/lib/api/operator";

const authState = { method: "none" as "none" | "keycloak" };
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
authenticated: true,
loading: false,
user: null,
roles: [],
method: authState.method,
login: () => {},
logout: () => {},
}),
useHasRole: () => true,
}));

function renderActivation(overrides: Partial<Parameters<typeof OperatorActivation>[0]> = {}) {
const onActivate = vi.fn();
renderWithProviders(
<OperatorActivation
initial={defaultOperatorConfig("Body text.")}
stage="idle"
error={null}
onActivate={onActivate}
{...overrides}
/>,
);
return { onActivate };
}

describe("OperatorActivation", () => {
beforeEach(() => {
authState.method = "none";
server.resetHandlers();
server.use(
http.get("*/secretstore/secrets/health", () =>
HttpResponse.json({ status: "UP", provider: "local", available: true }),
),
http.get("*/secretstore/secrets/default", () => HttpResponse.json([])),
);
});

// Regression guard: every control was previously anonymous to assistive
// tech — a bare <label> with no htmlFor next to an id-less control.
describe("accessibility", () => {
it("gives every native control an accessible name", () => {
renderActivation();
expect(screen.getByLabelText(/^provider$/i)).toBeInTheDocument();
expect(screen.getByLabelText(/^model$/i)).toBeInTheDocument();
expect(screen.getByLabelText(/^environment$/i)).toBeInTheDocument();
});

it("names the composite credential and auth-mode controls", () => {
renderActivation();
expect(
screen.getByRole("group", { name: /model api key/i }),
).toBeInTheDocument();
expect(
screen.getByRole("radiogroup", { name: /how the operator authenticates/i }),
).toBeInTheDocument();
});

it("announces activation progress", async () => {
renderActivation({ stage: "provisioning" });
await userEvent.type(screen.getByTestId("operator-api-key-input"), "sk-test-key");
await userEvent.click(screen.getByTestId("operator-next"));
const stage = await screen.findByTestId("operator-activation-stage");
expect(stage).toHaveAttribute("aria-live", "polite");
});
});

describe("reconfiguring an existing operator", () => {
it("pre-fills the stored vault key so the credential need not be re-entered", async () => {
renderActivation({
initial: {
...defaultOperatorConfig("Body text."),
provider: "anthropic",
credentialKey: "operator-llm-key",
},
});
// Ready to continue without touching the key field.
await waitFor(() =>
expect(screen.getByTestId("operator-next")).not.toBeDisabled(),
);
});

it("warns that saving replaces the existing agent", async () => {
renderActivation({
initial: {
...defaultOperatorConfig("Body text."),
agentId: "op-1",
version: 1,
credentialKey: "operator-llm-key",
},
});
await userEvent.click(await screen.findByTestId("operator-next"));
// setup-api only creates, so reconfiguring is not an in-place edit.
expect(await screen.findByTestId("operator-rebuild-warning")).toBeInTheDocument();
});

it("does not warn about a rebuild on first activation", async () => {
renderActivation();
await userEvent.type(screen.getByTestId("operator-api-key-input"), "sk-test-key");
await userEvent.click(screen.getByTestId("operator-next"));
await screen.findByTestId("operator-activate");
expect(screen.queryByTestId("operator-rebuild-warning")).not.toBeInTheDocument();
});

it("clears the key when the provider changes, since keys are provider-specific", async () => {
renderActivation({
initial: {
...defaultOperatorConfig("Body text."),
provider: "anthropic",
credentialKey: "operator-llm-key",
},
});
await userEvent.selectOptions(screen.getByTestId("operator-provider"), "openai");
await waitFor(() =>
expect(screen.getByTestId("operator-next")).toBeDisabled(),
);
});
});

it("states plainly that the operator is read-only", () => {
renderActivation();
expect(screen.getAllByText(/read-only/i).length).toBeGreaterThan(0);
});

it("blocks the next step until a model key is supplied", async () => {
renderActivation();
expect(screen.getByTestId("operator-next")).toBeDisabled();

await userEvent.type(screen.getByTestId("operator-api-key-input"), "sk-test-key");
await waitFor(() =>
expect(screen.getByTestId("operator-next")).not.toBeDisabled(),
);
});

it("does not require a key for a local provider, but does require a base URL", async () => {
renderActivation();
await userEvent.selectOptions(screen.getByTestId("operator-provider"), "ollama");

// Local provider needs no key, so only the base URL gates progress.
await waitFor(() => expect(screen.getByTestId("operator-base-url")).toBeInTheDocument());
expect(screen.getByTestId("operator-next")).toBeDisabled();

await userEvent.type(screen.getByTestId("operator-base-url"), "http://localhost:11434");
await waitFor(() =>
expect(screen.getByTestId("operator-next")).not.toBeDisabled(),
);
});

it("warns when the vault is unavailable so the key step isn't silently unusable", async () => {
server.use(
http.get("*/secretstore/secrets/health", () =>
HttpResponse.json(
{ status: "DOWN", provider: "local", available: false },
{ status: 503 },
),
),
);
renderActivation();
expect(await screen.findByText(/secrets vault is unavailable/i)).toBeInTheDocument();
});

describe("auth mode gating", () => {
it("allows the no-credentials mode when authentication is disabled", async () => {
renderActivation();
await userEvent.type(screen.getByTestId("operator-api-key-input"), "sk-test-key");
expect(screen.queryByTestId("operator-auth-blocked")).not.toBeInTheDocument();

await userEvent.click(screen.getByTestId("operator-next"));
expect(await screen.findByTestId("operator-activate")).not.toBeDisabled();
});

it("blocks the no-credentials mode when OIDC is enabled", async () => {
// Tool calls would carry no Authorization header and 401 on every lookup,
// so the operator would deploy READY and then be useless.
authState.method = "keycloak";
renderActivation();
await userEvent.type(screen.getByTestId("operator-api-key-input"), "sk-test-key");

expect(await screen.findByTestId("operator-auth-blocked")).toBeInTheDocument();
await userEvent.click(screen.getByTestId("operator-next"));
expect(await screen.findByTestId("operator-activate")).toBeDisabled();
});

it("unblocks once caller-identity is chosen", async () => {
authState.method = "keycloak";
const { onActivate } = renderActivation();
await userEvent.type(screen.getByTestId("operator-api-key-input"), "sk-test-key");
await userEvent.click(screen.getByTestId("operator-auth-caller-identity"));

// No acknowledgement to click: EDDI resolves ${caller:token} server-side,
// so nothing about the token is persisted for the admin to accept.
await userEvent.click(screen.getByTestId("operator-next"));
const activate = await screen.findByTestId("operator-activate");
expect(activate).not.toBeDisabled();

await userEvent.click(activate);
expect(onActivate).toHaveBeenCalledTimes(1);
expect(onActivate.mock.calls[0]![0]).toMatchObject({
authMode: "caller-identity",
scope: "read_only",
});
});

it("explains what caller-identity does when it is selected", async () => {
renderActivation();
await userEvent.click(screen.getByTestId("operator-auth-caller-identity"));
expect(await screen.findByText(/never stored/i)).toBeInTheDocument();
});
});

it("surfaces an activation error instead of failing silently", async () => {
renderActivation({ error: "This EDDI deployment does not expose 2 endpoint(s)" });
await userEvent.type(screen.getByTestId("operator-api-key-input"), "sk-test-key");
await userEvent.click(screen.getByTestId("operator-next"));
expect(await screen.findByTestId("operator-activation-error")).toHaveTextContent(
/does not expose 2 endpoint/i,
);
});

it("shows which stage activation is in", async () => {
renderActivation({ stage: "provisioning" });
await userEvent.type(screen.getByTestId("operator-api-key-input"), "sk-test-key");
await userEvent.click(screen.getByTestId("operator-next"));
expect(await screen.findByTestId("operator-activation-stage")).toBeInTheDocument();
});
});

describe("extractVaultKeyName", () => {
it("pulls the key name from the canonical reference", () => {
expect(extractVaultKeyName("vault:openai-key")).toBe("openai-key");
expect(extractVaultKeyName("${vault:openai-key}")).toBe("openai-key");
});

it("accepts the legacy prefix", () => {
expect(extractVaultKeyName("${eddivault:openai-key}")).toBe("openai-key");
});

it("returns null for a plain-text secret, so no secret is stored as a 'key name'", () => {
expect(extractVaultKeyName("sk-actual-secret-value")).toBeNull();
});
});
Loading