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
72 changes: 72 additions & 0 deletions docs/keiko-editor/1491-production-readiness-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Epic #1491 production readiness audit

Audit timestamp: 2026-06-26T12:13:23Z

Scope:

- Epic #1491: Keiko Agent-Native Editor Foundation and Runtime Governance.
- Final child issue #1389: Runtime, Git, and command UX with audit evidence.
- Integrated dependency: Epic #470 governed Git delivery, as merged into the #1491 feature branch by PR #1551.

## Current delivery state

- #470 remains closed as completed and is treated as an integrated dependency, not as an open workstream.
- PR #1551 is merged into `feat/keiko-agent-native-editor-foundation-and-runtime`.
- #1389 implementation is present on the #1491 feature branch, but the issue must stay open until this audit PR is merged and closure evidence is posted.
- #1491 is not yet merged to `dev`; epic closure must wait for a green PR from the feature branch to `dev`.

## Audit findings

The audit did not find a second Git write path, a browser shell escape, or a container execution bypass.
The editor runtime composes existing governed surfaces:

- Read-only repository state stays on `/api/git/status` and `/api/git/diff`.
- Git mutations stay on `/api/git-delivery/*` and the governed Git, pull request, and merge windows.
- Command execution uses server-discovered task ids only.
- Container execution uses the server-frozen catalog only.

Two production hardening gaps were found and fixed:

1. Command and container run controls could accept rapid duplicate submits before React rerendered the `running` state.
The widgets now use the synchronous in-flight ref as the immediate guard, and the run buttons are truly disabled during execution.
2. Runtime-linked windows could retain stale project paths when an existing window was reconfigured from the Runtime hub.
The Runtime hub and command/container windows now synchronize their project path from updated window configuration.

## Verification matrix

Local targeted verification:

- Runtime hub, command, container, and widget registry tests passed.
- Git delivery evidence check passed.
- Editor documentation link check passed.

Required full verification before merge:

- `npm run typecheck`
- `npm run lint`
- `npm run arch:check`
- `npm run arch:check:negative`
- `npm run test:coverage:quality`
- `npm run build --workspace @oscharko-dev/keiko-ui`
- `npm run check:git-delivery-evidence`
- `npm run check:editor-doc-links`
- `npm audit --audit-level=moderate --workspace @oscharko-dev/keiko-ui`

Additional browser verification should be run only if this audit PR expands beyond the current UI hardening and documentation changes.

## Closure requirements

#1389 can be closed after this audit PR is merged and the issue receives a closure comment linking:

- the merged implementation PR #1551,
- this audit PR,
- the local and GitHub check evidence,
- the retained Git Delivery boundary.

#1491 can be closed only after:

- every migrated child issue is closed with correct status labels,
- #1389 is closed,
- this audit evidence is merged,
- a PR from `feat/keiko-agent-native-editor-foundation-and-runtime` to `dev` is merged green,
- the epic receives a final closeout comment.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// the UI through the same paths a real BFF would. Covers: catalog fetch, run-button POST, failure
// surface, cancel via SSE-captured runId, SSE event display, and an axe a11y smoke.

import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "jest-axe";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
Expand Down Expand Up @@ -107,6 +107,17 @@ describe("CommandsWidget", () => {
expect(screen.getByRole("button", { name: /run task/i })).toBeInTheDocument();
});

it("reloads the discovered task catalog when the window project path changes", async () => {
const view = render(<CommandsWidget projectPath="/proj" />);
await screen.findByRole("combobox", { name: /task/i });
vi.mocked(fetchCommandCatalog).mockClear();

view.rerender(<CommandsWidget projectPath="/proj-next" />);

await waitFor(() => expect(fetchCommandCatalog).toHaveBeenCalledWith("/proj-next"));
expect(screen.getByLabelText(/project path/i)).toHaveValue("/proj-next");
});

it("populates the task dropdown from the discovered catalog", async () => {
const user = userEvent.setup();
render(<CommandsWidget projectPath="/proj" />);
Expand Down Expand Up @@ -139,6 +150,20 @@ describe("CommandsWidget", () => {
});
});

