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
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ independent blind review using only requirements, acceptance criteria, boundarie
repository path and comparison baseline. Fix in-scope blockers before delivery.
Do not use `codex exec` as a substitute reviewer.

The Core Web is an administrator console for execution and resource operations;
business collaboration remains in Parsar. Environment Template management shares
the Session creation catalog and uses the existing public client operations. Patch
only edited fields, confirm deletion, and never automatically retry an uncertain
write. A Core connection change must discard the previous connection's forms,
pending results and notices. Saving a Template must not allocate a Runtime, call a
model or imply execution readiness. Keep unsupported advanced profiles explicit.

For subsequent alignment and milestone closure batches, the main thread coordinates
design, shared interface agreements, file ownership, integration and merge. First
reconcile main and the boards, then list remaining mandatory milestone work,
Expand Down
314 changes: 314 additions & 0 deletions apps/web/e2e/environment-templates.spec.ts

Large diffs are not rendered by default.

58 changes: 54 additions & 4 deletions apps/web/e2e/fixture-core.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@ function initialState() {
sourceFiles: new Map(),
hostedWorkspaceFiles: [],
environmentTemplates: [],
environmentTemplateSequence: 0,
sessionCreateReceipts: new Map(),
controls: {
createAgentResponseVariant: "valid",
Expand Down Expand Up @@ -415,7 +416,15 @@ function initialState() {
environmentFileCreateStatus: 200,
environmentFileCreateResponseLoss: 0,
environmentTemplateListStatus: 200,
environmentTemplateListDelayMs: 0,
environmentTemplatePageSize: 100,
environmentTemplateCreateStatus: 200,
environmentTemplateRetrieveStatus: 200,
environmentTemplateRetrieveDelayMs: 0,
environmentTemplateUpdateStatus: 200,
environmentTemplateUpdateDelayMs: 0,
environmentTemplateDeleteStatus: 200,
environmentTemplateDeleteDelayMs: 0,
environmentResourceStatus: "pending",
environmentResourceVariant: "valid",
environmentEventStatus: 0,
Expand Down Expand Up @@ -1348,13 +1357,22 @@ const server = http.createServer(async (request, response) => {
return sendError(response, 400, "Fixture Environment Template query is invalid.");
}
const templates = [...state.environmentTemplates]
.sort((left, right) => left.created_at - right.created_at);
.sort((left, right) => left.created_at - right.created_at || left.id.localeCompare(right.id));
if (order === "desc") templates.reverse();
const page = templates.slice(0, limit);
const after = url.searchParams.get("after");
const before = url.searchParams.get("before");
const start = after ? templates.findIndex((template) => template.id === after) + 1 : 0;
const end = before ? templates.findIndex((template) => template.id === before) : templates.length;
if ((after && start === 0) || end < 0) return sendError(response, 404, "Fixture Template cursor not found.");
const page = templates.slice(start, Math.min(end, start + Math.min(limit, state.controls.environmentTemplatePageSize)));
const delayMs = state.controls.environmentTemplateListDelayMs;
state.controls.environmentTemplateListDelayMs = 0;
if (delayMs) await wait(delayMs);
if (response.destroyed) return;
return sendJson(response, {
object: "list",
data: page,
has_more: page.length < templates.length,
has_more: start + page.length < end,
first_id: page[0]?.id ?? null,
last_id: page.at(-1)?.id ?? null,
});
Expand All @@ -1379,7 +1397,7 @@ const server = http.createServer(async (request, response) => {
if (body.name !== undefined && body.name !== null && typeof body.name !== "string") {
return sendError(response, 400, "Fixture Environment Template name is invalid.");
}
const index = state.environmentTemplates.length + 1;
const index = ++state.environmentTemplateSequence;
const created = Math.floor(Date.now() / 1000);
const template = {
id: `4${String(index).padStart(7, "0")}-1111-4111-8111-111111111111`,
Expand All @@ -1400,6 +1418,38 @@ const server = http.createServer(async (request, response) => {
return sendError(response, 405, "This API method is not supported.", "unsupported_operation");
}

const templateMatch = url.pathname.match(/^\/v1\/agents\/environments\/templates\/([^/]+)$/u);
if (templateMatch) {
if (request.headers["openai-beta"] !== "agents=v1") {
return sendError(response, 400, "Fixture Environment Templates require the Agents beta header.");
}
const template = state.environmentTemplates.find((entry) => entry.id === decodeURIComponent(templateMatch[1]));
if (!template) return sendError(response, 404, "Fixture Environment Template not found.");
const operation = { GET: "Retrieve", POST: "Update", DELETE: "Delete" }[request.method];
if (!operation) return sendError(response, 405, "This API method is not supported.", "unsupported_operation");
const control = consumeControl(`environmentTemplate${operation}`);
if (control.delayMs) await wait(control.delayMs);
if (response.destroyed) return;
if (control.status !== 200) return sendError(response, control.status, `Fixture Environment Template ${operation.toLowerCase()} failed.`);
if (request.method === "GET") return sendJson(response, template);
if (request.method === "DELETE") {
state.environmentTemplates = state.environmentTemplates.filter((entry) => entry.id !== template.id);
return sendJson(response, { id: template.id, object: "agent.environment.template.deleted", deleted: true });
}
if (!isRecord(body) || !hasOnlyKeys(body, ["name", "network"]) ||
(body.name !== undefined && body.name !== null && typeof body.name !== "string")) {
return sendError(response, 400, "Fixture Environment Template patch is invalid.");
}
if (body.network !== undefined && body.network !== null &&
(!isRecord(body.network) || !hasOnlyKeys(body.network, ["access"]) || !["enabled", "disabled"].includes(body.network.access))) {
return sendError(response, 400, "Fixture Environment Template network is unsupported.");
}
if (Object.hasOwn(body, "name")) template.name = body.name;
if (Object.hasOwn(body, "network")) template.network = { access: body.network?.access ?? "enabled", allowed_domains: [] };
template.updated_at = Math.max(template.updated_at + 1, Math.floor(Date.now() / 1000));
return sendJson(response, template);
}

const environmentFilesMatch = url.pathname.match(/^\/v1\/agents\/environments\/([^/]+)\/files$/);
if (request.method === "POST" && environmentFilesMatch) {
const id = decodeURIComponent(environmentFilesMatch[1]);
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
requestAgentUpdate,
} from "./features/agents/agent-actions";
import { DashboardView } from "./features/dashboard/DashboardView";
import { EnvironmentTemplatesView } from "./features/environment-templates/EnvironmentTemplatesView";
import {
SessionsView,
type SessionDetailState,
Expand Down Expand Up @@ -131,6 +132,7 @@ type View = ProductView | "system";
function viewFromLocation(): View {
if (typeof window === "undefined") return "dashboard";
const candidate = window.location.hash.slice(1);
if (candidate === "templates" && __AGENTS_CORE_WEB_OPENAI_HOSTED_SESSIONS__) return "templates";
return candidate === "agents" || candidate === "sessions" || candidate === "vaults" || candidate === "system"
? candidate
: "dashboard";
Expand Down Expand Up @@ -2115,6 +2117,7 @@ export function App() {
active={view === "system" ? null : view}
onSelect={(nextView) => setView(nextView)}
showVaults={vaultSupported === true}
showTemplates={__AGENTS_CORE_WEB_OPENAI_HOSTED_SESSIONS__}
/>

<nav className="main-nav" aria-label="System navigation">
Expand Down Expand Up @@ -2162,6 +2165,15 @@ export function App() {
/>
</header>
<div className="page-transition" key={view}>
{view === "templates" ? (
<EnvironmentTemplatesView
key={`templates:${coreGeneration}`}
catalog={environmentTemplates}
operations={core}
onRefresh={refreshEnvironmentTemplates}
onConfigureConnection={() => setConnectionOpen(true)}
/>
) : null}
{view === "dashboard" ? (
<DashboardView
agents={agents}
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/components/ProductNavigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ describe("Product navigation", () => {
expect(html).toContain("Sessions");
expect(html).not.toContain("Vaults");
expect(html).not.toContain("Environments");
expect(html).not.toContain("Templates");
expect(html).toContain('aria-current="page"');
});

Expand All @@ -28,4 +29,12 @@ describe("Product navigation", () => {
expect(html).toContain("Vaults");
expect(html).toContain('aria-current="page"');
});

it("exposes Templates in managed Environment builds", () => {
const html = renderToStaticMarkup(
<ProductNavigation active="templates" showTemplates onSelect={() => undefined} />,
);
expect(html).toContain("Templates");
expect(html).toContain('aria-current="page"');
});
});
9 changes: 6 additions & 3 deletions apps/web/src/components/ProductNavigation.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,30 @@
import { Bot, LayoutDashboard, MessageSquare, Vault, type LucideIcon } from "lucide-react";
import { Bot, LayoutDashboard, Layers3, MessageSquare, Vault, type LucideIcon } from "lucide-react";

export type ProductView = "dashboard" | "agents" | "sessions" | "vaults";
export type ProductView = "dashboard" | "agents" | "sessions" | "vaults" | "templates";

const productViews: Array<{ id: ProductView; label: string; icon: LucideIcon }> = [
{ id: "dashboard", label: "Dashboard", icon: LayoutDashboard },
{ id: "agents", label: "Agents", icon: Bot },
{ id: "sessions", label: "Sessions", icon: MessageSquare },
{ id: "templates", label: "Templates", icon: Layers3 },
{ id: "vaults", label: "Vaults", icon: Vault },
];

export function ProductNavigation({
active,
onSelect,
showVaults = false,
showTemplates = false,
}: {
active: ProductView | null;
onSelect: (view: ProductView) => void;
showVaults?: boolean;
showTemplates?: boolean;
}) {
return (
<nav className="main-nav product-navigation" aria-label="Agents product">
<p className="nav-label">Workspace</p>
{productViews.filter((item) => item.id !== "vaults" || showVaults).map((item) => {
{productViews.filter((item) => (item.id !== "vaults" || showVaults) && (item.id !== "templates" || showTemplates)).map((item) => {
const Icon = item.icon;
return (
<button
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
.templates-page .page-header { align-items: center; flex-wrap: wrap; gap: 12px; }
.templates-page .page-header p, .templates-boundary { color: var(--fg-muted); font-size: 12px; line-height: 1.6; }
.templates-content { padding: 0 24px 24px; }
.templates-toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; margin: 24px 0 14px; }
.templates-toolbar > span { color: var(--fg-muted); font-size: 12px; }
.templates-search { display: flex; align-items: center; gap: 8px; width: 360px; max-width: 100%; color: var(--fg-muted); }
.templates-search input, .template-form input, .template-form select { min-width: 0; width: 100%; border: 1px solid var(--line-strong); background: var(--surface); color: var(--fg); border-radius: 5px; padding: 9px 10px; font: inherit; }
.templates-list { border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
.template-row { display: flex; align-items: center; gap: 24px; padding: 18px; background: var(--surface); }
.template-row + .template-row { border-top: 1px solid var(--line); }
.template-identity { display: flex; flex-direction: column; gap: 6px; flex: 1; min-width: 0; }
.template-identity h2 { margin: 0; font-size: 14px; font-weight: 550; overflow-wrap: anywhere; }
.template-identity code, .template-delete code { overflow-wrap: anywhere; font-size: 11px; }
.template-identity small { color: var(--fg-muted); font-size: 11px; }
.template-summary { display: flex; gap: 24px; margin: 0; font-size: 12px; }
.template-summary dt { color: var(--fg-muted); font-size: 11px; margin-bottom: 6px; }
.template-summary dd { margin: 0; }
.template-row-actions, .template-form-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.template-form-actions { justify-content: flex-end; margin-top: 12px; }
.templates-empty { padding: 36px 16px; border: 1px dashed var(--line-strong); border-radius: 8px; text-align: center; }
.templates-empty h2 { font-size: 15px; font-weight: 550; }
.templates-empty p, .template-form p, .template-delete p { color: var(--fg-muted); font-size: 12px; line-height: 1.6; }
.template-form { display: flex; flex-direction: column; gap: 10px; }
.template-form label { font-size: 12px; font-weight: 550; }
.template-form label span { color: var(--fg-muted); font-weight: 400; margin-left: 6px; }
.template-form p { margin: 0; }
.template-delete .button.danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); background: color-mix(in srgb, var(--danger) 7%, var(--surface)); }
.template-delete .button.danger:hover:not(:disabled) { background: color-mix(in srgb, var(--danger) 13%, var(--surface)); }
.template-form :is(input, select):focus-visible, .templates-search input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
@media (max-width: 1000px) {
.templates-content { padding: 0 16px 20px; }
.template-row { flex-wrap: wrap; gap: 16px; }
.template-identity { flex-basis: 100%; }
.template-summary { flex: 1; }
.templates-page .page-actions { flex-wrap: wrap; }
.templates-page .button, .template-form-actions .button { min-height: 40px; }
}
.templates-page { overflow-y: auto; }

.templates-search > .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import type { EnvironmentTemplate } from "@agents-core-web/agents-client";
import { EnvironmentTemplatesView, type EnvironmentTemplatesViewProps } from "./EnvironmentTemplatesView";
import { TemplateForm } from "./TemplateForm";

const template: EnvironmentTemplate = {
id: "eb4fa61b-6c45-4b4c-a33b-6d9d1f05fd21", object: "agent.environment.template", name: "Restricted",
network: { access: "disabled", allowed_domains: [] }, capability_directories: [],
packages: { npm: [], python: [], system: [] }, files: [], plugins: [], skills: [], created_at: 1, updated_at: 2,
};
function render(catalog: EnvironmentTemplatesViewProps["catalog"]) {
const operations = {
createEnvironmentTemplate: vi.fn(), updateEnvironmentTemplate: vi.fn(), deleteEnvironmentTemplate: vi.fn(),
};
const html = renderToStaticMarkup(<EnvironmentTemplatesView catalog={catalog} operations={operations} onRefresh={async () => undefined} onConfigureConnection={() => undefined} />);
expect(operations.createEnvironmentTemplate).not.toHaveBeenCalled();
expect(operations.updateEnvironmentTemplate).not.toHaveBeenCalled();
expect(operations.deleteEnvironmentTemplate).not.toHaveBeenCalled();
return html;
}

describe("Environment Templates view", () => {
it("distinguishes unread, unsupported, failed and empty catalogs", () => {
expect(render(null)).toContain("Loading Environment Templates");
expect(render({ state: "unsupported" })).toContain("does not expose the Template resource");
const failed = render({ state: "failed", message: "secret-token" });
expect(failed).toContain("could not be loaded");
expect(failed).not.toContain("No Environment Templates yet");
expect(failed).not.toContain("secret-token");
expect(render({ state: "ready", templates: [] })).toContain("No Environment Templates yet");
});

it("shows safe configuration and actions without execution readiness claims", () => {
const html = render({ state: "ready", templates: [template] });
expect(html).toContain("Environment Templates</h1>");
expect(html).toContain("Filter Templates");
expect(html).toContain("Edit Restricted");
expect(html).toContain("Delete Restricted");
expect(html).toContain(template.id);
expect(html).toContain("does not start a Runtime");
expect(html).not.toContain("connected");
});

it("does not render unknown fields or private configuration", () => {
const value = { ...template, env: { KEY: "secret-canary" }, setup_commands: ["private-command"] };
const html = render({ state: "ready", templates: [value] });
expect(html).not.toContain("secret-canary");
expect(html).not.toContain("private-command");
});

it("provides explicit labels and blocks an unchanged edit", () => {
const html = renderToStaticMarkup(<TemplateForm template={template} busy={false} onSave={() => undefined} onCancel={() => undefined} />);
expect(html).toContain("Network access</label>");
expect(html).toContain("Existing Sessions keep their configuration");
expect(html).toContain('type="submit" disabled=""');
});
});
Loading