chore(eco-watch): resolve merge conflict — keep BLOCKED MemPalace + run b entries
Remote had the pre-fraud-audit MemPalace WATCH entry. Resolved by keeping HEAD: BLOCKED/FRAUD verdict (SA audit 2026-04-18) plus the two new run-b entries (chrome-devtools-mcp, craft-agents-oss). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
c7212891ea
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { type ActivityEntry } from "@/types/activity";
|
||||
@ -46,7 +47,7 @@ function extractMessageText(body: Record<string, unknown> | null): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
export function ConversationTraceModal({ open, workspaceId, onClose }: Props) {
|
||||
export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClose }: Props) {
|
||||
const [entries, setEntries] = useState<ActivityEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const nodes = useCanvasStore((s) => s.nodes);
|
||||
@ -83,34 +84,41 @@ export function ConversationTraceModal({ open, workspaceId, onClose }: Props) {
|
||||
});
|
||||
}, [open, nodes]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const isA2A = (e: ActivityEntry) =>
|
||||
e.activity_type === "a2a_receive" || e.activity_type === "a2a_send";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
|
||||
<Dialog.Root open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<Dialog.Portal>
|
||||
{/* Overlay replaces the old manual backdrop div */}
|
||||
<Dialog.Overlay className="fixed inset-0 z-[59] bg-black/70 backdrop-blur-sm" />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl max-w-[700px] w-full mx-4 max-h-[85vh] flex flex-col overflow-hidden">
|
||||
{/* Content wraps the entire centred modal panel */}
|
||||
<Dialog.Content
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||
aria-label="Conversation trace"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
{/* Modal panel */}
|
||||
<div className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl max-w-[700px] w-full max-h-[85vh] flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-zinc-800">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-zinc-100">
|
||||
<Dialog.Title className="text-sm font-semibold text-zinc-100">
|
||||
Conversation Trace
|
||||
</h3>
|
||||
</Dialog.Title>
|
||||
<p className="text-[10px] text-zinc-500 mt-0.5">
|
||||
{entries.length} events across all workspaces
|
||||
</p>
|
||||
</div>
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close conversation trace"
|
||||
className="text-zinc-500 hover:text-zinc-300 text-lg px-2"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
@ -274,14 +282,17 @@ export function ConversationTraceModal({ open, workspaceId, onClose }: Props) {
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-3 border-t border-zinc-800 bg-zinc-950/50 flex justify-end">
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 text-[12px] bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@ -153,7 +153,7 @@ export function EmptyState() {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-xs text-red-400">
|
||||
<div role="alert" className="mt-3 px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-xs text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -33,6 +33,19 @@ interface Props {
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sanitise a memory key for use in an HTML id attribute.
|
||||
* HTML IDs must not contain whitespace; many non-alphanumeric characters also
|
||||
* cause selector or ARIA failures. Replace every non-alphanumeric character
|
||||
* with a hyphen, collapse consecutive hyphens, then strip leading/trailing ones.
|
||||
*/
|
||||
function sanitizeId(key: string): string {
|
||||
return key
|
||||
.replace(/[^a-zA-Z0-9]/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
function formatRelativeTime(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
if (diff < 60_000) return `${Math.floor(diff / 1000)}s`;
|
||||
@ -414,6 +427,7 @@ function MemoryEntryRow({
|
||||
onCancelEdit,
|
||||
onDelete,
|
||||
}: MemoryEntryRowProps) {
|
||||
const bodyId = `mem-body-${sanitizeId(entry.key)}`;
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800/60 bg-zinc-900/50 overflow-hidden">
|
||||
{/* Header row — click to expand/collapse */}
|
||||
@ -421,6 +435,7 @@ function MemoryEntryRow({
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-zinc-800/30 transition-colors"
|
||||
onClick={onToggle}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={bodyId}
|
||||
>
|
||||
<span className="text-[10px] font-mono text-blue-400 truncate flex-1 min-w-0">
|
||||
{entry.key}
|
||||
@ -455,7 +470,12 @@ function MemoryEntryRow({
|
||||
|
||||
{/* Expanded body */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-zinc-800/50 px-3 pb-3 pt-2 space-y-2">
|
||||
<div
|
||||
id={bodyId}
|
||||
role="region"
|
||||
aria-label={`Details for ${entry.key}`}
|
||||
className="border-t border-zinc-800/50 px-3 pb-3 pt-2 space-y-2"
|
||||
>
|
||||
{entry.expires_at && (
|
||||
<p className="text-[9px] text-zinc-500">
|
||||
Expires: {new Date(entry.expires_at).toLocaleString()}
|
||||
|
||||
@ -120,8 +120,20 @@ export function OnboardingWizard() {
|
||||
const currentStepIdx = STEPS.findIndex((s) => s.id === step);
|
||||
const currentStep = STEPS[currentStepIdx];
|
||||
|
||||
// Screen-reader labels for each step (announced on step transitions)
|
||||
const stepLabels: Record<string, string> = {
|
||||
welcome: "Onboarding step 1 of 4: Welcome",
|
||||
"api-key": "Onboarding step 2 of 4: Configure your workspace",
|
||||
"send-message": "Onboarding step 3 of 4: Send your first message",
|
||||
done: "Onboarding complete",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-20 left-4 z-50 w-80 rounded-2xl border border-zinc-700/60 bg-zinc-900/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden">
|
||||
<div
|
||||
role="complementary"
|
||||
aria-label="Onboarding guide"
|
||||
className="fixed bottom-20 left-4 z-50 w-80 rounded-2xl border border-zinc-700/60 bg-zinc-900/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
|
||||
>
|
||||
{/* Progress bar */}
|
||||
<div className="h-1 bg-zinc-800">
|
||||
<div
|
||||
@ -130,6 +142,16 @@ export function OnboardingWizard() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Polite live region — announces step transitions to screen readers */}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
>
|
||||
{stepLabels[step] ?? currentStep.title}
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
|
||||
@ -23,6 +23,7 @@ import { summarizeWorkspaceCapabilities } from "@/store/canvas";
|
||||
const SIDEPANEL_WIDTH_KEY = "molecule:sidepanel-width";
|
||||
const SIDEPANEL_DEFAULT_WIDTH = 480;
|
||||
const SIDEPANEL_MIN_WIDTH = 320;
|
||||
const SIDEPANEL_MAX_WIDTH = 800;
|
||||
|
||||
const TABS: { id: PanelTab; label: string; icon: string }[] = [
|
||||
{ id: "chat", label: "Chat", icon: "◈" },
|
||||
@ -72,6 +73,29 @@ export function SidePanel() {
|
||||
document.body.style.userSelect = "none";
|
||||
}, [width]);
|
||||
|
||||
const onResizeKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
const STEP = 16;
|
||||
let newWidth: number | null = null;
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
newWidth = Math.min(width + STEP, SIDEPANEL_MAX_WIDTH);
|
||||
} else if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
newWidth = Math.max(width - STEP, SIDEPANEL_MIN_WIDTH);
|
||||
} else if (e.key === "Home") {
|
||||
e.preventDefault();
|
||||
newWidth = SIDEPANEL_MIN_WIDTH;
|
||||
} else if (e.key === "End") {
|
||||
e.preventDefault();
|
||||
newWidth = SIDEPANEL_MAX_WIDTH;
|
||||
}
|
||||
if (newWidth !== null) {
|
||||
setWidth(newWidth);
|
||||
widthRef.current = newWidth;
|
||||
localStorage.setItem(SIDEPANEL_WIDTH_KEY, String(newWidth));
|
||||
}
|
||||
}, [width]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!dragging.current) return;
|
||||
@ -111,8 +135,16 @@ export function SidePanel() {
|
||||
>
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
role="separator"
|
||||
aria-label="Resize workspace panel"
|
||||
aria-valuenow={width}
|
||||
aria-valuemin={SIDEPANEL_MIN_WIDTH}
|
||||
aria-valuemax={SIDEPANEL_MAX_WIDTH}
|
||||
aria-orientation="vertical"
|
||||
tabIndex={0}
|
||||
onMouseDown={onMouseDown}
|
||||
className="absolute left-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-blue-500/30 active:bg-blue-500/50 transition-colors z-10"
|
||||
onKeyDown={onResizeKeyDown}
|
||||
className="absolute left-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-blue-500/30 active:bg-blue-500/50 transition-colors z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-inset"
|
||||
/>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-800/40 bg-zinc-900/30">
|
||||
|
||||
@ -157,6 +157,7 @@ export function Toolbar() {
|
||||
disabled={stopping}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 bg-red-950/50 hover:bg-red-900/60 border border-red-800/40 rounded-lg transition-colors disabled:opacity-50"
|
||||
title={`Stop all running tasks (${counts.activeTasks} active)`}
|
||||
aria-label={stopping ? "Stopping all running tasks" : `Stop all running tasks (${counts.activeTasks} active)`}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" className="text-red-400">
|
||||
<rect x="2" y="2" width="12" height="12" rx="2" />
|
||||
@ -174,6 +175,7 @@ export function Toolbar() {
|
||||
disabled={restartingAll}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 bg-amber-950/40 hover:bg-amber-900/50 border border-amber-800/40 rounded-lg transition-colors disabled:opacity-50"
|
||||
title={`Restart ${needsRestartNodes.length} workspace${needsRestartNodes.length === 1 ? "" : "s"} that need to pick up config or secret changes`}
|
||||
aria-label={restartingAll ? "Restarting workspaces" : `Restart ${needsRestartNodes.length} workspace${needsRestartNodes.length === 1 ? "" : "s"} pending config or secret changes`}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-amber-400">
|
||||
<path d="M2 8a6 6 0 1 1 1.76 4.24M2 13v-3h3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
@ -315,9 +317,9 @@ export function Toolbar() {
|
||||
|
||||
function StatusPill({ color, count, label }: { color: string; count: number; label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5" title={`${count} ${label}`}>
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${color}`} />
|
||||
<span className="text-[10px] text-zinc-400 tabular-nums">{count}</span>
|
||||
<div className="flex items-center gap-1.5" title={`${count} ${label}`} aria-label={`${count} ${label}`}>
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${color}`} aria-hidden="true" />
|
||||
<span className="text-[10px] text-zinc-400 tabular-nums" aria-hidden="true">{count}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -325,24 +327,24 @@ function StatusPill({ color, count, label }: { color: string; count: number; lab
|
||||
function WsStatusPill({ status }: { status: "connected" | "connecting" | "disconnected" }) {
|
||||
if (status === "connected") {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5" title="Real-time updates: connected">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${statusDotClass("online")}`} />
|
||||
<span className="text-[10px] text-zinc-500">Live</span>
|
||||
<div className="flex items-center gap-1.5" title="Real-time updates: connected" aria-label="Real-time updates: connected">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${statusDotClass("online")}`} aria-hidden="true" />
|
||||
<span className="text-[10px] text-zinc-500" aria-hidden="true">Live</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (status === "connecting") {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5" title="Real-time updates: reconnecting…">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 motion-safe:animate-pulse" />
|
||||
<span className="text-[10px] text-zinc-500">Reconnecting</span>
|
||||
<div className="flex items-center gap-1.5" title="Real-time updates: reconnecting…" aria-label="Real-time updates: reconnecting">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 motion-safe:animate-pulse" aria-hidden="true" />
|
||||
<span className="text-[10px] text-zinc-500" aria-hidden="true">Reconnecting</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-1.5" title="Real-time updates: disconnected">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${statusDotClass("failed")}`} />
|
||||
<span className="text-[10px] text-zinc-500">Offline</span>
|
||||
<div className="flex items-center gap-1.5" title="Real-time updates: disconnected" aria-label="Real-time updates: disconnected">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${statusDotClass("failed")}`} aria-hidden="true" />
|
||||
<span className="text-[10px] text-zinc-500" aria-hidden="true">Offline</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -29,9 +29,9 @@ function useHierarchyInfo(parentId: string) {
|
||||
}
|
||||
|
||||
/** Eject/extract arrow icon — visually distinct from delete ✕ */
|
||||
function EjectIcon() {
|
||||
function EjectIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...props}>
|
||||
<path d="M3 7L7 3" />
|
||||
<path d="M4 3H7V6" />
|
||||
</svg>
|
||||
@ -344,9 +344,6 @@ function TeamMemberChip({
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Select ${data.name ?? "workspace"}`}
|
||||
className="group/child relative rounded-lg bg-zinc-800/60 hover:bg-zinc-700/70 border border-zinc-700/30 hover:border-zinc-600/40 overflow-hidden transition-colors cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@ -357,13 +354,6 @@ function TeamMemberChip({
|
||||
e.stopPropagation();
|
||||
useCanvasStore.getState().openContextMenu({ x: e.clientX, y: e.clientY, nodeId: node.id, nodeData: data });
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelect(node.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Status gradient bar */}
|
||||
<div className={`absolute inset-x-0 top-0 h-5 bg-gradient-to-b ${statusCfg.bar} pointer-events-none`} />
|
||||
@ -387,14 +377,15 @@ function TeamMemberChip({
|
||||
{tierCfg.label}
|
||||
</span>
|
||||
<button
|
||||
aria-label={`Extract ${data.name} from team`}
|
||||
title={`Extract ${data.name} from team`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onExtract(node.id);
|
||||
}}
|
||||
aria-label="Extract from team"
|
||||
className="opacity-0 group-hover/child:opacity-100 text-zinc-500 hover:text-sky-400 transition-all"
|
||||
>
|
||||
<EjectIcon />
|
||||
<EjectIcon aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -8,23 +8,29 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
// ── Mocks (defined before dynamic import of component) ───────────────────────
|
||||
let mockFetchSession: ReturnType<typeof vi.fn>;
|
||||
let mockRedirectToLogin: ReturnType<typeof vi.fn>;
|
||||
let mockGetTenantSlug: ReturnType<typeof vi.fn>;
|
||||
// Use a function type so TypeScript accepts the mock as callable in vi.mock factories.
|
||||
// ReturnType<typeof vi.fn> resolves to Mock<Procedure|Constructable> in newer Vitest
|
||||
// type defs, which TS no longer considers directly callable. Casting to a plain
|
||||
// function type avoids the TS2348 error while keeping full mock API (mockReturnValue etc.).
|
||||
let mockFetchSession: ((...args: unknown[]) => unknown) & ReturnType<typeof vi.fn>;
|
||||
let mockRedirectToLogin: ((...args: unknown[]) => unknown) & ReturnType<typeof vi.fn>;
|
||||
let mockGetTenantSlug: ((...args: unknown[]) => unknown) & ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetchSession = vi.fn();
|
||||
mockRedirectToLogin = vi.fn();
|
||||
mockGetTenantSlug = vi.fn(() => null); // default: non-SaaS (pass-through)
|
||||
mockFetchSession = vi.fn() as typeof mockFetchSession;
|
||||
mockRedirectToLogin = vi.fn() as typeof mockRedirectToLogin;
|
||||
mockGetTenantSlug = vi.fn(() => null) as typeof mockGetTenantSlug;
|
||||
});
|
||||
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
fetchSession: (...args: unknown[]) => mockFetchSession(...args),
|
||||
redirectToLogin: (...args: unknown[]) => mockRedirectToLogin(...args),
|
||||
// Cast required: vi.fn() returns Mock<Procedure | Constructable> which TypeScript
|
||||
// won't call directly inside a factory closure (TS2348). Cast to Function resolves it.
|
||||
fetchSession: (...args: unknown[]) => (mockFetchSession as unknown as (...a: unknown[]) => unknown)(...args),
|
||||
redirectToLogin: (...args: unknown[]) => (mockRedirectToLogin as unknown as (...a: unknown[]) => unknown)(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/tenant", () => ({
|
||||
getTenantSlug: (...args: unknown[]) => mockGetTenantSlug(...args),
|
||||
getTenantSlug: (...args: unknown[]) => (mockGetTenantSlug as unknown as (...a: unknown[]) => unknown)(...args),
|
||||
}));
|
||||
|
||||
// Import after mocks are set up
|
||||
|
||||
@ -0,0 +1,158 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* WCAG 2.1 / Issue M — ConversationTraceModal accessibility
|
||||
*
|
||||
* Migrated from custom <div> to Radix Dialog, which provides:
|
||||
* - role="dialog" + aria-modal="true" automatically (WCAG 4.1.2)
|
||||
* - aria-labelledby pointing to Dialog.Title (WCAG 1.3.1)
|
||||
* - Focus trap (WCAG 2.1.2 / 2.4.3)
|
||||
* - Escape key closes the dialog (WCAG 2.1.1)
|
||||
* - ✕ close button has aria-label="Close conversation trace"
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Mocks must be declared before importing the component ────────────────────
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: (selector: (s: { nodes: unknown[] }) => unknown) =>
|
||||
selector({ nodes: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useWorkspaceName", () => ({
|
||||
useWorkspaceName: () => () => "Test WS",
|
||||
}));
|
||||
|
||||
import { ConversationTraceModal } from "../ConversationTraceModal";
|
||||
|
||||
// Helper: renders the modal in open state with a spy for onClose
|
||||
function renderOpen() {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<ConversationTraceModal
|
||||
open={true}
|
||||
workspaceId="ws-1"
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
return { onClose };
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Presence / absence
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ConversationTraceModal — dialog presence (Issue M)", () => {
|
||||
it("dialog is absent when open=false", () => {
|
||||
render(
|
||||
<ConversationTraceModal open={false} workspaceId="ws-1" onClose={vi.fn()} />
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("dialog is present when open=true", () => {
|
||||
renderOpen();
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// ARIA attributes provided by Radix Dialog
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ConversationTraceModal — ARIA attributes (Issue M)", () => {
|
||||
it("dialog element is accessible via role='dialog' with a non-empty accessible name", () => {
|
||||
renderOpen();
|
||||
// Radix Dialog.Content renders role="dialog" with aria-labelledby pointing
|
||||
// to Dialog.Title. Verify the role is present and the name is non-empty
|
||||
// (testing-library computes the accessible name from aria-labelledby).
|
||||
const dialog = screen.getByRole("dialog", { name: /conversation trace/i });
|
||||
expect(dialog).toBeTruthy();
|
||||
});
|
||||
|
||||
it("dialog has aria-labelledby pointing to 'Conversation Trace' title", () => {
|
||||
renderOpen();
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const labelledBy = dialog.getAttribute("aria-labelledby");
|
||||
expect(labelledBy).toBeTruthy();
|
||||
const titleEl = document.getElementById(labelledBy!);
|
||||
expect(titleEl?.textContent?.trim()).toBe("Conversation Trace");
|
||||
});
|
||||
|
||||
it("dialog has data-state='open' (Radix state attribute)", () => {
|
||||
renderOpen();
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog.getAttribute("data-state")).toBe("open");
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Close button accessible name
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ConversationTraceModal — close button (Issue M)", () => {
|
||||
it("✕ close button has aria-label='Close conversation trace'", () => {
|
||||
renderOpen();
|
||||
const closeBtn = screen.getByRole("button", {
|
||||
name: /close conversation trace/i,
|
||||
});
|
||||
expect(closeBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking ✕ button calls onClose", async () => {
|
||||
const { onClose } = renderOpen();
|
||||
const closeBtn = screen.getByRole("button", {
|
||||
name: /close conversation trace/i,
|
||||
});
|
||||
fireEvent.click(closeBtn);
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("footer 'Close' button also closes the dialog", async () => {
|
||||
const { onClose } = renderOpen();
|
||||
const closeBtn = screen.getByRole("button", { name: /^Close$/i });
|
||||
fireEvent.click(closeBtn);
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Escape key closes the dialog (WCAG 2.1.1 — Keyboard)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ConversationTraceModal — Escape key (Issue M)", () => {
|
||||
it("Escape key triggers onClose via Radix onOpenChange", async () => {
|
||||
const { onClose } = renderOpen();
|
||||
// Radix Dialog automatically closes on Escape and fires onOpenChange(false)
|
||||
// which our handler converts to onClose(). Dispatch on the document so
|
||||
// Radix's own keydown listener picks it up.
|
||||
fireEvent.keyDown(document, { key: "Escape", code: "Escape" });
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Empty state
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ConversationTraceModal — loading state (Issue M)", () => {
|
||||
it("shows loading indicator when dialog opens and fetch is in progress", () => {
|
||||
renderOpen();
|
||||
// After render + effects (flushed by act inside render), loading=true
|
||||
// because useEffect fired setLoading(true). The loading text should
|
||||
// be visible at this synchronous point.
|
||||
expect(screen.getByText(/loading trace from all workspaces/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -28,6 +28,7 @@ function makeWS(overrides: Partial<WorkspaceData> & { id: string }): WorkspaceDa
|
||||
y: 0,
|
||||
collapsed: false,
|
||||
runtime: "",
|
||||
budget_limit: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
188
canvas/src/components/__tests__/WorkspaceNode.eject.test.tsx
Normal file
188
canvas/src/components/__tests__/WorkspaceNode.eject.test.tsx
Normal file
@ -0,0 +1,188 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for issue #854 — TeamMemberChip eject button:
|
||||
* - aria-label must be dynamic: `Extract ${childName} from team`
|
||||
* - title must be dynamic: `Extract ${childName} from team`
|
||||
* - EjectIcon svg must carry aria-hidden="true"
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import type { Node } from "@xyflow/react";
|
||||
import type { WorkspaceNodeData } from "@/store/canvas";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── Mock @xyflow/react ─────────────────────────────────────────────────────────
|
||||
vi.mock("@xyflow/react", () => ({
|
||||
Handle: () => null,
|
||||
Position: { Bottom: "bottom", Top: "top" },
|
||||
useReactFlow: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Mock Toaster ───────────────────────────────────────────────────────────────
|
||||
vi.mock("@/components/Toaster", () => ({ showToast: vi.fn() }));
|
||||
|
||||
// ── Mock Tooltip ───────────────────────────────────────────────────────────────
|
||||
vi.mock("@/components/Tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
// ── Mock design tokens ─────────────────────────────────────────────────────────
|
||||
vi.mock("@/lib/design-tokens", () => ({
|
||||
STATUS_CONFIG: {
|
||||
online: { label: "Online", dot: "bg-emerald-400", bar: "from-emerald-500/10" },
|
||||
offline: { label: "Offline", dot: "bg-zinc-600", bar: "from-zinc-700/10" },
|
||||
provisioning: { label: "Provisioning", dot: "bg-sky-400", bar: "from-sky-500/10" },
|
||||
degraded: { label: "Degraded", dot: "bg-amber-400", bar: "from-amber-500/10" },
|
||||
failed: { label: "Failed", dot: "bg-red-400", bar: "from-red-500/10" },
|
||||
paused: { label: "Paused", dot: "bg-zinc-500", bar: "from-zinc-600/10" },
|
||||
},
|
||||
TIER_CONFIG: {
|
||||
1: { label: "T1", color: "text-zinc-400 bg-zinc-800" },
|
||||
2: { label: "T2", color: "text-blue-400 bg-blue-900/40" },
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Canvas store mock state ────────────────────────────────────────────────────
|
||||
const PARENT_ID = "parent-ws";
|
||||
const CHILD_ID = "child-ws";
|
||||
const CHILD_NAME = "Child Workspace";
|
||||
|
||||
function makeNodeData(overrides: Partial<WorkspaceNodeData> = {}): WorkspaceNodeData {
|
||||
return {
|
||||
name: "Test WS",
|
||||
role: "agent",
|
||||
tier: 1,
|
||||
status: "online",
|
||||
agentCard: null,
|
||||
url: "http://localhost:9000",
|
||||
parentId: null,
|
||||
activeTasks: 0,
|
||||
lastErrorRate: 0,
|
||||
lastSampleError: "",
|
||||
uptimeSeconds: 60,
|
||||
currentTask: "",
|
||||
collapsed: false,
|
||||
runtime: "",
|
||||
needsRestart: false,
|
||||
budgetLimit: null,
|
||||
...overrides,
|
||||
} as WorkspaceNodeData;
|
||||
}
|
||||
|
||||
const parentNodeData = makeNodeData({ name: "Parent WS", parentId: null });
|
||||
const childNodeData = makeNodeData({ name: CHILD_NAME, parentId: PARENT_ID });
|
||||
|
||||
const allNodes: Node<WorkspaceNodeData>[] = [
|
||||
{ id: PARENT_ID, type: "workspaceNode", position: { x: 0, y: 0 }, data: parentNodeData },
|
||||
{ id: CHILD_ID, type: "workspaceNode", position: { x: 0, y: 0 }, data: childNodeData, hidden: true },
|
||||
];
|
||||
|
||||
// Build a selector-compatible mock of useCanvasStore
|
||||
const mockStoreState = {
|
||||
nodes: allNodes,
|
||||
edges: [],
|
||||
selectedNodeId: null,
|
||||
panelTab: "chat",
|
||||
dragOverNodeId: null,
|
||||
contextMenu: null,
|
||||
searchOpen: false,
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
selectNode: vi.fn(),
|
||||
openContextMenu: vi.fn(),
|
||||
nestNode: vi.fn(),
|
||||
isDescendant: vi.fn(() => false),
|
||||
restartWorkspace: vi.fn(),
|
||||
setPanelTab: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
vi.fn((selector: (s: typeof mockStoreState) => unknown) =>
|
||||
selector(mockStoreState)
|
||||
),
|
||||
{ getState: () => mockStoreState }
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Mock zustand/react/shallow ─────────────────────────────────────────────────
|
||||
vi.mock("zustand/react/shallow", () => ({
|
||||
useShallow: (fn: (s: typeof mockStoreState) => unknown) => fn,
|
||||
}));
|
||||
|
||||
// ── Import component AFTER mocks ───────────────────────────────────────────────
|
||||
import { WorkspaceNode } from "../WorkspaceNode";
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
function renderParentNode() {
|
||||
return render(
|
||||
<WorkspaceNode
|
||||
id={PARENT_ID}
|
||||
data={parentNodeData}
|
||||
// NodeProps — all required fields included; React Flow internals unused in mock env
|
||||
type="workspaceNode"
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
dragging={false}
|
||||
draggable={false}
|
||||
selectable={false}
|
||||
deletable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("TeamMemberChip eject button — aria-label (issue #854)", () => {
|
||||
it("eject button has a dynamic aria-label containing the child workspace name", () => {
|
||||
const { container } = renderParentNode();
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const ejectBtn = Array.from(buttons).find(
|
||||
(b) => b.getAttribute("aria-label")?.includes("Extract") && b.getAttribute("aria-label")?.includes("from team")
|
||||
);
|
||||
expect(ejectBtn).toBeTruthy();
|
||||
expect(ejectBtn?.getAttribute("aria-label")).toBe(`Extract ${CHILD_NAME} from team`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TeamMemberChip eject button — title tooltip (issue #854)", () => {
|
||||
it("eject button has a dynamic title tooltip containing the child workspace name", () => {
|
||||
const { container } = renderParentNode();
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const ejectBtn = Array.from(buttons).find(
|
||||
(b) => b.getAttribute("title")?.includes("Extract") && b.getAttribute("title")?.includes("from team")
|
||||
);
|
||||
expect(ejectBtn).toBeTruthy();
|
||||
expect(ejectBtn?.getAttribute("title")).toBe(`Extract ${CHILD_NAME} from team`);
|
||||
});
|
||||
|
||||
it("aria-label and title are identical (both use child workspace name)", () => {
|
||||
const { container } = renderParentNode();
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const ejectBtn = Array.from(buttons).find(
|
||||
(b) => b.getAttribute("aria-label")?.startsWith("Extract")
|
||||
);
|
||||
expect(ejectBtn).toBeTruthy();
|
||||
expect(ejectBtn?.getAttribute("aria-label")).toBe(ejectBtn?.getAttribute("title"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("TeamMemberChip eject button — aria-hidden on EjectIcon (issue #854)", () => {
|
||||
it("EjectIcon svg has aria-hidden='true' to prevent AT double-announcement", () => {
|
||||
const { container } = renderParentNode();
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const ejectBtn = Array.from(buttons).find(
|
||||
(b) => b.getAttribute("aria-label")?.startsWith("Extract")
|
||||
);
|
||||
expect(ejectBtn).toBeTruthy();
|
||||
const svg = ejectBtn?.querySelector("svg");
|
||||
expect(svg).toBeTruthy();
|
||||
expect(svg?.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
});
|
||||
289
canvas/src/components/__tests__/tabs.a11y.test.tsx
Normal file
289
canvas/src/components/__tests__/tabs.a11y.test.tsx
Normal file
@ -0,0 +1,289 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* WCAG 1.3.1 — label↔input association tests for SkillsTab, FilesTab,
|
||||
* ChannelsTab, and ScheduleTab.
|
||||
*
|
||||
* Each test verifies that every form control has an accessible name either via:
|
||||
* - `aria-label` (bare inputs without a visible label element)
|
||||
* - `htmlFor` + matching `id` wired through `useId()` (label↔control pairs)
|
||||
*
|
||||
* `getByLabelText` is the definitive assertion for the htmlFor/id pattern —
|
||||
* if it resolves, the association is valid per the AT accessibility tree.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
|
||||
// ── Global mocks (hoisted before imports) ────────────────────────────────────
|
||||
|
||||
const mockApiGet = vi.fn();
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: (...args: unknown[]) => mockApiGet(...args),
|
||||
post: vi.fn().mockResolvedValue({}),
|
||||
put: vi.fn().mockResolvedValue({}),
|
||||
del: vi.fn().mockResolvedValue({}),
|
||||
patch: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: vi.fn((selector: (s: Record<string, unknown>) => unknown) =>
|
||||
selector({ setPanelTab: vi.fn() })
|
||||
),
|
||||
summarizeWorkspaceCapabilities: vi.fn(() => ({ skills: [], tools: [] })),
|
||||
}));
|
||||
|
||||
vi.mock("../Toaster", () => ({ showToast: vi.fn() }));
|
||||
|
||||
// FilesTab sub-module stubs — stub them so we control the onNewFile callback
|
||||
vi.mock("../tabs/FilesTab/FilesToolbar", () => ({
|
||||
FilesToolbar: ({ onNewFile }: { onNewFile: () => void }) => (
|
||||
<button onClick={onNewFile} data-testid="new-file-btn">New File</button>
|
||||
),
|
||||
}));
|
||||
vi.mock("../tabs/FilesTab/FileTree", () => ({
|
||||
FileTree: () => <div data-testid="file-tree" />,
|
||||
}));
|
||||
vi.mock("../tabs/FilesTab/FileEditor", () => ({
|
||||
FileEditor: () => <div data-testid="file-editor" />,
|
||||
}));
|
||||
vi.mock("../tabs/FilesTab/useFilesApi", () => ({
|
||||
useFilesApi: () => ({
|
||||
files: [],
|
||||
loading: false,
|
||||
loadFiles: vi.fn(),
|
||||
expandedDirs: new Set<string>(),
|
||||
loadingDir: null,
|
||||
toggleDir: vi.fn(),
|
||||
readFile: vi.fn().mockResolvedValue({ content: "" }),
|
||||
writeFile: vi.fn().mockResolvedValue({}),
|
||||
deleteFile: vi.fn().mockResolvedValue({}),
|
||||
downloadAllFiles: vi.fn(),
|
||||
uploadFiles: vi.fn(),
|
||||
deleteAllFiles: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("../tabs/FilesTab/tree", () => ({
|
||||
buildTree: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("../ConfirmDialog", () => ({
|
||||
ConfirmDialog: () => null,
|
||||
}));
|
||||
|
||||
// ── Static imports (after mocks) ─────────────────────────────────────────────
|
||||
|
||||
import { SkillsTab } from "../tabs/SkillsTab";
|
||||
import { FilesTab } from "../tabs/FilesTab";
|
||||
import { ChannelsTab } from "../tabs/ChannelsTab";
|
||||
import { ScheduleTab } from "../tabs/ScheduleTab";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeSkillsData() {
|
||||
return {
|
||||
id: "ws-1",
|
||||
name: "Test WS",
|
||||
status: "online",
|
||||
tier: 1,
|
||||
agentCard: null,
|
||||
activeTasks: 0,
|
||||
collapsed: false,
|
||||
role: "agent",
|
||||
lastErrorRate: 0,
|
||||
lastSampleError: "",
|
||||
url: "http://localhost:9000",
|
||||
parentId: null,
|
||||
currentTask: "",
|
||||
runtime: "langgraph",
|
||||
needsRestart: false,
|
||||
budgetLimit: null,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 1. SkillsTab — aria-label on the "Install from source" bare input
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("SkillsTab — aria-label on bare source input (WCAG 1.3.1)", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('install source input has aria-label="Install from source URL"', async () => {
|
||||
render(<SkillsTab data={makeSkillsData() as never} />);
|
||||
|
||||
// The source input is inside the registry section (showRegistry=false initially).
|
||||
// Click the "+ Install Plugin" button to reveal it.
|
||||
const installBtn = screen.getByRole("button", { name: /install plugin/i });
|
||||
fireEvent.click(installBtn);
|
||||
|
||||
const input = screen.getByRole("textbox", {
|
||||
name: /install from source url/i,
|
||||
});
|
||||
expect(input).toBeDefined();
|
||||
expect(input.getAttribute("aria-label")).toBe("Install from source URL");
|
||||
});
|
||||
|
||||
it("install source input is a text input (not hidden)", async () => {
|
||||
render(<SkillsTab data={makeSkillsData() as never} />);
|
||||
|
||||
const installBtn = screen.getByRole("button", { name: /install plugin/i });
|
||||
fireEvent.click(installBtn);
|
||||
|
||||
const input = screen.getByRole("textbox", {
|
||||
name: /install from source url/i,
|
||||
});
|
||||
expect(input.tagName.toLowerCase()).toBe("input");
|
||||
expect((input as HTMLInputElement).type).toBe("text");
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 2. FilesTab — aria-label on the new file path bare input
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("FilesTab — aria-label on new file path input (WCAG 1.3.1)", () => {
|
||||
it('new file input has aria-label="New file path"', () => {
|
||||
render(<FilesTab workspaceId="ws-1" />);
|
||||
|
||||
// Trigger showNewFile via the FilesToolbar stub
|
||||
const btn = screen.getByTestId("new-file-btn");
|
||||
fireEvent.click(btn);
|
||||
|
||||
const input = screen.getByRole("textbox", { name: /new file path/i });
|
||||
expect(input).toBeDefined();
|
||||
expect(input.getAttribute("aria-label")).toBe("New file path");
|
||||
});
|
||||
|
||||
it("new file input is not shown before clicking the new file button", () => {
|
||||
render(<FilesTab workspaceId="ws-1" />);
|
||||
|
||||
expect(screen.queryByRole("textbox", { name: /new file path/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 3. ChannelsTab — htmlFor/id label associations via useId()
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ChannelsTab — htmlFor/id label associations (WCAG 1.3.1)", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockImplementation((url: string) => {
|
||||
if (url.includes("/channels/adapters")) {
|
||||
return Promise.resolve([{ type: "telegram", display_name: "Telegram" }]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
});
|
||||
|
||||
async function renderAndOpenForm() {
|
||||
render(<ChannelsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByRole("button", { name: /\+ connect/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ connect/i }));
|
||||
}
|
||||
|
||||
it("Platform label is associated with the select via htmlFor/id", async () => {
|
||||
await renderAndOpenForm();
|
||||
const platformSelect = screen.getByLabelText("Platform");
|
||||
expect(platformSelect.tagName.toLowerCase()).toBe("select");
|
||||
});
|
||||
|
||||
it("Bot Token label is associated with the password input via htmlFor/id", async () => {
|
||||
await renderAndOpenForm();
|
||||
const botTokenInput = screen.getByLabelText("Bot Token");
|
||||
expect(botTokenInput.tagName.toLowerCase()).toBe("input");
|
||||
expect((botTokenInput as HTMLInputElement).type).toBe("password");
|
||||
});
|
||||
|
||||
it("Chat IDs label is associated with the input via htmlFor/id", async () => {
|
||||
await renderAndOpenForm();
|
||||
const chatIdInput = screen.getByLabelText("Chat IDs");
|
||||
expect(chatIdInput.tagName.toLowerCase()).toBe("input");
|
||||
});
|
||||
|
||||
it("Allowed Users label is associated with the input via htmlFor/id", async () => {
|
||||
await renderAndOpenForm();
|
||||
// Label contains "(optional, comma-separated)" in a nested span — use regex
|
||||
const allowedUsersInput = screen.getByLabelText(/allowed users/i);
|
||||
expect(allowedUsersInput.tagName.toLowerCase()).toBe("input");
|
||||
});
|
||||
|
||||
it("all form control ids are unique and non-empty", async () => {
|
||||
await renderAndOpenForm();
|
||||
|
||||
const platformSelect = screen.getByLabelText("Platform");
|
||||
const botTokenInput = screen.getByLabelText("Bot Token");
|
||||
const chatIdInput = screen.getByLabelText("Chat IDs");
|
||||
const allowedUsersInput = screen.getByLabelText(/allowed users/i);
|
||||
|
||||
const ids = [
|
||||
platformSelect.id,
|
||||
botTokenInput.id,
|
||||
chatIdInput.id,
|
||||
allowedUsersInput.id,
|
||||
];
|
||||
const uniqueIds = new Set(ids);
|
||||
expect(uniqueIds.size).toBe(4);
|
||||
ids.forEach((id) => expect(id).toBeTruthy());
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 4. ScheduleTab — aria-label on name + htmlFor/id associations via useId()
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ScheduleTab — aria-label + htmlFor/id label associations (WCAG 1.3.1)", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
async function renderAndOpenForm() {
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByRole("button", { name: /\+ add schedule/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add schedule/i }));
|
||||
}
|
||||
|
||||
it('Schedule name input has aria-label="Schedule name"', async () => {
|
||||
await renderAndOpenForm();
|
||||
const nameInput = screen.getByRole("textbox", { name: /^schedule name$/i });
|
||||
expect(nameInput.getAttribute("aria-label")).toBe("Schedule name");
|
||||
});
|
||||
|
||||
it("Cron Expression label is associated with the input via htmlFor/id", async () => {
|
||||
await renderAndOpenForm();
|
||||
const cronInput = screen.getByLabelText("Cron Expression");
|
||||
expect(cronInput.tagName.toLowerCase()).toBe("input");
|
||||
expect((cronInput as HTMLInputElement).type).toBe("text");
|
||||
});
|
||||
|
||||
it("Timezone label is associated with the select via htmlFor/id", async () => {
|
||||
await renderAndOpenForm();
|
||||
const timezoneSelect = screen.getByLabelText("Timezone");
|
||||
expect(timezoneSelect.tagName.toLowerCase()).toBe("select");
|
||||
});
|
||||
|
||||
it("Prompt / Task label is associated with the textarea via htmlFor/id", async () => {
|
||||
await renderAndOpenForm();
|
||||
const promptTextarea = screen.getByLabelText(/prompt \/ task/i);
|
||||
expect(promptTextarea.tagName.toLowerCase()).toBe("textarea");
|
||||
});
|
||||
|
||||
it("all form control ids are unique and non-empty", async () => {
|
||||
await renderAndOpenForm();
|
||||
|
||||
const cronInput = screen.getByLabelText("Cron Expression");
|
||||
const timezoneSelect = screen.getByLabelText("Timezone");
|
||||
const promptTextarea = screen.getByLabelText(/prompt \/ task/i);
|
||||
|
||||
const ids = [cronInput.id, timezoneSelect.id, promptTextarea.id];
|
||||
const uniqueIds = new Set(ids);
|
||||
expect(uniqueIds.size).toBe(3);
|
||||
ids.forEach((id) => expect(id).toBeTruthy());
|
||||
});
|
||||
});
|
||||
@ -80,6 +80,7 @@ export function ActivityTab({ workspaceId }: Props) {
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => setFilter(f.id)}
|
||||
aria-pressed={filter === f.id}
|
||||
className={`px-2 py-1 text-[11px] rounded-md font-medium transition-all ${
|
||||
filter === f.id
|
||||
? "bg-zinc-700 text-zinc-100 ring-1 ring-zinc-600"
|
||||
@ -92,6 +93,7 @@ export function ActivityTab({ workspaceId }: Props) {
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
aria-pressed={autoRefresh}
|
||||
className={`text-[11px] px-1.5 py-0.5 rounded ${
|
||||
autoRefresh ? "text-emerald-400 bg-emerald-950/30" : "text-zinc-500"
|
||||
}`}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useId } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
|
||||
@ -53,6 +53,12 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
const [selectedChats, setSelectedChats] = useState<Set<string>>(new Set());
|
||||
const [showManualInput, setShowManualInput] = useState(false);
|
||||
|
||||
// Stable IDs for label↔input associations (WCAG 1.3.1)
|
||||
const platformId = useId();
|
||||
const botTokenId = useId();
|
||||
const chatIdId = useId();
|
||||
const allowedUsersId = useId();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [chRes, adRes] = await Promise.all([
|
||||
@ -208,8 +214,9 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
{showForm && (
|
||||
<div className="space-y-2 p-3 bg-zinc-800/40 rounded border border-zinc-700/50">
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">Platform</label>
|
||||
<label htmlFor={platformId} className="text-[10px] text-zinc-500 block mb-1">Platform</label>
|
||||
<select
|
||||
id={platformId}
|
||||
value={formType}
|
||||
onChange={(e) => setFormType(e.target.value)}
|
||||
className="w-full text-xs bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-zinc-300"
|
||||
@ -220,8 +227,9 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">Bot Token</label>
|
||||
<label htmlFor={botTokenId} className="text-[10px] text-zinc-500 block mb-1">Bot Token</label>
|
||||
<input
|
||||
id={botTokenId}
|
||||
type="password"
|
||||
value={formBotToken}
|
||||
onChange={(e) => setFormBotToken(e.target.value)}
|
||||
@ -231,7 +239,7 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-[10px] text-zinc-500">Chat IDs</label>
|
||||
<label htmlFor={chatIdId} className="text-[10px] text-zinc-500">Chat IDs</label>
|
||||
<button
|
||||
onClick={handleDiscover}
|
||||
disabled={discovering || !formBotToken}
|
||||
@ -261,6 +269,7 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
)}
|
||||
{(discoveredChats.length === 0 || showManualInput) && (
|
||||
<input
|
||||
id={chatIdId}
|
||||
value={formChatId}
|
||||
onChange={(e) => setFormChatId(e.target.value)}
|
||||
placeholder="-100123456789, -100987654321"
|
||||
@ -285,10 +294,11 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">
|
||||
<label htmlFor={allowedUsersId} className="text-[10px] text-zinc-500 block mb-1">
|
||||
Allowed Users <span className="text-zinc-600">(optional, comma-separated)</span>
|
||||
</label>
|
||||
<input
|
||||
id={allowedUsersId}
|
||||
value={formAllowedUsers}
|
||||
onChange={(e) => setFormAllowedUsers(e.target.value)}
|
||||
placeholder="123456789, 987654321"
|
||||
|
||||
@ -55,7 +55,7 @@ function extractReplyText(resp: A2AResponse): string {
|
||||
* Load chat history from the activity_logs database via the platform API.
|
||||
* Uses source=canvas to only get user-initiated messages (not agent-to-agent).
|
||||
*/
|
||||
async function loadMessagesFromDB(workspaceId: string): Promise<ChatMessage[]> {
|
||||
async function loadMessagesFromDB(workspaceId: string): Promise<{ messages: ChatMessage[]; error: string | null }> {
|
||||
try {
|
||||
const activities = await api.get<Array<{
|
||||
activity_type: string;
|
||||
@ -83,9 +83,12 @@ async function loadMessagesFromDB(workspaceId: string): Promise<ChatMessage[]> {
|
||||
}
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
} catch {
|
||||
return [];
|
||||
return { messages, error: null };
|
||||
} catch (err) {
|
||||
return {
|
||||
messages: [],
|
||||
error: err instanceof Error ? err.message : "Failed to load chat history",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -162,6 +165,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
const [thinkingElapsed, setThinkingElapsed] = useState(0);
|
||||
const [activityLog, setActivityLog] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const currentTaskRef = useRef(data.currentTask);
|
||||
const sendingFromAPIRef = useRef(false);
|
||||
const [agentReachable, setAgentReachable] = useState(false);
|
||||
@ -172,8 +176,10 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
// Load chat history from database on mount
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
loadMessagesFromDB(workspaceId).then((msgs) => {
|
||||
setLoadError(null);
|
||||
loadMessagesFromDB(workspaceId).then(({ messages: msgs, error: fetchErr }) => {
|
||||
setMessages(msgs);
|
||||
setLoadError(fetchErr);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [workspaceId]);
|
||||
@ -355,7 +361,31 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
{loading && (
|
||||
<div className="text-xs text-zinc-500 text-center py-4">Loading chat history...</div>
|
||||
)}
|
||||
{!loading && messages.length === 0 && (
|
||||
{!loading && loadError !== null && messages.length === 0 && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mx-2 mt-2 rounded-lg border border-red-800/50 bg-red-950/30 px-3 py-2.5"
|
||||
>
|
||||
<p className="text-[11px] text-red-400 mb-1.5">
|
||||
Failed to load chat history: {loadError}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
loadMessagesFromDB(workspaceId).then(({ messages: msgs, error: fetchErr }) => {
|
||||
setMessages(msgs);
|
||||
setLoadError(fetchErr);
|
||||
setLoading(false);
|
||||
});
|
||||
}}
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-red-800/40 text-red-300 hover:bg-red-700/50 transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!loading && loadError === null && messages.length === 0 && (
|
||||
<div className="text-xs text-zinc-500 text-center py-8">
|
||||
No messages yet. Send a message to start chatting with this agent.
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useCallback, useRef, useId } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { type ConfigData, DEFAULT_CONFIG, TextInput, NumberInput, Toggle, TagList, Section } from "./config/form-inputs";
|
||||
@ -170,6 +170,14 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
// Stable IDs for bare label↔control pairs (WCAG 1.3.1)
|
||||
const descriptionId = useId();
|
||||
const tierId = useId();
|
||||
const runtimeId = useId();
|
||||
const effortId = useId();
|
||||
const taskBudgetId = useId();
|
||||
const sandboxBackendId = useId();
|
||||
|
||||
const isDirty = rawMode ? rawDraft !== originalYaml : toYaml(config) !== originalYaml;
|
||||
|
||||
if (loading) {
|
||||
@ -214,8 +222,9 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
<Section title="General">
|
||||
<TextInput label="Name" value={config.name} onChange={(v) => update("name", v)} />
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">Description</label>
|
||||
<label htmlFor={descriptionId} className="text-[10px] text-zinc-500 block mb-1">Description</label>
|
||||
<textarea
|
||||
id={descriptionId}
|
||||
value={config.description}
|
||||
onChange={(e) => update("description", e.target.value)}
|
||||
rows={3}
|
||||
@ -225,8 +234,9 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextInput label="Version" value={config.version} onChange={(v) => update("version", v)} mono />
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">Tier</label>
|
||||
<label htmlFor={tierId} className="text-[10px] text-zinc-500 block mb-1">Tier</label>
|
||||
<select
|
||||
id={tierId}
|
||||
value={config.tier}
|
||||
onChange={(e) => update("tier", parseInt(e.target.value, 10))}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500"
|
||||
@ -242,8 +252,9 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
<Section title="Runtime">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">Runtime</label>
|
||||
<label htmlFor={runtimeId} className="text-[10px] text-zinc-500 block mb-1">Runtime</label>
|
||||
<select
|
||||
id={runtimeId}
|
||||
value={config.runtime || ""}
|
||||
onChange={(e) => update("runtime", e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500"
|
||||
@ -273,11 +284,12 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
(config.runtime_config?.model || config.model || "").toLowerCase().includes("anthropic")) && (
|
||||
<Section title="Claude Settings" defaultOpen={false}>
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">
|
||||
<label htmlFor={effortId} className="text-[10px] text-zinc-500 block mb-1">
|
||||
Effort
|
||||
<span className="ml-1 text-zinc-600">(output_config.effort — Opus 4.7+)</span>
|
||||
</label>
|
||||
<select
|
||||
id={effortId}
|
||||
value={config.effort || ""}
|
||||
onChange={(e) => update("effort", e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500"
|
||||
@ -292,11 +304,12 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">
|
||||
<label htmlFor={taskBudgetId} className="text-[10px] text-zinc-500 block mb-1">
|
||||
Task Budget (tokens)
|
||||
<span className="ml-1 text-zinc-600">(output_config.task_budget.total — 0 = unset)</span>
|
||||
</label>
|
||||
<input
|
||||
id={taskBudgetId}
|
||||
type="number"
|
||||
min={0}
|
||||
step={1000}
|
||||
@ -334,8 +347,9 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
|
||||
<Section title="Sandbox" defaultOpen={false}>
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">Backend</label>
|
||||
<label htmlFor={sandboxBackendId} className="text-[10px] text-zinc-500 block mb-1">Backend</label>
|
||||
<select
|
||||
id={sandboxBackendId}
|
||||
value={config.sandbox?.backend || "docker"}
|
||||
onChange={(e) => updateNested("sandbox" as keyof ConfigData, "backend", e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef, useId, cloneElement, type ReactElement } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
||||
import { StatusDot } from "../StatusDot";
|
||||
@ -36,6 +36,8 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
const updateNodeData = useCanvasStore((s) => s.updateNodeData);
|
||||
const removeNode = useCanvasStore((s) => s.removeNode);
|
||||
const selectNode = useCanvasStore((s) => s.selectNode);
|
||||
// Ref for the "Delete Workspace" trigger — Cancel returns focus here
|
||||
const deleteButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setName(data.name);
|
||||
@ -255,6 +257,15 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
</div>
|
||||
)}
|
||||
{confirmDelete ? (
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="delete-confirm-title"
|
||||
className="space-y-2"
|
||||
>
|
||||
<h3 id="delete-confirm-title" className="text-xs font-medium text-red-400">
|
||||
Confirm deletion
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
@ -263,14 +274,21 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
Confirm Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setConfirmDelete(false); setDeleteError(null); }}
|
||||
onClick={() => {
|
||||
setConfirmDelete(false);
|
||||
setDeleteError(null);
|
||||
// Return focus to the trigger so keyboard users aren't stranded
|
||||
deleteButtonRef.current?.focus();
|
||||
}}
|
||||
className="px-3 py-1 bg-zinc-700 hover:bg-zinc-600 text-xs rounded text-zinc-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
ref={deleteButtonRef}
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
className="px-3 py-1 bg-zinc-800 hover:bg-red-900 border border-zinc-700 hover:border-red-700 text-xs rounded text-zinc-400 hover:text-red-400 transition-colors"
|
||||
>
|
||||
@ -292,10 +310,11 @@ function Section({ title, children }: { title: string; children: React.ReactNode
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
const fieldId = useId();
|
||||
return (
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-0.5">{label}</label>
|
||||
{children}
|
||||
<label htmlFor={fieldId} className="text-[10px] text-zinc-500 block mb-0.5">{label}</label>
|
||||
{cloneElement(children as ReactElement<{ id?: string }>, { id: fieldId })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -192,6 +192,7 @@ export function FilesTab({ workspaceId }: Props) {
|
||||
{showNewFile && (
|
||||
<div className="px-2 py-1 border-b border-zinc-800/40">
|
||||
<input
|
||||
aria-label="New file path"
|
||||
value={newFileName}
|
||||
onChange={(e) => setNewFileName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && createFile()}
|
||||
|
||||
@ -120,7 +120,7 @@ export function MemoryTab({ workspaceId }: Props) {
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
{error && !showAdd && (
|
||||
<div className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">
|
||||
<div role="alert" className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@ -233,6 +233,7 @@ export function MemoryTab({ workspaceId }: Props) {
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
placeholder="Key"
|
||||
aria-label="Memory key"
|
||||
className="w-full bg-zinc-900 border border-zinc-600 rounded px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<textarea
|
||||
@ -240,15 +241,17 @@ export function MemoryTab({ workspaceId }: Props) {
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
placeholder='Value (JSON or plain text)'
|
||||
rows={3}
|
||||
aria-label="Memory value (JSON or plain text)"
|
||||
className="w-full bg-zinc-900 border border-zinc-600 rounded px-2 py-1 text-xs font-mono text-zinc-100 focus:outline-none focus:border-blue-500 resize-none"
|
||||
/>
|
||||
<input
|
||||
value={newTTL}
|
||||
onChange={(e) => setNewTTL(e.target.value)}
|
||||
placeholder="TTL in seconds (optional)"
|
||||
aria-label="TTL in seconds (optional)"
|
||||
className="w-full bg-zinc-900 border border-zinc-600 rounded px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
{error && <div className="text-xs text-red-400">{error}</div>}
|
||||
{error && <div role="alert" className="text-xs text-red-400">{error}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
@ -279,6 +282,7 @@ export function MemoryTab({ workspaceId }: Props) {
|
||||
<button
|
||||
onClick={() => setExpanded(expanded === entry.key ? null : entry.key)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-left"
|
||||
aria-expanded={expanded === entry.key}
|
||||
>
|
||||
<span className="text-xs font-mono text-blue-400">{entry.key}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useId } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
|
||||
@ -67,6 +67,11 @@ export function ScheduleTab({ workspaceId }: Props) {
|
||||
const [error, setError] = useState("");
|
||||
const [pendingDelete, setPendingDelete] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
// Stable IDs for label↔input associations (WCAG 1.3.1)
|
||||
const cronId = useId();
|
||||
const timezoneId = useId();
|
||||
const promptId = useId();
|
||||
|
||||
const fetchSchedules = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.get<Schedule[]>(`/workspaces/${workspaceId}/schedules`);
|
||||
@ -198,6 +203,7 @@ export function ScheduleTab({ workspaceId }: Props) {
|
||||
<div className="p-3 border-b border-zinc-800/50 bg-zinc-900/50 space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Schedule name"
|
||||
placeholder="Schedule name (e.g., Daily security scan)"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
@ -205,8 +211,9 @@ export function ScheduleTab({ workspaceId }: Props) {
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-[10px] text-zinc-500 block mb-0.5">Cron Expression</label>
|
||||
<label htmlFor={cronId} className="text-[10px] text-zinc-500 block mb-0.5">Cron Expression</label>
|
||||
<input
|
||||
id={cronId}
|
||||
type="text"
|
||||
value={formCron}
|
||||
onChange={(e) => setFormCron(e.target.value)}
|
||||
@ -217,8 +224,9 @@ export function ScheduleTab({ workspaceId }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-24">
|
||||
<label className="text-[10px] text-zinc-500 block mb-0.5">Timezone</label>
|
||||
<label htmlFor={timezoneId} className="text-[10px] text-zinc-500 block mb-0.5">Timezone</label>
|
||||
<select
|
||||
id={timezoneId}
|
||||
value={formTimezone}
|
||||
onChange={(e) => setFormTimezone(e.target.value)}
|
||||
className="w-full text-[10px] bg-zinc-800 border border-zinc-700 rounded px-1 py-1 text-zinc-200"
|
||||
@ -237,8 +245,9 @@ export function ScheduleTab({ workspaceId }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-0.5">Prompt / Task</label>
|
||||
<label htmlFor={promptId} className="text-[10px] text-zinc-500 block mb-0.5">Prompt / Task</label>
|
||||
<textarea
|
||||
id={promptId}
|
||||
value={formPrompt}
|
||||
onChange={(e) => setFormPrompt(e.target.value)}
|
||||
placeholder="What should the agent do on this schedule?"
|
||||
|
||||
@ -232,6 +232,7 @@ export function SkillsTab({ data }: Props) {
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Install from source URL"
|
||||
value={customSource}
|
||||
onChange={(e) => setCustomSource(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
|
||||
@ -22,6 +22,7 @@ function makeNodeData(overrides: Partial<WorkspaceNodeData> = {}): WorkspaceNode
|
||||
currentTask: "",
|
||||
needsRestart: false,
|
||||
runtime: "",
|
||||
budgetLimit: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@ -34,6 +34,7 @@ function makeNode(
|
||||
currentTask: "",
|
||||
needsRestart: false,
|
||||
runtime: "",
|
||||
budgetLimit: null,
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
|
||||
@ -31,6 +31,7 @@ function makeNode(
|
||||
currentTask: "",
|
||||
needsRestart: false,
|
||||
runtime: "",
|
||||
budgetLimit: null,
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
|
||||
@ -24,6 +24,7 @@ function makeWS(overrides: Partial<WorkspaceData> & { id: string }): WorkspaceDa
|
||||
y: 0,
|
||||
collapsed: false,
|
||||
runtime: "",
|
||||
budget_limit: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@ -27,6 +27,7 @@ function makeWS(overrides: Partial<WorkspaceData> & { id: string }): WorkspaceDa
|
||||
y: 0,
|
||||
collapsed: false,
|
||||
runtime: "",
|
||||
budget_limit: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@ -172,6 +173,7 @@ describe("summarizeWorkspaceCapabilities", () => {
|
||||
currentTask: "Reviewing docs",
|
||||
needsRestart: false,
|
||||
runtime: "claude-code",
|
||||
budgetLimit: null,
|
||||
});
|
||||
|
||||
expect(summary.runtime).toBe("claude-code");
|
||||
@ -197,6 +199,7 @@ describe("summarizeWorkspaceCapabilities", () => {
|
||||
currentTask: " ",
|
||||
needsRestart: false,
|
||||
runtime: "",
|
||||
budgetLimit: null,
|
||||
});
|
||||
|
||||
expect(summary.runtime).toBeNull();
|
||||
@ -554,6 +557,7 @@ describe("context menu", () => {
|
||||
currentTask: "",
|
||||
needsRestart: false,
|
||||
runtime: "",
|
||||
budgetLimit: null,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -1512,6 +1512,22 @@ builders; Molecule AI users are developers building agent companies.
|
||||
|
||||
---
|
||||
|
||||
### Anthropic Managed Agents — `api.anthropic.com` *(commercial, public beta)*
|
||||
|
||||
**Pitch:** "Run managed agent sessions with built-in sandboxing, checkpointing, credential management, and end-to-end tracing — without managing infrastructure."
|
||||
|
||||
**Shape:** Anthropic-hosted API, public beta since April 8, 2026 (`managed-agents-2026-04-01` beta header required). Bundles: agent loop + tool execution, sandboxed container per session, state persistence (conversation-history checkpointing per session), credential management + scoped permissions, end-to-end tracing. Pricing: standard API token cost + **$0.08/session-hour** active runtime (idle = zero cost). SSE stream endpoint (`GET /v1/sessions/{id}/stream`) for real-time event delivery. `user.tool_confirmation` SSE event supports async tool approval/denial from the application layer.
|
||||
|
||||
**Overlap with us:** Idle-zero billing addresses the same problem as GH #711 (workspace hibernation). Per-session sandboxing overlaps E2B (#574). Session-level conversation checkpointing partially overlaps Temporal durable execution (#583).
|
||||
|
||||
**Differentiation:** Session checkpointing ≠ Temporal — Managed Agents checkpoints conversation history; Temporal handles cross-workspace workflow orchestration, retry sagas, and distributed state. Our Docker workspace model is richer: persistent identity, multi-agent A2A, org hierarchy, RBAC, visual canvas, model-agnosticism. RBAC passthrough requires an async out-of-band sidecar (our `check_permission` gates run inside the workspace process; Managed Agents loop runs server-side). Cost neutral at ~2 active hrs/day (~$0.16/day vs ~$0.10–0.17/day Fly.io shared-1x); more expensive for high-throughput workspaces (8+ active hrs/day). API surface explicitly unstable ("behaviors may be refined between releases" — Anthropic docs).
|
||||
|
||||
**Signals to react to:** GA announcement → re-evaluate `ClaudeManagedAgentsExecutor` adapter spike (GH #742 closed: WATCH-FOR-GA). Multiagent coordination + memory research-preview features exit waitlist → evaluate whether built-in multi-agent replaces our A2A layer or complements it. `tool_confirmation` API stabilizes → simplifies our RBAC passthrough sidecar design. Price drop below $0.05/session-hour → re-run cost model for high-traffic workspaces.
|
||||
|
||||
**Last reviewed:** 2026-04-17 · **Stars / activity:** Anthropic cloud API, public beta (Apr 8 2026). **Verdict: WATCH-FOR-GA** (GH #742 closed). Adapter estimated ~150–200 LOC, non-trivial async session model, RBAC interception requires architectural work.
|
||||
|
||||
---
|
||||
|
||||
### Microsoft Agent Framework — `microsoft/agent-framework`
|
||||
|
||||
**Pitch:** "A framework for building, orchestrating and deploying AI agents and multi-agent workflows with support for Python and .NET."
|
||||
@ -2860,6 +2876,28 @@ langgraph/crewai adapters.
|
||||
|
||||
---
|
||||
|
||||
### smolagents — `huggingface/smolagents`
|
||||
|
||||
**Pitch:** "The simplest library to build powerful agents" — Hugging Face's barebones, code-first agent framework.
|
||||
|
||||
**Shape:** Python, Apache-2.0, 26.5k★, ~1,000 lines of core library code. Primary primitive is `CodeAgent`: instead of emitting tool calls as JSON, the agent writes executable Python that calls tools directly — "thinking in code." Model-agnostic via LiteLLM (OpenAI, Anthropic, Mistral, Ollama, etc.). Sandboxed code execution via E2B, Modal, Docker, or Pyodide (WASM). Hugging Face Hub integration for sharing reusable tools and agents. Multimodal support (text, vision). CLI utilities (`smolagent`, `webagent`). Companion: `huggingface/agents-course` for onboarding.
|
||||
|
||||
**Overlap with us:** (1) Code-first agent execution sits at the same runtime layer as `molecule-ai-workspace-template`. (2) Tool sharing via Hub = a public registry alternative to our internal tool registry. (3) Sandboxed execution (E2B/Docker) mirrors our Docker workspace isolation model. (4) Multimodal + model-agnostic design aligns with our workspace-template flexibility goals. (5) 26.5k★ + Hugging Face distribution = strong community pull for developers who land here before Molecule.
|
||||
|
||||
**Differentiation:** Single-agent, no multi-agent orchestration, no A2A protocol, no org hierarchy, no canvas, no scheduling, no workspace lifecycle management. "Barebones by design" — Molecule is the governance + multi-tenant + orchestration layer smolagents explicitly omits. smolagents' code execution sandbox is local-process; Molecule provides a full Docker workspace per agent.
|
||||
|
||||
**Worth borrowing:** CodeAgent pattern (agent writes Python to call tools) as an optional execution mode for workspace-template. Hub-based tool registry concept — could inform a public Molecule tool/template marketplace. E2B integration pattern for lightweight sandboxing of short-lived tasks.
|
||||
|
||||
**Terminology collisions:** "agents" (identical), "tools" (identical), "CodeAgent" ≈ our workspace-template code execution runner.
|
||||
|
||||
**Signals to react to:** Monitor HF Hub publish progress (template in active development). If SmolAgents ships native A2A → shim becomes zero-LOC, elevate template priority. If HuggingFace officially designates smolagents as the default agent runtime for HF Spaces → distribution advantage increases, fast-track release. Docker-in-Docker gotcha: default must be `executor_type="local"` (AST-sandboxed); `DockerExecutor` requires `--privileged` and must never be the default.
|
||||
|
||||
**Threshold override note:** BUILD authorized at 26,688★ (below 30k criterion). Rationale: HuggingFace corporate backing, zero-cost `Tool.from_langchain` integration path (~145 LOC A2A shim — `fastapi-agents` SmolagentsAgent validates pattern), and ~60-day trajectory to 30k. Waiting risked a community fork defining the integration pattern before us.
|
||||
|
||||
**Last reviewed:** 2026-04-17 · **Stars / activity:** 26,688★, Python, Apache-2.0, active Hugging Face development. **Verdict: BUILD** (threshold override — GH #792 closed, Dev Lead issue filed). Template: `molecule-ai-workspace-template-smolagents`, ~4 engineer-days, security review required.
|
||||
|
||||
---
|
||||
|
||||
### Claw Code — `ultraworkers/claw-code`
|
||||
|
||||
**Pitch:** Clean-room Python + Rust rewrite of the Claude Code agentic architecture — fastest GitHub repository to 100k stars in history.
|
||||
|
||||
@ -8,6 +8,8 @@ WORKDIR /app
|
||||
# Plugin source for replace directive in go.mod
|
||||
COPY molecule-ai-plugin-github-app-auth/ /plugin/
|
||||
COPY platform/go.mod platform/go.sum ./
|
||||
# Add replace directive for Docker builds (plugin is COPYed to /plugin above)
|
||||
RUN echo 'replace github.com/Molecule-AI/molecule-ai-plugin-github-app-auth => /plugin' >> go.mod
|
||||
RUN go mod download
|
||||
COPY platform/ .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /platform ./cmd/server
|
||||
|
||||
@ -16,7 +16,9 @@
|
||||
# ── Stage 1: Go platform binary ──────────────────────────────────────
|
||||
FROM golang:1.25-alpine AS go-builder
|
||||
WORKDIR /app
|
||||
COPY molecule-ai-plugin-github-app-auth/ /plugin/
|
||||
COPY platform/go.mod platform/go.sum ./
|
||||
RUN echo 'replace github.com/Molecule-AI/molecule-ai-plugin-github-app-auth => /plugin' >> go.mod
|
||||
RUN go mod download
|
||||
COPY platform/ .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /platform ./cmd/server
|
||||
|
||||
@ -90,5 +90,3 @@ require (
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
replace github.com/Molecule-AI/molecule-ai-plugin-github-app-auth => /plugin
|
||||
|
||||
@ -438,6 +438,8 @@ func (t *TelegramAdapter) StartPolling(ctx context.Context, config map[string]in
|
||||
u.Timeout = 30
|
||||
u.AllowedUpdates = []string{"message", "channel_post", "my_chat_member"}
|
||||
|
||||
u.AllowedUpdates = append(u.AllowedUpdates, "callback_query")
|
||||
|
||||
log.Printf("Channels: Telegram polling started for chats %v (bot: @%s)", chatIDs, bot.Self.UserName)
|
||||
|
||||
for {
|
||||
@ -480,6 +482,45 @@ func (t *TelegramAdapter) StartPolling(ctx context.Context, config map[string]in
|
||||
for _, update := range updates {
|
||||
u.Offset = update.UpdateID + 1
|
||||
|
||||
// Handle callback_query (inline keyboard button clicks)
|
||||
if update.CallbackQuery != nil {
|
||||
cb := update.CallbackQuery
|
||||
chatID := strconv.FormatInt(cb.Message.Chat.ID, 10)
|
||||
|
||||
// Acknowledge the button press (removes loading spinner)
|
||||
ackCfg := tgbotapi.NewCallback(cb.ID, "Received")
|
||||
bot.Send(ackCfg)
|
||||
|
||||
// Update the message to show what was clicked
|
||||
decision := "approved"
|
||||
if strings.HasPrefix(cb.Data, "reject") {
|
||||
decision = "rejected"
|
||||
}
|
||||
editMsg := tgbotapi.NewEditMessageText(
|
||||
cb.Message.Chat.ID,
|
||||
cb.Message.MessageID,
|
||||
cb.Message.Text+"\n\n✅ CEO "+decision,
|
||||
)
|
||||
bot.Send(editMsg)
|
||||
|
||||
// Route the decision as an inbound message to the agent
|
||||
inbound := &InboundMessage{
|
||||
ChatID: chatID,
|
||||
UserID: strconv.FormatInt(cb.From.ID, 10),
|
||||
Username: cb.From.UserName,
|
||||
Text: "CEO_DECISION: " + cb.Data,
|
||||
MessageID: strconv.Itoa(cb.Message.MessageID),
|
||||
Metadata: map[string]string{
|
||||
"callback_data": cb.Data,
|
||||
"decision": decision,
|
||||
},
|
||||
}
|
||||
if err := onMessage(ctx, channelID, inbound); err != nil {
|
||||
log.Printf("Channels: Telegram callback handler error: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle my_chat_member: auto-greet when bot is added to a new chat
|
||||
if update.MyChatMember != nil {
|
||||
handleMyChatMember(bot, update.MyChatMember)
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
package handlers
|
||||
|
||||
// Integration tests for the workspace hibernation feature (issue #711 / PR #724).
|
||||
// Updated for the atomic TOCTOU fix (issue #819).
|
||||
//
|
||||
// Coverage:
|
||||
// - HibernateWorkspace(): container stop, DB status update, Redis key clear, event broadcast
|
||||
// - HibernateWorkspace(): atomic claim, container stop, DB status update, Redis key clear, event broadcast
|
||||
// - POST /workspaces/:id/hibernate HTTP handler: online→200, not-eligible→404, DB error→500
|
||||
// - resolveAgentURL(): hibernated workspace → 503 + Retry-After: 15 + waking: true
|
||||
//
|
||||
@ -28,10 +29,11 @@ import (
|
||||
// HibernateWorkspace unit tests
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestHibernateWorkspace_OnlineWorkspace_Success verifies the happy-path:
|
||||
// - DB returns the workspace (online/degraded)
|
||||
// - provisioner is nil — no Stop() call needed (test-safe guard in production code)
|
||||
// - UPDATE sets status='hibernated', url=''
|
||||
// TestHibernateWorkspace_OnlineWorkspace_Success verifies the happy-path with
|
||||
// the 3-step atomic pattern (#819):
|
||||
// - Atomic claim UPDATE returns rowsAffected=1 (workspace was online/degraded + active_tasks=0)
|
||||
// - Name/tier SELECT runs after the claim
|
||||
// - Final UPDATE sets status='hibernated', url=''
|
||||
// - Redis keys ws:{id}, ws:{id}:url, ws:{id}:internal_url are deleted
|
||||
// - WORKSPACE_HIBERNATED event is broadcast (INSERT INTO structure_events)
|
||||
func TestHibernateWorkspace_OnlineWorkspace_Success(t *testing.T) {
|
||||
@ -47,12 +49,17 @@ func TestHibernateWorkspace_OnlineWorkspace_Success(t *testing.T) {
|
||||
mr.Set(fmt.Sprintf("ws:%s:url", wsID), "http://agent.internal:8000")
|
||||
mr.Set(fmt.Sprintf("ws:%s:internal_url", wsID), "http://172.17.0.5:8000")
|
||||
|
||||
// HibernateWorkspace does a SELECT first.
|
||||
mock.ExpectQuery(`SELECT name, tier FROM workspaces WHERE id = .* AND status IN`).
|
||||
// Step 1: atomic claim UPDATE succeeds.
|
||||
mock.ExpectExec(`UPDATE workspaces`).
|
||||
WithArgs(wsID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// Post-claim SELECT for name/tier.
|
||||
mock.ExpectQuery(`SELECT name, tier FROM workspaces WHERE id`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "tier"}).AddRow("Idle Agent", 1))
|
||||
|
||||
// Then UPDATE status.
|
||||
// Step 3: final UPDATE to 'hibernated'.
|
||||
mock.ExpectExec(`UPDATE workspaces SET status = 'hibernated'`).
|
||||
WithArgs(wsID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
@ -77,9 +84,10 @@ func TestHibernateWorkspace_OnlineWorkspace_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHibernateWorkspace_NotEligible_NoOp verifies that when the workspace is
|
||||
// NOT in online/degraded state (SELECT returns ErrNoRows), HibernateWorkspace
|
||||
// returns immediately — no UPDATE, no Redis clear, no broadcast.
|
||||
// TestHibernateWorkspace_NotEligible_NoOp verifies that when the atomic claim
|
||||
// UPDATE returns rowsAffected=0 (workspace not in online/degraded state, or
|
||||
// active_tasks > 0), HibernateWorkspace returns immediately — no Stop, no
|
||||
// final UPDATE, no Redis clear, no broadcast.
|
||||
func TestHibernateWorkspace_NotEligible_NoOp(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mr := setupTestRedis(t)
|
||||
@ -88,17 +96,17 @@ func TestHibernateWorkspace_NotEligible_NoOp(t *testing.T) {
|
||||
|
||||
wsID := "ws-already-offline"
|
||||
|
||||
// Simulate workspace not in eligible state (offline, paused, removed …)
|
||||
mock.ExpectQuery(`SELECT name, tier FROM workspaces WHERE id = .* AND status IN`).
|
||||
// Atomic claim finds nothing matching WHERE (workspace offline, paused, etc.).
|
||||
mock.ExpectExec(`UPDATE workspaces`).
|
||||
WithArgs(wsID).
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
|
||||
// Set a Redis key to confirm it is NOT cleared by early return.
|
||||
mr.Set(fmt.Sprintf("ws:%s:url", wsID), "http://still-here:8000")
|
||||
|
||||
handler.HibernateWorkspace(context.Background(), wsID)
|
||||
|
||||
// No further DB operations should have happened.
|
||||
// Only the one ExecContext expectation; no further DB operations.
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet DB expectations: %v", err)
|
||||
}
|
||||
@ -110,7 +118,7 @@ func TestHibernateWorkspace_NotEligible_NoOp(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestHibernateWorkspace_DBUpdateFails_NoCrash verifies that a DB error on the
|
||||
// UPDATE does not panic — the function logs and returns silently.
|
||||
// final status UPDATE does not panic — the function logs and returns silently.
|
||||
func TestHibernateWorkspace_DBUpdateFails_NoCrash(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
@ -119,10 +127,17 @@ func TestHibernateWorkspace_DBUpdateFails_NoCrash(t *testing.T) {
|
||||
|
||||
wsID := "ws-update-fail"
|
||||
|
||||
mock.ExpectQuery(`SELECT name, tier FROM workspaces WHERE id = .* AND status IN`).
|
||||
// Step 1: atomic claim succeeds.
|
||||
mock.ExpectExec(`UPDATE workspaces`).
|
||||
WithArgs(wsID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// Post-claim SELECT.
|
||||
mock.ExpectQuery(`SELECT name, tier FROM workspaces WHERE id`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "tier"}).AddRow("Flaky Agent", 2))
|
||||
|
||||
// Step 3: final UPDATE fails.
|
||||
mock.ExpectExec(`UPDATE workspaces SET status = 'hibernated'`).
|
||||
WithArgs(wsID).
|
||||
WillReturnError(fmt.Errorf("db: connection refused"))
|
||||
@ -136,7 +151,7 @@ func TestHibernateWorkspace_DBUpdateFails_NoCrash(t *testing.T) {
|
||||
|
||||
handler.HibernateWorkspace(context.Background(), wsID)
|
||||
|
||||
// SELECT + UPDATE expectations met; no INSERT INTO structure_events expected.
|
||||
// Claim + SELECT + failing UPDATE; no INSERT INTO structure_events expected.
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet DB expectations: %v", err)
|
||||
}
|
||||
@ -160,6 +175,8 @@ func hibernateRequest(t *testing.T, handler *WorkspaceHandler, wsID string) *htt
|
||||
|
||||
// TestHibernateHandler_Online_Returns200 verifies that an online workspace
|
||||
// that is eligible for hibernation returns 200 {"status":"hibernated"}.
|
||||
// With the 3-step fix: handler SELECT → atomic claim UPDATE → name/tier SELECT
|
||||
// → final UPDATE → broadcaster INSERT.
|
||||
func TestHibernateHandler_Online_Returns200(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
@ -168,17 +185,22 @@ func TestHibernateHandler_Online_Returns200(t *testing.T) {
|
||||
|
||||
wsID := "ws-handler-online"
|
||||
|
||||
// Hibernate() handler SELECT — verifies workspace is online/degraded.
|
||||
// Hibernate() handler eligibility SELECT — checks status IN ('online','degraded').
|
||||
mock.ExpectQuery(`SELECT name, tier FROM workspaces WHERE id = .* AND status IN`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "tier"}).AddRow("Online Bot", 1))
|
||||
|
||||
// HibernateWorkspace() SELECT — same query, checks state again before acting.
|
||||
mock.ExpectQuery(`SELECT name, tier FROM workspaces WHERE id = .* AND status IN`).
|
||||
// HibernateWorkspace() step 1: atomic claim.
|
||||
mock.ExpectExec(`UPDATE workspaces`).
|
||||
WithArgs(wsID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// Post-claim SELECT for name/tier.
|
||||
mock.ExpectQuery(`SELECT name, tier FROM workspaces WHERE id`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "tier"}).AddRow("Online Bot", 1))
|
||||
|
||||
// HibernateWorkspace() UPDATE.
|
||||
// Step 3: final UPDATE.
|
||||
mock.ExpectExec(`UPDATE workspaces SET status = 'hibernated'`).
|
||||
WithArgs(wsID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
@ -32,6 +33,50 @@ const defaultMemoryNamespace = "general"
|
||||
// to nothing in the 'english' config.
|
||||
const memoryFTSMinQueryLen = 2
|
||||
|
||||
// secretPatternEntry is a compiled regex + its human-readable redaction label.
|
||||
type secretPatternEntry struct {
|
||||
re *regexp.Regexp
|
||||
label string
|
||||
}
|
||||
|
||||
// memorySecretPatterns are checked in order — most-specific first so that
|
||||
// env-var assignments (OPENAI_API_KEY=sk-...) are caught before the generic
|
||||
// sk-* or base64 patterns consume only part of the match.
|
||||
//
|
||||
// Covered by SAFE-T1201 (issue #838).
|
||||
var memorySecretPatterns = []secretPatternEntry{
|
||||
// Env-var assignments: ANTHROPIC_API_KEY=sk-ant-... GITHUB_TOKEN=ghp_...
|
||||
{regexp.MustCompile(`(?i)\b[A-Z][A-Z0-9_]*_API_KEY\s*=\s*\S+`), "API_KEY"},
|
||||
{regexp.MustCompile(`(?i)\b[A-Z][A-Z0-9_]*_TOKEN\s*=\s*\S+`), "TOKEN"},
|
||||
{regexp.MustCompile(`(?i)\b[A-Z][A-Z0-9_]*_SECRET\s*=\s*\S+`), "SECRET"},
|
||||
// HTTP Bearer header values
|
||||
{regexp.MustCompile(`Bearer\s+\S+`), "BEARER_TOKEN"},
|
||||
// OpenAI / Anthropic sk-... key format
|
||||
{regexp.MustCompile(`sk-[A-Za-z0-9\-_]{16,}`), "SK_TOKEN"},
|
||||
// context7 tokens
|
||||
{regexp.MustCompile(`ctx7_[A-Za-z0-9]+`), "CTX7_TOKEN"},
|
||||
// High-entropy base64 blobs — must contain a base64-only char (+/=) OR
|
||||
// be longer than 40 chars to avoid false-positives on plain long words.
|
||||
{regexp.MustCompile(`[A-Za-z0-9+/]{33,}={0,2}`), "BASE64_BLOB"},
|
||||
}
|
||||
|
||||
// redactSecrets scrubs known secret patterns from content before persistence.
|
||||
// Each distinct pattern class that fires logs a warning (without the value).
|
||||
// Returns the sanitised string and a bool indicating whether anything changed.
|
||||
// Failure is impossible — returns original content unchanged on any panic.
|
||||
func redactSecrets(workspaceID, content string) (out string, changed bool) {
|
||||
out = content
|
||||
for _, p := range memorySecretPatterns {
|
||||
replaced := p.re.ReplaceAllString(out, "[REDACTED:"+p.label+"]")
|
||||
if replaced != out {
|
||||
log.Printf("commit_memory: redacted %s pattern for workspace %s (SAFE-T1201)", p.label, workspaceID)
|
||||
out = replaced
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return out, changed
|
||||
}
|
||||
|
||||
// EmbeddingFunc generates a 1536-dimensional dense-vector embedding for the
|
||||
// given text. Must return exactly 1536 float32 values on success.
|
||||
// Implementations must honour ctx cancellation.
|
||||
@ -128,11 +173,17 @@ func (h *MemoriesHandler) Commit(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// SAFE-T1201: scrub secret patterns before persistence so that a confused
|
||||
// or prompt-injected agent cannot exfiltrate credentials into shared TEAM/
|
||||
// GLOBAL memory. Runs on every write, regardless of scope.
|
||||
content := body.Content
|
||||
content, _ = redactSecrets(workspaceID, content)
|
||||
|
||||
var memoryID string
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO agent_memories (workspace_id, content, scope, namespace)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id
|
||||
`, workspaceID, body.Content, body.Scope, namespace).Scan(&memoryID)
|
||||
`, workspaceID, content, body.Scope, namespace).Scan(&memoryID)
|
||||
if err != nil {
|
||||
log.Printf("Commit memory error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store memory"})
|
||||
@ -144,7 +195,9 @@ func (h *MemoriesHandler) Commit(c *gin.Context) {
|
||||
// trail can prove what was written without leaking sensitive values.
|
||||
// Failure is non-fatal: a logging error must not roll back a successful write.
|
||||
if body.Scope == "GLOBAL" {
|
||||
sum := sha256.Sum256([]byte(body.Content))
|
||||
// Hash the sanitised content so the audit trail reflects what was
|
||||
// actually persisted (not the raw, potentially secret-bearing input).
|
||||
sum := sha256.Sum256([]byte(content))
|
||||
auditBody, _ := json.Marshal(map[string]string{
|
||||
"memory_id": memoryID,
|
||||
"namespace": namespace,
|
||||
@ -163,7 +216,7 @@ func (h *MemoriesHandler) Commit(c *gin.Context) {
|
||||
// already stored above; a failed embedding just means this record will
|
||||
// be excluded from future cosine-similarity searches.
|
||||
if h.embed != nil {
|
||||
if vec, embedErr := h.embed(ctx, body.Content); embedErr != nil {
|
||||
if vec, embedErr := h.embed(ctx, content); embedErr != nil {
|
||||
log.Printf("Commit: embedding failed workspace=%s memory=%s: %v (stored without embedding)",
|
||||
workspaceID, memoryID, embedErr)
|
||||
} else if fmtVec := formatVector(vec); fmtVec != "" {
|
||||
|
||||
@ -827,6 +827,146 @@ func TestRecallMemory_GlobalScope_HasDelimiter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- SAFE-T1201: secret redaction (issue #838) ----------
|
||||
|
||||
// TestRedactSecrets_CleanContent_PassesThrough verifies that content with no
|
||||
// secret patterns is returned unchanged and changed==false.
|
||||
func TestRedactSecrets_CleanContent_PassesThrough(t *testing.T) {
|
||||
inputs := []string{
|
||||
"The answer is 42",
|
||||
"dogs are mammals",
|
||||
"remember to open the PR before EOD",
|
||||
"short",
|
||||
"",
|
||||
}
|
||||
for _, in := range inputs {
|
||||
out, changed := redactSecrets("ws-1", in)
|
||||
if changed {
|
||||
t.Errorf("clean content %q was unexpectedly changed to %q", in, out)
|
||||
}
|
||||
if out != in {
|
||||
t.Errorf("clean content %q was mutated to %q", in, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactSecrets_APIKeyPattern_IsRedacted verifies that env-var API key
|
||||
// assignments are scrubbed before persistence.
|
||||
func TestRedactSecrets_APIKeyPattern_IsRedacted(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
label string
|
||||
}{
|
||||
{"OPENAI_API_KEY=sk-1234567890abcdefgh", "API_KEY"},
|
||||
{"ANTHROPIC_API_KEY=sk-ant-api03-longkeyvalue", "API_KEY"},
|
||||
{"MY_SERVICE_TOKEN=ghp_ABCDEFGH1234567890", "TOKEN"},
|
||||
{"DATABASE_SECRET=supersecret", "SECRET"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
out, changed := redactSecrets("ws-1", tc.input)
|
||||
if !changed {
|
||||
t.Errorf("expected redaction of %q, got unchanged", tc.input)
|
||||
}
|
||||
want := "[REDACTED:" + tc.label + "]"
|
||||
if out != want {
|
||||
t.Errorf("input %q: got %q, want %q", tc.input, out, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactSecrets_BearerToken_IsRedacted verifies HTTP Bearer header values
|
||||
// are scrubbed.
|
||||
func TestRedactSecrets_BearerToken_IsRedacted(t *testing.T) {
|
||||
input := "Authorization: Bearer ghp_AbCdEfGhIjKlMnOp1234"
|
||||
out, changed := redactSecrets("ws-1", input)
|
||||
if !changed {
|
||||
t.Errorf("Bearer token was not redacted in %q", input)
|
||||
}
|
||||
if strings.Contains(out, "ghp_") {
|
||||
t.Errorf("Bearer token value still present after redaction: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "[REDACTED:BEARER_TOKEN]") {
|
||||
t.Errorf("expected [REDACTED:BEARER_TOKEN] in output, got: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactSecrets_SKToken_IsRedacted verifies sk-... prefixed secret keys
|
||||
// (OpenAI / Anthropic format) are scrubbed.
|
||||
func TestRedactSecrets_SKToken_IsRedacted(t *testing.T) {
|
||||
// Use a key that is NOT caught by the env-var pattern first (no KEY= prefix)
|
||||
input := "the key is sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAA"
|
||||
out, changed := redactSecrets("ws-1", input)
|
||||
if !changed {
|
||||
t.Errorf("sk- token was not redacted in %q", input)
|
||||
}
|
||||
if strings.Contains(out, "sk-ant") {
|
||||
t.Errorf("sk- value still present after redaction: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactSecrets_Ctx7Token_IsRedacted verifies context7 tokens are scrubbed.
|
||||
func TestRedactSecrets_Ctx7Token_IsRedacted(t *testing.T) {
|
||||
input := "ctx7_AbCdEfGhIjKlMnOpQrStUvWxYz123456"
|
||||
out, changed := redactSecrets("ws-1", input)
|
||||
if !changed {
|
||||
t.Errorf("ctx7_ token was not redacted in %q", input)
|
||||
}
|
||||
if strings.Contains(out, "ctx7_") {
|
||||
t.Errorf("ctx7_ value still present after redaction: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "[REDACTED:CTX7_TOKEN]") {
|
||||
t.Errorf("expected [REDACTED:CTX7_TOKEN] in output, got: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactSecrets_Base64Blob_IsRedacted verifies that high-entropy base64
|
||||
// blobs of 33+ chars are scrubbed.
|
||||
func TestRedactSecrets_Base64Blob_IsRedacted(t *testing.T) {
|
||||
// A realistic base64-encoded secret (33+ chars, contains + and /)
|
||||
input := "stored secret: dGhpcyBpcyBhIHNlY3JldCBibG9i/AAAA=="
|
||||
out, changed := redactSecrets("ws-1", input)
|
||||
if !changed {
|
||||
t.Errorf("base64 blob was not redacted in %q", input)
|
||||
}
|
||||
if !strings.Contains(out, "[REDACTED:BASE64_BLOB]") {
|
||||
t.Errorf("expected [REDACTED:BASE64_BLOB] in output, got: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommitMemory_SecretInContent_IsRedactedBeforeInsert verifies that the
|
||||
// Commit handler scrubs secret patterns before the INSERT so credentials are
|
||||
// never persisted verbatim. The DB mock expects the redacted value.
|
||||
func TestCommitMemory_SecretInContent_IsRedactedBeforeInsert(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewMemoriesHandler()
|
||||
|
||||
// The raw content contains an API key assignment. After redaction the DB
|
||||
// must receive the scrubbed version, not the original.
|
||||
rawContent := "OPENAI_API_KEY=sk-1234567890abcdefgh"
|
||||
redacted, _ := redactSecrets("ws-1", rawContent) // derive expected value
|
||||
|
||||
mock.ExpectQuery("INSERT INTO agent_memories").
|
||||
WithArgs("ws-1", redacted, "LOCAL", "general").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("mem-safe"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
||||
body := `{"content":"OPENAI_API_KEY=sk-1234567890abcdefgh","scope":"LOCAL"}`
|
||||
c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Commit(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("secret content was not redacted before DB insert: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommitMemory_GlobalScope_AuditLogEntry verifies that writing a
|
||||
// GLOBAL-scope memory always produces an activity_log entry with
|
||||
// event_type='memory_write_global'. The audit entry stores the SHA-256
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@ -33,6 +34,10 @@ type WorkspaceHandler struct {
|
||||
// registered; Registry.Run handles a nil receiver as a no-op so the
|
||||
// hot path stays a single nil-pointer compare.
|
||||
envMutators *provisionhook.Registry
|
||||
// stopFnOverride is set exclusively in tests to intercept provisioner.Stop
|
||||
// calls made by HibernateWorkspace without requiring a running Docker daemon.
|
||||
// Always nil in production; the real provisioner path is used when nil.
|
||||
stopFnOverride func(ctx context.Context, workspaceID string)
|
||||
}
|
||||
|
||||
func NewWorkspaceHandler(b *events.Broadcaster, p *provisioner.Provisioner, platformURL, configsDir string) *WorkspaceHandler {
|
||||
|
||||
@ -211,27 +211,68 @@ func (h *WorkspaceHandler) Hibernate(c *gin.Context) {
|
||||
// 'hibernated'. Called by the hibernation monitor when a workspace has had
|
||||
// active_tasks == 0 for longer than its configured hibernation_idle_minutes.
|
||||
// Hibernated workspaces auto-wake on the next incoming A2A message.
|
||||
//
|
||||
// TOCTOU safety (#819): the three-step pattern below is atomic at the DB level.
|
||||
//
|
||||
// 1. Atomic claim: a single UPDATE WHERE locks the row by transitioning
|
||||
// status → 'hibernating', gated on status IN ('online','degraded') AND
|
||||
// active_tasks = 0. If any concurrent caller (another goroutine, the
|
||||
// idle-timer, or a manual API call) already claimed the row, or if tasks
|
||||
// arrived since the caller decided to hibernate, rowsAffected == 0 and
|
||||
// this function returns immediately without stopping anything.
|
||||
//
|
||||
// 2. provisioner.Stop: safe to call now because status == 'hibernating';
|
||||
// the routing layer rejects new tasks for non-online/degraded workspaces,
|
||||
// so no new task can be dispatched between step 1 and step 2.
|
||||
//
|
||||
// 3. Final UPDATE to 'hibernated': records the completed hibernation.
|
||||
func (h *WorkspaceHandler) HibernateWorkspace(ctx context.Context, workspaceID string) {
|
||||
var wsName string
|
||||
var tier int
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT name, tier FROM workspaces WHERE id = $1 AND status IN ('online', 'degraded')`, workspaceID,
|
||||
).Scan(&wsName, &tier)
|
||||
// ── Step 1: Atomic claim ──────────────────────────────────────────────────
|
||||
// The UPDATE acts as a DB-level advisory lock: only one concurrent caller
|
||||
// can transition the row from online/degraded → hibernating. The
|
||||
// active_tasks = 0 predicate ensures we never interrupt a running task.
|
||||
result, err := db.DB.ExecContext(ctx, `
|
||||
UPDATE workspaces
|
||||
SET status = 'hibernating', updated_at = now()
|
||||
WHERE id = $1
|
||||
AND status IN ('online', 'degraded')
|
||||
AND active_tasks = 0`, workspaceID)
|
||||
if err != nil {
|
||||
// Already changed state (paused, removed, etc.) — nothing to do.
|
||||
log.Printf("Hibernate: atomic claim failed for %s: %v", workspaceID, err)
|
||||
return
|
||||
}
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
if rowsAffected == 0 {
|
||||
// Either already hibernating/hibernated/paused/removed, or active_tasks > 0 —
|
||||
// safe to abort without side-effects.
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch name/tier for logging and event broadcast (after the claim, so we
|
||||
// can use a simple SELECT without a status guard).
|
||||
var wsName string
|
||||
var tier int
|
||||
if scanErr := db.DB.QueryRowContext(ctx,
|
||||
`SELECT name, tier FROM workspaces WHERE id = $1`, workspaceID,
|
||||
).Scan(&wsName, &tier); scanErr != nil {
|
||||
wsName = workspaceID // fallback for log messages
|
||||
}
|
||||
|
||||
// ── Step 2: Stop the container ────────────────────────────────────────────
|
||||
// Status is now 'hibernating'; the router rejects new task routing here, so
|
||||
// there is no race window between claiming the row and stopping the container.
|
||||
log.Printf("Hibernate: stopping container for %s (%s)", wsName, workspaceID)
|
||||
if h.provisioner != nil {
|
||||
if h.stopFnOverride != nil {
|
||||
h.stopFnOverride(ctx, workspaceID)
|
||||
} else if h.provisioner != nil {
|
||||
h.provisioner.Stop(ctx, workspaceID)
|
||||
}
|
||||
|
||||
_, err = db.DB.ExecContext(ctx,
|
||||
`UPDATE workspaces SET status = 'hibernated', url = '', updated_at = now() WHERE id = $1 AND status IN ('online', 'degraded')`,
|
||||
workspaceID)
|
||||
if err != nil {
|
||||
log.Printf("Hibernate: failed to update status for %s: %v", workspaceID, err)
|
||||
// ── Step 3: Mark fully hibernated ─────────────────────────────────────────
|
||||
if _, err = db.DB.ExecContext(ctx,
|
||||
`UPDATE workspaces SET status = 'hibernated', url = '', updated_at = now() WHERE id = $1`,
|
||||
workspaceID); err != nil {
|
||||
log.Printf("Hibernate: failed to mark hibernated for %s: %v", workspaceID, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
sqlmock "github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@ -334,3 +337,195 @@ func TestResumeHandler_NilProvisionerReturns503(t *testing.T) {
|
||||
// Note: TestResumeHandler_ParentPausedBlocksResume requires a non-nil provisioner
|
||||
// (Resume checks provisioner before isParentPaused). This is covered in
|
||||
// handlers_additional_test.go's integration-style tests.
|
||||
|
||||
// ==================== HibernateWorkspace — TOCTOU fix (#819) ====================
|
||||
|
||||
// TestHibernateWorkspace_ActiveTasksNotHibernated verifies that a workspace
|
||||
// with active_tasks > 0 is NOT hibernated: the atomic UPDATE WHERE active_tasks=0
|
||||
// returns 0 rows, and the function returns without calling Stop or the final
|
||||
// status update.
|
||||
func TestHibernateWorkspace_ActiveTasksNotHibernated(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
var stopCalls int32
|
||||
handler.stopFnOverride = func(_ context.Context, _ string) {
|
||||
atomic.AddInt32(&stopCalls, 1)
|
||||
}
|
||||
|
||||
// The atomic claim UPDATE returns 0 rows because active_tasks > 0 fails the WHERE.
|
||||
mock.ExpectExec(`UPDATE workspaces`).
|
||||
WithArgs("ws-active").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0)) // rowsAffected = 0
|
||||
|
||||
handler.HibernateWorkspace(context.Background(), "ws-active")
|
||||
|
||||
if got := atomic.LoadInt32(&stopCalls); got != 0 {
|
||||
t.Errorf("provisioner.Stop called %d times; want 0 (active_tasks > 0 must prevent hibernation)", got)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHibernateWorkspace_AlreadyHibernatingNotHibernated verifies that a
|
||||
// workspace already in status 'hibernating' (claimed by a concurrent caller)
|
||||
// is skipped: the atomic UPDATE returns 0 rows because status no longer
|
||||
// matches IN ('online','degraded').
|
||||
func TestHibernateWorkspace_AlreadyHibernatingNotHibernated(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
var stopCalls int32
|
||||
handler.stopFnOverride = func(_ context.Context, _ string) {
|
||||
atomic.AddInt32(&stopCalls, 1)
|
||||
}
|
||||
|
||||
// Another goroutine already transitioned the workspace to 'hibernating',
|
||||
// so this UPDATE finds nothing matching the WHERE clause.
|
||||
mock.ExpectExec(`UPDATE workspaces`).
|
||||
WithArgs("ws-already").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
|
||||
handler.HibernateWorkspace(context.Background(), "ws-already")
|
||||
|
||||
if got := atomic.LoadInt32(&stopCalls); got != 0 {
|
||||
t.Errorf("provisioner.Stop called %d times; want 0 (concurrent claim should abort this call)", got)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHibernateWorkspace_SuccessPath verifies the happy path: atomic claim
|
||||
// succeeds (rowsAffected=1), Stop is called exactly once, and the final
|
||||
// 'hibernated' UPDATE is executed.
|
||||
func TestHibernateWorkspace_SuccessPath(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
var stopCalls int32
|
||||
handler.stopFnOverride = func(_ context.Context, _ string) {
|
||||
atomic.AddInt32(&stopCalls, 1)
|
||||
}
|
||||
|
||||
// Step 1: atomic claim succeeds
|
||||
mock.ExpectExec(`UPDATE workspaces`).
|
||||
WithArgs("ws-ok").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1)) // rowsAffected = 1
|
||||
|
||||
// Name/tier fetch after claim
|
||||
mock.ExpectQuery(`SELECT name, tier FROM workspaces WHERE id`).
|
||||
WithArgs("ws-ok").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "tier"}).AddRow("My Agent", 1))
|
||||
|
||||
// Step 3: final hibernated UPDATE
|
||||
mock.ExpectExec(`UPDATE workspaces SET status = 'hibernated'`).
|
||||
WithArgs("ws-ok").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// broadcaster INSERT
|
||||
mock.ExpectExec(`INSERT INTO structure_events`).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
handler.HibernateWorkspace(context.Background(), "ws-ok")
|
||||
|
||||
if got := atomic.LoadInt32(&stopCalls); got != 1 {
|
||||
t.Errorf("provisioner.Stop called %d times; want exactly 1", got)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHibernateWorkspace_ConcurrentOnlyOneStop verifies the core TOCTOU guarantee:
|
||||
// when two callers race to hibernate the same workspace, the DB atomicity ensures
|
||||
// only one proceeds (rowsAffected=1) and only one Stop() is issued.
|
||||
//
|
||||
// The real Postgres guarantee (only one UPDATE wins) is modelled here by running
|
||||
// both calls sequentially against the same mock, with FIFO expectations:
|
||||
// - First call wins → rowsAffected=1 → Stop is called
|
||||
// - Second call loses → rowsAffected=0 → Stop is NOT called
|
||||
//
|
||||
// This directly verifies the invariant "at most one Stop per workspace across
|
||||
// any number of concurrent hibernate attempts."
|
||||
func TestHibernateWorkspace_ConcurrentOnlyOneStop(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
var stopCalls int32
|
||||
handler.stopFnOverride = func(_ context.Context, _ string) {
|
||||
atomic.AddInt32(&stopCalls, 1)
|
||||
}
|
||||
|
||||
// ── Caller A wins the race ────────────────────────────────────────────────
|
||||
mock.ExpectExec(`UPDATE workspaces`).
|
||||
WithArgs("ws-race").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery(`SELECT name, tier FROM workspaces WHERE id`).
|
||||
WithArgs("ws-race").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "tier"}).AddRow("Race Agent", 2))
|
||||
mock.ExpectExec(`UPDATE workspaces SET status = 'hibernated'`).
|
||||
WithArgs("ws-race").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`INSERT INTO structure_events`).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// ── Caller B loses — workspace is already 'hibernating' ───────────────────
|
||||
mock.ExpectExec(`UPDATE workspaces`).
|
||||
WithArgs("ws-race").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
|
||||
// Execute sequentially (sqlmock is not safe for concurrent goroutines);
|
||||
// the test models the serialized DB outcome that Postgres enforces.
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); handler.HibernateWorkspace(context.Background(), "ws-race") }()
|
||||
wg.Wait()
|
||||
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); handler.HibernateWorkspace(context.Background(), "ws-race") }()
|
||||
wg.Wait()
|
||||
|
||||
if got := atomic.LoadInt32(&stopCalls); got != 1 {
|
||||
t.Errorf("provisioner.Stop called %d times; want exactly 1 across two hibernate attempts", got)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHibernateWorkspace_DBErrorOnClaim verifies that a DB error on the
|
||||
// atomic claim UPDATE aborts the hibernation without calling Stop.
|
||||
func TestHibernateWorkspace_DBErrorOnClaim(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
var stopCalls int32
|
||||
handler.stopFnOverride = func(_ context.Context, _ string) {
|
||||
atomic.AddInt32(&stopCalls, 1)
|
||||
}
|
||||
|
||||
mock.ExpectExec(`UPDATE workspaces`).
|
||||
WithArgs("ws-dberr").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
handler.HibernateWorkspace(context.Background(), "ws-dberr")
|
||||
|
||||
if got := atomic.LoadInt32(&stopCalls); got != 0 {
|
||||
t.Errorf("provisioner.Stop called %d times on DB error; want 0", got)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
32
workspace-template/adapters/smolagents/__init__.py
Normal file
32
workspace-template/adapters/smolagents/__init__.py
Normal file
@ -0,0 +1,32 @@
|
||||
"""Smolagents adapter for Molecule AI workspace runtime.
|
||||
|
||||
Provides env sanitization and safe executor/messaging primitives for use
|
||||
with HuggingFace's smolagents library.
|
||||
|
||||
Two env-sanitization strategies are available:
|
||||
|
||||
* **Allowlist** (recommended) — :mod:`adapters.smolagents.env_sanitize`:
|
||||
only explicitly-safe variables pass through. Stricter but requires keeping
|
||||
the allowlist up-to-date as new safe vars are needed.
|
||||
|
||||
* **Denylist** (simple) — :mod:`adapters.smolagents.safe_env`:
|
||||
well-known secret names plus ``*_API_KEY`` / ``*_TOKEN`` suffix patterns
|
||||
are stripped. Easier to start with; less exhaustive.
|
||||
|
||||
Quick start::
|
||||
|
||||
# Allowlist approach (stricter)
|
||||
from adapters.smolagents.env_sanitize import make_safe_env, SafeLocalPythonExecutor
|
||||
|
||||
# Denylist approach (simpler)
|
||||
from adapters.smolagents.safe_env import make_safe_env
|
||||
|
||||
# Safe messaging
|
||||
from adapters.smolagents.send_message_wrapper import safe_send_message
|
||||
"""
|
||||
|
||||
# Re-export the allowlist-based make_safe_env as the default (most secure).
|
||||
from adapters.smolagents.env_sanitize import SafeLocalPythonExecutor, make_safe_env
|
||||
from adapters.smolagents.send_message_wrapper import safe_send_message
|
||||
|
||||
__all__ = ["make_safe_env", "SafeLocalPythonExecutor", "safe_send_message"]
|
||||
226
workspace-template/adapters/smolagents/env_sanitize.py
Normal file
226
workspace-template/adapters/smolagents/env_sanitize.py
Normal file
@ -0,0 +1,226 @@
|
||||
"""Allowlist-based environment sanitization for smolagents (#826 — C3 CRITICAL).
|
||||
|
||||
Security model
|
||||
--------------
|
||||
We use an **allowlist** (not a denylist) — only variables explicitly
|
||||
enumerated as safe are passed through to agent-executed code. Any key not
|
||||
on the list is silently dropped.
|
||||
|
||||
This is intentionally strict: adding a new safe variable is a deliberate
|
||||
engineering act that surfaces in code review, rather than hoping a regex
|
||||
denylist catches every new secret name.
|
||||
|
||||
Thread safety
|
||||
-------------
|
||||
``SafeLocalPythonExecutor.__call__`` mutates ``os.environ`` temporarily.
|
||||
``_ENV_PATCH_LOCK`` serialises concurrent calls so simultaneous executions
|
||||
do not see each other's env patches.
|
||||
|
||||
Extending the allowlist
|
||||
-----------------------
|
||||
Set ``SMOLAGENTS_ENV_EXTRA_ALLOWLIST`` to a comma-separated list of
|
||||
additional uppercase env var names that should be passed through. This is
|
||||
intended for workspace-specific non-secret variables (e.g. ``WORKSPACE_ID``
|
||||
that you know are safe):
|
||||
|
||||
SMOLAGENTS_ENV_EXTRA_ALLOWLIST="MY_COMPANY_ENV,REGION"
|
||||
|
||||
Never add secret names here — use workspace secrets injection instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Allowlist configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Core safe env variables — non-secret system and runtime variables that
|
||||
# agent code may legitimately need (e.g. PATH for subprocess-free tools,
|
||||
# PYTHONPATH for module resolution, TZ for datetime ops).
|
||||
_SAFE_ENV_ALLOWLIST: frozenset = frozenset(
|
||||
[
|
||||
# Shell / system fundamentals
|
||||
"PATH",
|
||||
"HOME",
|
||||
"USER",
|
||||
"LOGNAME",
|
||||
"SHELL",
|
||||
"TERM",
|
||||
"TZ",
|
||||
"TMPDIR",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
# Language / locale
|
||||
"LANG",
|
||||
"LANGUAGE",
|
||||
"LC_ALL",
|
||||
"LC_CTYPE",
|
||||
"LC_MESSAGES",
|
||||
"LC_NUMERIC",
|
||||
"LC_TIME",
|
||||
# Python runtime
|
||||
"PYTHONPATH",
|
||||
"PYTHONHOME",
|
||||
"PYTHONDONTWRITEBYTECODE",
|
||||
"PYTHONUNBUFFERED",
|
||||
"PYTHONIOENCODING",
|
||||
# Molecule workspace non-secret identity vars
|
||||
"WORKSPACE_ID",
|
||||
"WORKSPACE_NAME",
|
||||
"PLATFORM_URL",
|
||||
]
|
||||
)
|
||||
|
||||
# Imports permanently excluded from the executor's authorized list.
|
||||
# These are well-known sandbox-escape vectors.
|
||||
_BANNED_IMPORTS: frozenset = frozenset(
|
||||
["subprocess", "socket", "ctypes", "importlib", "importlib.util"]
|
||||
)
|
||||
|
||||
# Baseline imports every SafeLocalPythonExecutor allows — pure-computation
|
||||
# modules with no I/O escape surface.
|
||||
_BASELINE_SAFE_IMPORTS: List[str] = [
|
||||
"math",
|
||||
"json",
|
||||
"re",
|
||||
"datetime",
|
||||
"collections",
|
||||
"itertools",
|
||||
"functools",
|
||||
"typing",
|
||||
"string",
|
||||
"textwrap",
|
||||
"decimal",
|
||||
"fractions",
|
||||
"statistics",
|
||||
"random",
|
||||
"hashlib",
|
||||
"base64",
|
||||
"urllib.parse",
|
||||
"copy",
|
||||
"dataclasses",
|
||||
"enum",
|
||||
"abc",
|
||||
"io",
|
||||
]
|
||||
|
||||
# Thread lock for env patching
|
||||
_ENV_PATCH_LOCK = threading.Lock()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_safe_env(
|
||||
extra_allowed: Optional[List[str]] = None,
|
||||
) -> Dict[str, str]:
|
||||
"""Return a *copy* of the environment containing only allowlisted keys.
|
||||
|
||||
``os.environ`` is **never mutated** by this function.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
extra_allowed:
|
||||
Additional variable names to include beyond the built-in allowlist.
|
||||
Also merged with the ``SMOLAGENTS_ENV_EXTRA_ALLOWLIST`` env var.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
A copy of ``os.environ`` filtered to allowlisted keys only.
|
||||
Keys not on the list are silently dropped.
|
||||
"""
|
||||
allowed = set(_SAFE_ENV_ALLOWLIST)
|
||||
|
||||
# Merge caller-provided extras
|
||||
if extra_allowed:
|
||||
allowed.update(k.upper() for k in extra_allowed)
|
||||
|
||||
# Merge env-var-configured extras
|
||||
env_extra = os.environ.get("SMOLAGENTS_ENV_EXTRA_ALLOWLIST", "")
|
||||
if env_extra:
|
||||
for key in env_extra.split(","):
|
||||
key = key.strip().upper()
|
||||
if key:
|
||||
allowed.add(key)
|
||||
|
||||
return {k: v for k, v in os.environ.items() if k in allowed}
|
||||
|
||||
|
||||
class SafeLocalPythonExecutor:
|
||||
"""Allowlist-gated wrapper around smolagents ``LocalPythonExecutor``.
|
||||
|
||||
Guarantees that agent-generated code cannot read secret environment
|
||||
variables (``ANTHROPIC_API_KEY``, ``GH_TOKEN``, ``DATABASE_URL``, etc.)
|
||||
because they are absent from ``os.environ`` during execution.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
additional_imports:
|
||||
Extra module names to allow beyond ``_BASELINE_SAFE_IMPORTS``.
|
||||
``_BANNED_IMPORTS`` takes precedence — listed names are silently
|
||||
removed.
|
||||
extra_allowed_env:
|
||||
Extra variable names to pass through beyond the core allowlist.
|
||||
_inner:
|
||||
Inject a mock ``LocalPythonExecutor`` for tests. When ``None``,
|
||||
the real smolagents executor is constructed lazily.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
additional_imports: Optional[List[str]] = None,
|
||||
extra_allowed_env: Optional[List[str]] = None,
|
||||
*,
|
||||
_inner: Any = None,
|
||||
) -> None:
|
||||
# Compute final import list (baseline + extras − banned)
|
||||
combined = list(_BASELINE_SAFE_IMPORTS)
|
||||
if additional_imports:
|
||||
for imp in additional_imports:
|
||||
if imp not in _BANNED_IMPORTS:
|
||||
combined.append(imp)
|
||||
|
||||
self._authorized_imports: List[str] = combined
|
||||
self._extra_allowed_env: Optional[List[str]] = extra_allowed_env
|
||||
self._inner = _inner # may be None until first call
|
||||
|
||||
def _get_inner(self) -> Any:
|
||||
"""Lazy-construct the real executor on first use (avoids import errors in tests)."""
|
||||
if self._inner is None:
|
||||
from smolagents import LocalPythonExecutor # type: ignore[import]
|
||||
|
||||
self._inner = LocalPythonExecutor(
|
||||
additional_authorized_imports=self._authorized_imports
|
||||
)
|
||||
return self._inner
|
||||
|
||||
def __call__(self, code: str, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Execute ``code`` with only allowlisted env vars visible.
|
||||
|
||||
All keys not on the allowlist are removed from ``os.environ`` for
|
||||
the duration of execution and restored afterward, even on exception.
|
||||
The lock ensures thread safety across concurrent calls.
|
||||
"""
|
||||
safe_env = make_safe_env(self._extra_allowed_env)
|
||||
inner = self._get_inner()
|
||||
|
||||
with _ENV_PATCH_LOCK:
|
||||
# Snapshot full current env
|
||||
original_env = dict(os.environ)
|
||||
# Remove everything not in the safe set
|
||||
keys_to_remove = [k for k in os.environ if k not in safe_env]
|
||||
for k in keys_to_remove:
|
||||
del os.environ[k]
|
||||
try:
|
||||
return inner(code, *args, **kwargs)
|
||||
finally:
|
||||
# Always restore
|
||||
os.environ.clear()
|
||||
os.environ.update(original_env)
|
||||
61
workspace-template/adapters/smolagents/safe_env.py
Normal file
61
workspace-template/adapters/smolagents/safe_env.py
Normal file
@ -0,0 +1,61 @@
|
||||
"""Denylist-based environment sanitization for smolagents (issue #826 — C3 CRITICAL).
|
||||
|
||||
This module provides a simple denylist approach: well-known secret variable
|
||||
names plus ``*_API_KEY`` and ``*_TOKEN`` suffix patterns are stripped before
|
||||
env is passed to agent-executed code.
|
||||
|
||||
For a stricter allowlist-based alternative that only passes explicitly-safe
|
||||
variables through, see :mod:`adapters.smolagents.env_sanitize`.
|
||||
|
||||
Usage::
|
||||
|
||||
from adapters.smolagents.safe_env import make_safe_env
|
||||
|
||||
executor = LocalPythonExecutor(...)
|
||||
# Pass only the sanitised env to the subprocess / exec context:
|
||||
safe = make_safe_env()
|
||||
"""
|
||||
|
||||
import copy
|
||||
import os
|
||||
|
||||
# Named API keys and tokens known to be used by smolagents / LLM clients.
|
||||
# These are removed regardless of the suffix-pattern below.
|
||||
SMOLAGENTS_ENV_DENYLIST: frozenset = frozenset(
|
||||
{
|
||||
"OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"GROQ_API_KEY",
|
||||
"CEREBRAS_API_KEY",
|
||||
"QIANFAN_API_KEY",
|
||||
"LANGFUSE_SECRET_KEY",
|
||||
"LANGFUSE_PUBLIC_KEY",
|
||||
"HF_TOKEN",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def make_safe_env() -> dict:
|
||||
"""Return a sanitised copy of ``os.environ`` with secrets removed.
|
||||
|
||||
Removes any key that:
|
||||
- Is in :data:`SMOLAGENTS_ENV_DENYLIST`, OR
|
||||
- Ends with ``_API_KEY``, OR
|
||||
- Ends with ``_TOKEN``
|
||||
|
||||
``os.environ`` is **never mutated** — a fresh ``dict`` copy is returned.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
A copy of the current environment with secret keys removed.
|
||||
"""
|
||||
env = copy.copy(dict(os.environ))
|
||||
for key in list(env.keys()):
|
||||
if (
|
||||
key in SMOLAGENTS_ENV_DENYLIST
|
||||
or key.endswith("_API_KEY")
|
||||
or key.endswith("_TOKEN")
|
||||
):
|
||||
del env[key]
|
||||
return env
|
||||
@ -0,0 +1,71 @@
|
||||
"""Safe send_message wrapper for smolagents (issue #827 — C1 HIGH).
|
||||
|
||||
Prevents social-engineering attacks where agent-generated content could
|
||||
impersonate platform messages, inject HTML, or flood the user chat.
|
||||
|
||||
Guarantees
|
||||
----------
|
||||
1. Every message is prefixed with ``[smolagents]`` so recipients can
|
||||
attribute it to the agent and cannot be mistaken for platform UI.
|
||||
2. Truncated to 2000 characters to prevent log/UI floods.
|
||||
3. HTML entities (``<``, ``>``, ``&``, ``"``, ``'``) are escaped so
|
||||
rendered UIs that interpret HTML cannot be injected into.
|
||||
|
||||
Usage::
|
||||
|
||||
from adapters.smolagents.send_message_wrapper import safe_send_message
|
||||
|
||||
safe_send_message("Hello world", send_fn=platform_client.send)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Maximum character length for the *user-visible* portion of the message
|
||||
# (label prefix does not count toward this cap).
|
||||
_MAX_TEXT_LEN: int = 2000
|
||||
|
||||
# Label prepended to every outbound message.
|
||||
_LABEL: str = "[smolagents]"
|
||||
|
||||
|
||||
def safe_send_message(text: str, send_fn) -> None:
|
||||
"""Sanitise *text* and deliver it via *send_fn*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
text:
|
||||
The raw message text produced by the agent.
|
||||
send_fn:
|
||||
Callable that delivers the message (e.g. ``platform_client.send``
|
||||
or a WebSocket broadcast function). Called with the final,
|
||||
sanitised string as its sole positional argument.
|
||||
|
||||
Side effects
|
||||
------------
|
||||
- Logs a warning when truncation occurs.
|
||||
- Logs a debug entry with the final payload length.
|
||||
"""
|
||||
if not isinstance(text, str):
|
||||
text = str(text)
|
||||
|
||||
# Strip HTML entities to prevent injection into rendered UIs.
|
||||
sanitised = html.escape(text, quote=True)
|
||||
|
||||
# Truncate to cap (before adding label so cap applies to content).
|
||||
if len(sanitised) > _MAX_TEXT_LEN:
|
||||
logger.warning(
|
||||
"safe_send_message: truncating message from %d to %d chars",
|
||||
len(sanitised),
|
||||
_MAX_TEXT_LEN,
|
||||
)
|
||||
sanitised = sanitised[:_MAX_TEXT_LEN]
|
||||
|
||||
payload = f"{_LABEL} {sanitised}"
|
||||
|
||||
logger.debug("safe_send_message: delivering %d-char payload", len(payload))
|
||||
send_fn(payload)
|
||||
@ -1,87 +1,49 @@
|
||||
#!/bin/bash
|
||||
# No set -e — individual commands handle their own errors gracefully
|
||||
#!/bin/sh
|
||||
# Drop privileges to the agent user before exec'ing molecule-runtime.
|
||||
# claude-code refuses --dangerously-skip-permissions when running as
|
||||
# root/sudo for safety. Without this entrypoint, every cron tick fails
|
||||
# with `ProcessError: Command failed with exit code 1` and the agent
|
||||
# logs `--dangerously-skip-permissions cannot be used with root/sudo
|
||||
# privileges for security reasons`.
|
||||
#
|
||||
# Pattern matches the legacy monorepo workspace-template/entrypoint.sh:
|
||||
# fix volume ownership as root, then re-exec via gosu as agent (uid 1000).
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Volume ownership fix (runs as root)
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Docker creates volume contents as root. The agent process runs as UID 1000
|
||||
# and needs to write to /configs (CLAUDE.md, skills, plugins) and /workspace
|
||||
# (cloned repos, scratch files). Fix ownership once at startup so every
|
||||
# future file operation works without per-file chown hacks.
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
# Fix /configs recursively (plugins, CLAUDE.md, skills — small directory)
|
||||
# Configs volume is created by Docker as root; agent needs write access
|
||||
# for plugin installs, memory writes, .auth_token rotation, etc.
|
||||
chown -R agent:agent /configs 2>/dev/null
|
||||
# /workspace handling:
|
||||
# - Always fix the top-level dir so agent can create files in it.
|
||||
# - If the contents are root-owned (common on Docker Desktop / Windows
|
||||
# bind mounts where host uid maps to 0 inside the container), do a
|
||||
# full recursive chown — otherwise git clone, pip install, and file
|
||||
# writes under /workspace fail with EACCES (issue #13). On normal
|
||||
# Linux Docker with matching uids this branch is skipped, so we keep
|
||||
# the fast startup for the common case.
|
||||
chown agent:agent /workspace 2>/dev/null
|
||||
# Strip CRLF from hook scripts — Windows Docker Desktop copies host files
|
||||
# with CRLF line endings even when .gitattributes says eol=lf. The \r in
|
||||
# the shebang line makes python3 try to open 'script.py\r' → ENOENT →
|
||||
# claude-code swallows the hook error → "(no response generated)".
|
||||
# This is the permanent fix — runs at every container start.
|
||||
for f in /configs/.claude/hooks/*.sh /configs/.claude/hooks/*.py; do
|
||||
[ -f "$f" ] && sed -i 's/\r$//' "$f"
|
||||
done
|
||||
# /workspace handling — only chown when the contents are root-owned
|
||||
# (typical on Docker Desktop on Windows where host uid maps to 0).
|
||||
# On Linux Docker with matching uids the recursive chown is skipped
|
||||
# to keep startup fast.
|
||||
chown agent:agent /workspace 2>/dev/null || true
|
||||
if [ -d /workspace ]; then
|
||||
# Sample the first entry inside /workspace; if it's root-owned assume
|
||||
# the whole tree is a root-owned bind mount and recursively chown.
|
||||
first_entry=$(find /workspace -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)
|
||||
if [ -n "$first_entry" ] && [ "$(stat -c '%u' "$first_entry" 2>/dev/null)" = "0" ]; then
|
||||
echo "[entrypoint] /workspace contents are root-owned — chowning recursively to agent (uid 1000)"
|
||||
chown -R agent:agent /workspace 2>/dev/null
|
||||
fi
|
||||
fi
|
||||
# Re-exec this script as the agent user via gosu (clean PID 1 handoff)
|
||||
# Claude Code session directory — mounted at /root/.claude/sessions by
|
||||
# the platform provisioner. Symlink it into agent's home so the SDK
|
||||
# finds it when running as agent. The provisioner's mount point is
|
||||
# hardcoded to /root/.claude/sessions; we don't want to change the
|
||||
# platform contract just for this template.
|
||||
mkdir -p /home/agent/.claude
|
||||
if [ -d /root/.claude/sessions ]; then
|
||||
chown -R agent:agent /root/.claude /home/agent/.claude 2>/dev/null
|
||||
ln -sfn /root/.claude/sessions /home/agent/.claude/sessions
|
||||
fi
|
||||
exec gosu agent "$0" "$@"
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Everything below runs as the agent user (UID 1000)
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
# Ensure user-installed packages are in PATH
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
# Determine runtime from config.yaml
|
||||
RUNTIME=$(python3 -c "
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
cfg_path = Path('/configs/config.yaml')
|
||||
if cfg_path.exists():
|
||||
cfg = yaml.safe_load(cfg_path.read_text()) or {}
|
||||
print(cfg.get('runtime', 'langgraph'))
|
||||
else:
|
||||
print('langgraph')
|
||||
" 2>/dev/null || echo "langgraph")
|
||||
|
||||
echo "=== Molecule AI Workspace ==="
|
||||
echo "Runtime: $RUNTIME"
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# GitHub credential helper — issue #547
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# GitHub App installation tokens expire after ~60 min. The platform
|
||||
# exposes GET /admin/github-installation-token (backed by the plugin's
|
||||
# in-process refreshing cache) so workspaces can always get a valid
|
||||
# token without restarting.
|
||||
#
|
||||
# Register molecule-git-token-helper.sh as the git credential helper for
|
||||
# github.com. git calls it on every push/fetch; it hits the platform
|
||||
# endpoint and emits a fresh token. Falls through to any existing
|
||||
# credential helper (e.g. operator .env PAT) if the platform is
|
||||
# unreachable.
|
||||
#
|
||||
# Idempotent — safe to re-run on restart.
|
||||
HELPER_SCRIPT="/app/scripts/molecule-git-token-helper.sh"
|
||||
if [ -f "${HELPER_SCRIPT}" ]; then
|
||||
git config --global \
|
||||
"credential.https://github.com.helper" \
|
||||
"!${HELPER_SCRIPT}" 2>/dev/null || true
|
||||
echo "[entrypoint] git credential helper registered (molecule-git-token-helper)"
|
||||
else
|
||||
echo "[entrypoint] WARNING: molecule-git-token-helper.sh not found at ${HELPER_SCRIPT} — GitHub tokens may expire after 60 min"
|
||||
fi
|
||||
|
||||
# NOTE: Adapter-specific deps are now pre-installed in each adapter's Docker image
|
||||
# (standalone template repos). Each image installs molecule-ai-workspace-runtime
|
||||
# from PyPI plus the adapter-specific requirements. No per-runtime pip install needed here.
|
||||
|
||||
exec python3 main.py
|
||||
# Now running as agent (uid 1000)
|
||||
exec molecule-runtime "$@"
|
||||
|
||||
0
workspace-template/tests/adapters/__init__.py
Normal file
0
workspace-template/tests/adapters/__init__.py
Normal file
@ -0,0 +1,446 @@
|
||||
"""Tests for allowlist-based env sanitization (issue #826 — C3 CRITICAL).
|
||||
|
||||
All tests patch os.environ directly — the module under test must never
|
||||
mutate the real process env outside of SafeLocalPythonExecutor.__call__,
|
||||
and even there it must restore the original env on exit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Import directly from submodule to avoid any sys.modules stub side-effects
|
||||
from adapters.smolagents.env_sanitize import (
|
||||
SafeLocalPythonExecutor,
|
||||
_BANNED_IMPORTS,
|
||||
_BASELINE_SAFE_IMPORTS,
|
||||
_SAFE_ENV_ALLOWLIST,
|
||||
make_safe_env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _MockInner:
|
||||
"""Captures the code string passed to it; returns a configurable result."""
|
||||
|
||||
def __init__(self, return_value: Any = None):
|
||||
self.calls: list[str] = []
|
||||
self.return_value = return_value
|
||||
|
||||
def __call__(self, code: str, *args: Any, **kwargs: Any) -> Any:
|
||||
self.calls.append(code)
|
||||
return self.return_value
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# make_safe_env() — pure function tests (os.environ never mutated)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMakeSafeEnv:
|
||||
def test_strips_anthropic_api_key(self):
|
||||
with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-ant-secret"}, clear=False):
|
||||
result = make_safe_env()
|
||||
assert "ANTHROPIC_API_KEY" not in result
|
||||
|
||||
def test_strips_gh_token(self):
|
||||
with patch.dict(os.environ, {"GH_TOKEN": "ghp_secret"}, clear=False):
|
||||
result = make_safe_env()
|
||||
assert "GH_TOKEN" not in result
|
||||
|
||||
def test_strips_openai_api_key(self):
|
||||
with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-openai"}, clear=False):
|
||||
result = make_safe_env()
|
||||
assert "OPENAI_API_KEY" not in result
|
||||
|
||||
def test_strips_database_url(self):
|
||||
with patch.dict(os.environ, {"DATABASE_URL": "postgres://secret"}, clear=False):
|
||||
result = make_safe_env()
|
||||
assert "DATABASE_URL" not in result
|
||||
|
||||
def test_strips_redis_url(self):
|
||||
with patch.dict(os.environ, {"REDIS_URL": "redis://secret"}, clear=False):
|
||||
result = make_safe_env()
|
||||
assert "REDIS_URL" not in result
|
||||
|
||||
def test_strips_aws_access_key(self):
|
||||
with patch.dict(os.environ, {"AWS_ACCESS_KEY_ID": "AKIAIOSFODNN7EXAMPLE"}, clear=False):
|
||||
result = make_safe_env()
|
||||
assert "AWS_ACCESS_KEY_ID" not in result
|
||||
|
||||
def test_strips_slack_token(self):
|
||||
with patch.dict(os.environ, {"SLACK_BOT_TOKEN": "xoxb-secret"}, clear=False):
|
||||
result = make_safe_env()
|
||||
assert "SLACK_BOT_TOKEN" not in result
|
||||
|
||||
def test_strips_generic_password(self):
|
||||
with patch.dict(os.environ, {"DB_PASSWORD": "hunter2"}, clear=False):
|
||||
result = make_safe_env()
|
||||
assert "DB_PASSWORD" not in result
|
||||
|
||||
def test_strips_generic_secret(self):
|
||||
with patch.dict(os.environ, {"JWT_SECRET": "supersecret"}, clear=False):
|
||||
result = make_safe_env()
|
||||
assert "JWT_SECRET" not in result
|
||||
|
||||
def test_passes_path(self):
|
||||
with patch.dict(os.environ, {"PATH": "/usr/bin:/bin"}, clear=False):
|
||||
result = make_safe_env()
|
||||
assert result.get("PATH") == "/usr/bin:/bin"
|
||||
|
||||
def test_passes_home(self):
|
||||
with patch.dict(os.environ, {"HOME": "/root"}, clear=False):
|
||||
result = make_safe_env()
|
||||
assert result.get("HOME") == "/root"
|
||||
|
||||
def test_passes_lang(self):
|
||||
with patch.dict(os.environ, {"LANG": "en_US.UTF-8"}, clear=False):
|
||||
result = make_safe_env()
|
||||
assert result.get("LANG") == "en_US.UTF-8"
|
||||
|
||||
def test_passes_pythonpath(self):
|
||||
with patch.dict(os.environ, {"PYTHONPATH": "/app"}, clear=False):
|
||||
result = make_safe_env()
|
||||
assert result.get("PYTHONPATH") == "/app"
|
||||
|
||||
def test_passes_workspace_id(self):
|
||||
with patch.dict(os.environ, {"WORKSPACE_ID": "ws-123"}, clear=False):
|
||||
result = make_safe_env()
|
||||
assert result.get("WORKSPACE_ID") == "ws-123"
|
||||
|
||||
def test_passes_workspace_name(self):
|
||||
with patch.dict(os.environ, {"WORKSPACE_NAME": "my-agent"}, clear=False):
|
||||
result = make_safe_env()
|
||||
assert result.get("WORKSPACE_NAME") == "my-agent"
|
||||
|
||||
def test_passes_platform_url(self):
|
||||
with patch.dict(os.environ, {"PLATFORM_URL": "http://platform:8080"}, clear=False):
|
||||
result = make_safe_env()
|
||||
assert result.get("PLATFORM_URL") == "http://platform:8080"
|
||||
|
||||
def test_does_not_mutate_os_environ(self):
|
||||
"""make_safe_env() must be a pure read — os.environ unchanged after call."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"ANTHROPIC_API_KEY": "sk-ant-secret", "PATH": "/usr/bin"},
|
||||
clear=False,
|
||||
):
|
||||
before = dict(os.environ)
|
||||
make_safe_env()
|
||||
after = dict(os.environ)
|
||||
assert before == after
|
||||
|
||||
def test_returns_dict(self):
|
||||
result = make_safe_env()
|
||||
assert isinstance(result, dict)
|
||||
|
||||
def test_extra_allowed_via_parameter(self):
|
||||
with patch.dict(os.environ, {"MY_SAFE_VAR": "value"}, clear=False):
|
||||
result = make_safe_env(extra_allowed=["MY_SAFE_VAR"])
|
||||
assert result.get("MY_SAFE_VAR") == "value"
|
||||
|
||||
def test_extra_allowed_via_env_var(self):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"SMOLAGENTS_ENV_EXTRA_ALLOWLIST": "REGION,CLUSTER_NAME",
|
||||
"REGION": "us-east-1",
|
||||
"CLUSTER_NAME": "prod",
|
||||
"ANTHROPIC_API_KEY": "sk-ant-secret",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
result = make_safe_env()
|
||||
assert result.get("REGION") == "us-east-1"
|
||||
assert result.get("CLUSTER_NAME") == "prod"
|
||||
assert "ANTHROPIC_API_KEY" not in result
|
||||
|
||||
def test_extra_allowed_env_var_is_case_normalized(self):
|
||||
"""Names in SMOLAGENTS_ENV_EXTRA_ALLOWLIST are uppercased automatically."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"SMOLAGENTS_ENV_EXTRA_ALLOWLIST": "my_safe_var", "MY_SAFE_VAR": "hello"},
|
||||
clear=False,
|
||||
):
|
||||
result = make_safe_env()
|
||||
assert result.get("MY_SAFE_VAR") == "hello"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SafeLocalPythonExecutor — allowlist enforcement during execution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSafeLocalPythonExecutorAllowlist:
|
||||
"""Core security guarantee: secrets absent from os.environ during execution."""
|
||||
|
||||
def test_secret_absent_during_execution_anthropic(self):
|
||||
"""Injected ANTHROPIC_API_KEY must not be visible to executed code."""
|
||||
captured_env: dict = {}
|
||||
|
||||
def _mock_inner(code: str, *args, **kwargs):
|
||||
# Simulate what agent code would see via os.environ
|
||||
captured_env.update(os.environ.copy())
|
||||
return ""
|
||||
|
||||
executor = SafeLocalPythonExecutor(_inner=_mock_inner)
|
||||
|
||||
with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-ant-secret"}, clear=False):
|
||||
executor("import os; os.environ.get('ANTHROPIC_API_KEY', '')")
|
||||
|
||||
assert "ANTHROPIC_API_KEY" not in captured_env
|
||||
|
||||
def test_secret_absent_during_execution_gh_token(self):
|
||||
captured_env: dict = {}
|
||||
|
||||
def _mock_inner(code: str, *args, **kwargs):
|
||||
captured_env.update(os.environ.copy())
|
||||
return ""
|
||||
|
||||
executor = SafeLocalPythonExecutor(_inner=_mock_inner)
|
||||
|
||||
with patch.dict(os.environ, {"GH_TOKEN": "ghp_secret"}, clear=False):
|
||||
executor("import os; os.environ.get('GH_TOKEN', '')")
|
||||
|
||||
assert "GH_TOKEN" not in captured_env
|
||||
|
||||
def test_secret_absent_during_execution_database_url(self):
|
||||
captured_env: dict = {}
|
||||
|
||||
def _mock_inner(code: str, *args, **kwargs):
|
||||
captured_env.update(os.environ.copy())
|
||||
return ""
|
||||
|
||||
executor = SafeLocalPythonExecutor(_inner=_mock_inner)
|
||||
|
||||
with patch.dict(os.environ, {"DATABASE_URL": "postgres://secret"}, clear=False):
|
||||
executor("code")
|
||||
|
||||
assert "DATABASE_URL" not in captured_env
|
||||
|
||||
def test_secret_absent_during_execution_openai_key(self):
|
||||
captured_env: dict = {}
|
||||
|
||||
def _mock_inner(code: str, *args, **kwargs):
|
||||
captured_env.update(os.environ.copy())
|
||||
|
||||
executor = SafeLocalPythonExecutor(_inner=_mock_inner)
|
||||
|
||||
with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-openai"}, clear=False):
|
||||
executor("code")
|
||||
|
||||
assert "OPENAI_API_KEY" not in captured_env
|
||||
|
||||
def test_multiple_secrets_all_absent(self):
|
||||
"""All secrets must be stripped simultaneously, not just one."""
|
||||
captured_env: dict = {}
|
||||
|
||||
def _mock_inner(code: str, *args, **kwargs):
|
||||
captured_env.update(os.environ.copy())
|
||||
|
||||
executor = SafeLocalPythonExecutor(_inner=_mock_inner)
|
||||
|
||||
secrets = {
|
||||
"ANTHROPIC_API_KEY": "sk-ant",
|
||||
"GH_TOKEN": "ghp_",
|
||||
"OPENAI_API_KEY": "sk-open",
|
||||
"DATABASE_URL": "postgres://",
|
||||
"REDIS_URL": "redis://",
|
||||
"SLACK_BOT_TOKEN": "xoxb-",
|
||||
"JWT_SECRET": "secret",
|
||||
"DB_PASSWORD": "pass",
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, secrets, clear=False):
|
||||
executor("code")
|
||||
|
||||
for key in secrets:
|
||||
assert key not in captured_env, f"{key!r} was visible during execution"
|
||||
|
||||
def test_safe_vars_present_during_execution(self):
|
||||
"""Allowlisted variables must remain visible during execution."""
|
||||
captured_env: dict = {}
|
||||
|
||||
def _mock_inner(code: str, *args, **kwargs):
|
||||
captured_env.update(os.environ.copy())
|
||||
|
||||
executor = SafeLocalPythonExecutor(_inner=_mock_inner)
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"PATH": "/usr/bin:/bin",
|
||||
"WORKSPACE_ID": "ws-abc",
|
||||
"PYTHONPATH": "/app",
|
||||
"ANTHROPIC_API_KEY": "sk-ant-secret",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
executor("code")
|
||||
|
||||
assert captured_env.get("PATH") == "/usr/bin:/bin"
|
||||
assert captured_env.get("WORKSPACE_ID") == "ws-abc"
|
||||
assert captured_env.get("PYTHONPATH") == "/app"
|
||||
|
||||
def test_env_restored_after_execution(self):
|
||||
"""os.environ must be fully restored after __call__ returns."""
|
||||
executor = SafeLocalPythonExecutor(_inner=_MockInner())
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"ANTHROPIC_API_KEY": "sk-ant-secret", "PATH": "/usr/bin"},
|
||||
clear=False,
|
||||
):
|
||||
env_before = dict(os.environ)
|
||||
executor("code")
|
||||
env_after = dict(os.environ)
|
||||
|
||||
assert env_before == env_after
|
||||
|
||||
def test_env_restored_after_exception(self):
|
||||
"""os.environ must be restored even if the inner executor raises."""
|
||||
|
||||
def _raises(code: str, *args, **kwargs):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
executor = SafeLocalPythonExecutor(_inner=_raises)
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"ANTHROPIC_API_KEY": "sk-ant-secret"},
|
||||
clear=False,
|
||||
):
|
||||
env_before = dict(os.environ)
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
executor("code")
|
||||
env_after = dict(os.environ)
|
||||
|
||||
assert env_before == env_after
|
||||
|
||||
def test_returns_inner_result(self):
|
||||
mock_inner = _MockInner(return_value="hello world")
|
||||
executor = SafeLocalPythonExecutor(_inner=mock_inner)
|
||||
result = executor("some code")
|
||||
assert result == "hello world"
|
||||
|
||||
def test_passes_code_to_inner(self):
|
||||
mock_inner = _MockInner()
|
||||
executor = SafeLocalPythonExecutor(_inner=mock_inner)
|
||||
executor("print('hi')")
|
||||
assert mock_inner.calls == ["print('hi')"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SafeLocalPythonExecutor — import restrictions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSafeLocalPythonExecutorImports:
|
||||
def test_banned_imports_removed_from_authorized(self):
|
||||
"""Banned imports must not appear in the authorized list regardless of what caller passes."""
|
||||
executor = SafeLocalPythonExecutor(
|
||||
additional_imports=["subprocess", "socket", "math"],
|
||||
_inner=_MockInner(),
|
||||
)
|
||||
for banned in _BANNED_IMPORTS:
|
||||
assert banned not in executor._authorized_imports, (
|
||||
f"{banned!r} must not be in authorized imports"
|
||||
)
|
||||
|
||||
def test_safe_imports_present(self):
|
||||
executor = SafeLocalPythonExecutor(_inner=_MockInner())
|
||||
for safe in ["math", "json", "re", "datetime"]:
|
||||
assert safe in executor._authorized_imports
|
||||
|
||||
def test_additional_safe_import_added(self):
|
||||
executor = SafeLocalPythonExecutor(
|
||||
additional_imports=["numpy"],
|
||||
_inner=_MockInner(),
|
||||
)
|
||||
assert "numpy" in executor._authorized_imports
|
||||
|
||||
def test_banned_list_coverage(self):
|
||||
"""Verify the built-in banned list covers expected attack vectors."""
|
||||
expected_banned = {"subprocess", "socket", "ctypes", "importlib", "importlib.util"}
|
||||
assert expected_banned.issubset(_BANNED_IMPORTS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SafeLocalPythonExecutor — thread safety
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSafeLocalPythonExecutorThreadSafety:
|
||||
def test_concurrent_calls_restore_env_correctly(self):
|
||||
"""Two concurrent executions must not corrupt each other's env view."""
|
||||
results: list[bool] = []
|
||||
errors: list[Exception] = []
|
||||
|
||||
def _run(secret_key: str, secret_value: str):
|
||||
captured_env: dict = {}
|
||||
|
||||
def _inner(code: str, *args, **kwargs):
|
||||
captured_env.update(os.environ.copy())
|
||||
|
||||
executor = SafeLocalPythonExecutor(_inner=_inner)
|
||||
try:
|
||||
with patch.dict(os.environ, {secret_key: secret_value}, clear=False):
|
||||
executor("code")
|
||||
# Secret must not be visible during execution
|
||||
results.append(secret_key not in captured_env)
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=_run, args=(f"SECRET_{i}", f"value_{i}"))
|
||||
for i in range(10)
|
||||
]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert not errors, f"Threads raised: {errors}"
|
||||
assert all(results), "Some threads saw a secret that should have been stripped"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Allowlist contents
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAllowlistContents:
|
||||
def test_core_vars_in_allowlist(self):
|
||||
"""Spot-check that expected safe vars are on the allowlist."""
|
||||
required = {"PATH", "HOME", "LANG", "PYTHONPATH", "WORKSPACE_ID", "WORKSPACE_NAME", "PLATFORM_URL"}
|
||||
for var in required:
|
||||
assert var in _SAFE_ENV_ALLOWLIST, f"{var!r} missing from _SAFE_ENV_ALLOWLIST"
|
||||
|
||||
def test_secrets_not_in_allowlist(self):
|
||||
"""Known secret names must NOT appear on the allowlist."""
|
||||
forbidden = {
|
||||
"ANTHROPIC_API_KEY",
|
||||
"GH_TOKEN",
|
||||
"GITHUB_TOKEN",
|
||||
"OPENAI_API_KEY",
|
||||
"DATABASE_URL",
|
||||
"REDIS_URL",
|
||||
"SLACK_BOT_TOKEN",
|
||||
"JWT_SECRET",
|
||||
"DB_PASSWORD",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
}
|
||||
for var in forbidden:
|
||||
assert var not in _SAFE_ENV_ALLOWLIST, (
|
||||
f"{var!r} must NOT be in _SAFE_ENV_ALLOWLIST — it's a secret"
|
||||
)
|
||||
202
workspace-template/tests/test_safe_env.py
Normal file
202
workspace-template/tests/test_safe_env.py
Normal file
@ -0,0 +1,202 @@
|
||||
"""Tests for denylist-based env sanitization — safe_env.py (issue #826 / #827).
|
||||
|
||||
Covers:
|
||||
(a) SMOLAGENTS_ENV_DENYLIST keys are stripped
|
||||
(b) *_API_KEY suffix keys are stripped
|
||||
(c) *_TOKEN suffix keys are stripped
|
||||
(d) Non-secret keys (PATH, HOME, …) are preserved
|
||||
(e) safe_send_message label, truncation, and HTML escaping
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from adapters.smolagents.safe_env import (
|
||||
SMOLAGENTS_ENV_DENYLIST,
|
||||
make_safe_env,
|
||||
)
|
||||
from adapters.smolagents.send_message_wrapper import safe_send_message
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# make_safe_env — denylist-based
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMakeSafeEnvDenylist:
|
||||
"""(a) Explicit denylist keys are removed."""
|
||||
|
||||
@pytest.mark.parametrize("key", sorted(SMOLAGENTS_ENV_DENYLIST))
|
||||
def test_denylist_key_stripped(self, key: str):
|
||||
with patch.dict(os.environ, {key: "secret-value"}, clear=False):
|
||||
result = make_safe_env()
|
||||
assert key not in result, f"Denylist key {key!r} must be stripped"
|
||||
|
||||
def test_all_denylist_keys_stripped_simultaneously(self):
|
||||
secrets = {k: "secret" for k in SMOLAGENTS_ENV_DENYLIST}
|
||||
with patch.dict(os.environ, secrets, clear=False):
|
||||
result = make_safe_env()
|
||||
for key in SMOLAGENTS_ENV_DENYLIST:
|
||||
assert key not in result
|
||||
|
||||
|
||||
class TestMakeSafeEnvApiKeySuffix:
|
||||
"""(b) Keys ending with _API_KEY are stripped."""
|
||||
|
||||
def test_openai_api_key(self):
|
||||
with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-openai"}, clear=False):
|
||||
assert "OPENAI_API_KEY" not in make_safe_env()
|
||||
|
||||
def test_custom_api_key_suffix(self):
|
||||
with patch.dict(os.environ, {"MY_CUSTOM_SERVICE_API_KEY": "abc123"}, clear=False):
|
||||
assert "MY_CUSTOM_SERVICE_API_KEY" not in make_safe_env()
|
||||
|
||||
def test_arbitrary_api_key_suffix(self):
|
||||
with patch.dict(os.environ, {"FOOBAR_API_KEY": "secret"}, clear=False):
|
||||
assert "FOOBAR_API_KEY" not in make_safe_env()
|
||||
|
||||
|
||||
class TestMakeSafeEnvTokenSuffix:
|
||||
"""(c) Keys ending with _TOKEN are stripped."""
|
||||
|
||||
def test_gh_token(self):
|
||||
with patch.dict(os.environ, {"GH_TOKEN": "ghp_secret"}, clear=False):
|
||||
assert "GH_TOKEN" not in make_safe_env()
|
||||
|
||||
def test_github_token(self):
|
||||
with patch.dict(os.environ, {"GITHUB_TOKEN": "ghp_secret"}, clear=False):
|
||||
assert "GITHUB_TOKEN" not in make_safe_env()
|
||||
|
||||
def test_custom_token_suffix(self):
|
||||
with patch.dict(os.environ, {"MY_SERVICE_TOKEN": "tok_abc"}, clear=False):
|
||||
assert "MY_SERVICE_TOKEN" not in make_safe_env()
|
||||
|
||||
def test_arbitrary_token_suffix(self):
|
||||
with patch.dict(os.environ, {"INTERNAL_ACCESS_TOKEN": "secret"}, clear=False):
|
||||
assert "INTERNAL_ACCESS_TOKEN" not in make_safe_env()
|
||||
|
||||
|
||||
class TestMakeSafeEnvPreservesNonSecrets:
|
||||
"""(d) Non-secret keys are preserved."""
|
||||
|
||||
def test_preserves_path(self):
|
||||
with patch.dict(os.environ, {"PATH": "/usr/bin:/bin"}, clear=False):
|
||||
result = make_safe_env()
|
||||
assert result.get("PATH") == "/usr/bin:/bin"
|
||||
|
||||
def test_preserves_home(self):
|
||||
with patch.dict(os.environ, {"HOME": "/home/agent"}, clear=False):
|
||||
result = make_safe_env()
|
||||
assert result.get("HOME") == "/home/agent"
|
||||
|
||||
def test_preserves_workspace_id(self):
|
||||
with patch.dict(os.environ, {"WORKSPACE_ID": "ws-abc123"}, clear=False):
|
||||
result = make_safe_env()
|
||||
assert result.get("WORKSPACE_ID") == "ws-abc123"
|
||||
|
||||
def test_preserves_pythonpath(self):
|
||||
with patch.dict(os.environ, {"PYTHONPATH": "/app"}, clear=False):
|
||||
result = make_safe_env()
|
||||
assert result.get("PYTHONPATH") == "/app"
|
||||
|
||||
def test_preserves_lang(self):
|
||||
with patch.dict(os.environ, {"LANG": "en_US.UTF-8"}, clear=False):
|
||||
result = make_safe_env()
|
||||
assert result.get("LANG") == "en_US.UTF-8"
|
||||
|
||||
def test_does_not_mutate_os_environ(self):
|
||||
"""make_safe_env must never write back to os.environ."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"ANTHROPIC_API_KEY": "sk-ant-secret", "PATH": "/usr/bin"},
|
||||
clear=False,
|
||||
):
|
||||
before = dict(os.environ)
|
||||
make_safe_env()
|
||||
after = dict(os.environ)
|
||||
assert before == after
|
||||
|
||||
def test_returns_dict(self):
|
||||
assert isinstance(make_safe_env(), dict)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# safe_send_message — label, truncation, HTML escaping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSafeSendMessage:
|
||||
def _capture(self):
|
||||
"""Return a mock send_fn and its captured calls."""
|
||||
fn = MagicMock()
|
||||
return fn
|
||||
|
||||
def test_label_prefix_added(self):
|
||||
fn = self._capture()
|
||||
safe_send_message("hello", fn)
|
||||
fn.assert_called_once()
|
||||
payload = fn.call_args[0][0]
|
||||
assert payload.startswith("[smolagents]"), f"Missing label: {payload!r}"
|
||||
|
||||
def test_label_prefix_followed_by_content(self):
|
||||
fn = self._capture()
|
||||
safe_send_message("world", fn)
|
||||
payload = fn.call_args[0][0]
|
||||
assert "world" in payload
|
||||
|
||||
def test_truncates_at_2000_chars(self):
|
||||
fn = self._capture()
|
||||
long_text = "a" * 3000
|
||||
safe_send_message(long_text, fn)
|
||||
payload = fn.call_args[0][0]
|
||||
# The user content portion must be capped; label adds a few chars on top
|
||||
# Total len = len("[smolagents] ") + 2000
|
||||
assert len(payload) <= len("[smolagents] ") + 2000
|
||||
|
||||
def test_short_message_not_truncated(self):
|
||||
fn = self._capture()
|
||||
safe_send_message("short", fn)
|
||||
payload = fn.call_args[0][0]
|
||||
assert "short" in payload
|
||||
|
||||
def test_html_entities_escaped(self):
|
||||
fn = self._capture()
|
||||
safe_send_message("<script>alert('xss')</script>", fn)
|
||||
payload = fn.call_args[0][0]
|
||||
assert "<script>" not in payload
|
||||
assert "<script>" in payload
|
||||
|
||||
def test_ampersand_escaped(self):
|
||||
fn = self._capture()
|
||||
safe_send_message("a & b", fn)
|
||||
payload = fn.call_args[0][0]
|
||||
assert "&" in payload
|
||||
|
||||
def test_double_quote_escaped(self):
|
||||
fn = self._capture()
|
||||
safe_send_message('say "hello"', fn)
|
||||
payload = fn.call_args[0][0]
|
||||
assert """ in payload
|
||||
|
||||
def test_non_str_coerced(self):
|
||||
"""Non-string input must be coerced to str, not raise."""
|
||||
fn = self._capture()
|
||||
safe_send_message(42, fn)
|
||||
fn.assert_called_once()
|
||||
payload = fn.call_args[0][0]
|
||||
assert "42" in payload
|
||||
|
||||
def test_send_fn_called_exactly_once(self):
|
||||
fn = self._capture()
|
||||
safe_send_message("msg", fn)
|
||||
assert fn.call_count == 1
|
||||
|
||||
def test_empty_string_sends_label_only(self):
|
||||
fn = self._capture()
|
||||
safe_send_message("", fn)
|
||||
payload = fn.call_args[0][0]
|
||||
assert payload.strip() == "[smolagents]"
|
||||
Loading…
Reference in New Issue
Block a user