it("guards against duplicate submissions before React rerenders the running state", async () => {
vi.mocked(createCommandRun).mockImplementation(() => new Promise<never>(() => undefined));
render(<CommandsWidget projectPath="/proj" />);
await screen.findByRole("combobox", { name: /task/i });
const runButton = screen.getByRole("button", { name: /run task/i });
await waitFor(() => expect(runButton).toHaveAttribute("aria-disabled", "false"));

fireEvent.click(runButton);
fireEvent.click(runButton);

expect(createCommandRun).toHaveBeenCalledTimes(1);
expect(runButton).toBeDisabled();
});

it("surfaces a failure reason badge for a non-zero exit", async () => {
vi.mocked(createCommandRun).mockResolvedValue({
...RESULT,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ function resultSummary(result: CommandTaskRunResult): string {

export function CommandsWidget(props: CommandsWidgetProps): ReactNode {
const [projectInput, setProjectInput] = useState<string>(props.projectPath ?? "");
useEffect(() => {
setProjectInput(props.projectPath ?? "");
}, [props.projectPath]);

const [tasks, setTasks] = useState<readonly CommandTask[]>([]);
const [taskId, setTaskId] = useState<string>("");
const [running, setRunning] = useState(false);
Expand Down Expand Up @@ -169,7 +173,7 @@ export function CommandsWidget(props: CommandsWidgetProps): ReactNode {
const onSubmit = useCallback(
async (e: FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault();
if (running || taskId.length === 0) return;
if (running || runningRef.current || taskId.length === 0) return;
setError(null);
setResult(null);
setInFlightRunId(null);
Expand Down Expand Up @@ -236,7 +240,7 @@ export function CommandsWidget(props: CommandsWidgetProps): ReactNode {
className="tm-action"
data-primary="true"
ref={runBtnRef}
disabled={tasks.length === 0}
disabled={running || tasks.length === 0 || taskId.length === 0}
aria-disabled={running || tasks.length === 0 || taskId.length === 0}
>
{running ? "Running…" : "Run task"}
Expand All @@ -245,6 +249,7 @@ export function CommandsWidget(props: CommandsWidgetProps): ReactNode {
<button
type="button"
className="tm-action"
disabled={inFlightRunId === null}
aria-disabled={inFlightRunId === null}
onClick={() => void onAbort()}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// state (catalog tasks + run control), api errors rendered as a muted code-tagged message that never
// leaks URLs/credentials, and an axe a11y assertion (zero serious/critical).

import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "jest-axe";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
Expand Down Expand Up @@ -160,6 +160,20 @@ describe("ContainerStatusWidget", () => {
expect(screen.getByRole("button", { name: /run diagnostic/i })).toBeInTheDocument();
});

it("re-probes capability and catalog when the window project path changes", async () => {
vi.mocked(fetchContainerCapability).mockResolvedValue(AVAILABLE);
vi.mocked(fetchContainerCatalog).mockResolvedValue(CATALOG);
const view = render(<ContainerStatusWidget projectPath="/proj" />);
await screen.findByRole("button", { name: /run diagnostic/i });
vi.mocked(fetchContainerCapability).mockClear();
vi.mocked(fetchContainerCatalog).mockClear();

view.rerender(<ContainerStatusWidget projectPath="/proj-next" />);

await waitFor(() => expect(fetchContainerCapability).toHaveBeenCalledWith("/proj-next"));
await waitFor(() => expect(fetchContainerCatalog).toHaveBeenCalledWith("/proj-next"));
});

it("runs the selected diagnostic and shows the structured result", async () => {
vi.mocked(fetchContainerCapability).mockResolvedValue(AVAILABLE);
vi.mocked(fetchContainerCatalog).mockResolvedValue(CATALOG);
Expand All @@ -181,6 +195,22 @@ describe("ContainerStatusWidget", () => {
});
});

it("guards against duplicate diagnostic submissions before React rerenders the running state", async () => {
vi.mocked(fetchContainerCapability).mockResolvedValue(AVAILABLE);
vi.mocked(fetchContainerCatalog).mockResolvedValue(CATALOG);
vi.mocked(createContainerRun).mockImplementation(() => new Promise<never>(() => undefined));
render(<ContainerStatusWidget projectPath="/proj" />);

const runButton = await screen.findByRole("button", { name: /run diagnostic/i });
await waitFor(() => expect(runButton).toHaveAttribute("aria-disabled", "false"));

fireEvent.click(runButton);
fireEvent.click(runButton);

expect(createContainerRun).toHaveBeenCalledTimes(1);
expect(runButton).toBeDisabled();
});

it("renders a capability fetch error as a muted, code-tagged message that never leaks a URL", async () => {
vi.mocked(fetchContainerCapability).mockRejectedValue(
new ApiError("WORKSPACE_NOT_REGISTERED", "Project is not registered.", 403),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ function EngineStatusList({
}

export function ContainerStatusWidget(props: ContainerStatusWidgetProps): ReactNode {
const [projectInput] = useState<string>(props.projectPath ?? "");
const projectInput = props.projectPath ?? "";
const [capability, setCapability] = useState<ContainerCapabilityResponse | null>(null);
const [tasks, setTasks] = useState<readonly ContainerTask[]>([]);
const [taskId, setTaskId] = useState<string>("");
Expand Down Expand Up @@ -290,7 +290,7 @@ export function ContainerStatusWidget(props: ContainerStatusWidgetProps): ReactN
const onSubmit = useCallback(
async (e: FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault();
if (running || !hasRunControl || taskId.length === 0) return;
if (running || runningRef.current || !hasRunControl || taskId.length === 0) return;
setError(null);
setResult(null);
setInFlightRunId(null);
Expand Down Expand Up @@ -367,7 +367,7 @@ export function ContainerStatusWidget(props: ContainerStatusWidgetProps): ReactN
className="tm-action"
data-primary="true"
ref={runBtnRef}
disabled={tasks.length === 0}
disabled={running || tasks.length === 0 || taskId.length === 0}
aria-disabled={running || tasks.length === 0 || taskId.length === 0}
>
{running ? "Running…" : "Run diagnostic"}
Expand All @@ -376,6 +376,7 @@ export function ContainerStatusWidget(props: ContainerStatusWidgetProps): ReactN
<button
type="button"
className="tm-action"
disabled={inFlightRunId === null}
aria-disabled={inFlightRunId === null}
onClick={() => void onAbort()}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,15 @@ describe("RuntimeHubWidget", () => {
);
expect(await axe(container)).toHaveNoViolations();
});

it("syncs the editable project path when the window configuration changes", () => {
const h = handlers();
const view = render(<RuntimeHubWidget projectPath="/repo" {...h} />);

expect(screen.getByLabelText("Project path")).toHaveValue("/repo");

view.rerender(<RuntimeHubWidget projectPath="/repo-next" {...h} />);

expect(screen.getByLabelText("Project path")).toHaveValue("/repo-next");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// without adding execution authority: read-only Git lives in Files, command/container runs keep their
// server-frozen catalogs, and all Git mutations stay in Epic #470 Git Delivery windows.

import { useState, type ReactNode } from "react";
import { useEffect, useState, type ReactNode } from "react";
import { Icons } from "../../Icons";

interface RuntimeHubWidgetProps {
Expand Down Expand Up @@ -44,6 +44,10 @@ function RuntimeActionButton({ action }: { readonly action: RuntimeAction }): Re

export function RuntimeHubWidget(props: RuntimeHubWidgetProps): ReactNode {
const [projectInput, setProjectInput] = useState<string>(props.projectPath ?? "");
useEffect(() => {
setProjectInput(props.projectPath ?? "");
}, [props.projectPath]);

const projectPath = projectInput.trim();
const hasProject = projectPath.length > 0;

Expand Down
Loading