Merge pull request #1237 from Molecule-AI/staging

staging → main: tenant-guard allowlist registry paths
This commit is contained in:
Hongming Wang 2026-04-20 19:50:09 -07:00 committed by GitHub
commit 2bc4cb6357
41 changed files with 1902 additions and 721 deletions

View File

@ -85,6 +85,11 @@ function setLocation(href: string) {
}
beforeEach(() => {
// Always reset to real timers first. If a previous polling test failed
// before its finally-block ran, fake timers would still be active and
// vi.useFakeTimers() in the polling tests would be a no-op — causing
// setTimeout(0) to hang and the test to time out.
vi.useRealTimers();
vi.clearAllMocks();
// Reset mock return values so each test starts fresh.
// The mock functions (vi.fn) persist across tests; only their
@ -95,10 +100,9 @@ beforeEach(() => {
});
afterEach(() => {
cleanup();
});
afterEach(() => {
// Ensure fake timers are never left active after a test — even one that
// failed before reaching its own finally-block.
vi.useRealTimers();
cleanup();
});

View File

@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { api } from "@/lib/api";
import { showToast } from "@/components/Toaster";
interface Props {
workspaceId: string;
@ -133,7 +134,13 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-zinc-800 bg-zinc-900/40">
{output && (
<button
onClick={() => navigator.clipboard?.writeText(output)}
onClick={() => {
if (navigator.clipboard) {
navigator.clipboard.writeText(output);
} else {
showToast("Copy requires HTTPS — please select and copy manually", "info");
}
}}
className="px-3 py-1.5 text-[11px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
>
Copy

View File

@ -6,26 +6,24 @@ import { ConfirmDialog } from "@/components/ConfirmDialog";
// ── Types ─────────────────────────────────────────────────────────────────────
interface MemoryEntry {
key: string;
value: unknown;
version: number;
/** Omitted by the API when there is no TTL (Go omitempty) */
expires_at?: string;
updated_at: string;
/** Memory entry returned by GET /workspaces/:id/memories */
export interface MemoryEntry {
id: string;
workspace_id: string;
content: string;
scope: "LOCAL" | "TEAM" | "GLOBAL";
namespace: string;
created_at: string;
/**
* Semantic similarity score (01). Only present when the API is queried
* with ?q=<query> and the pgvector backend has been deployed (issue #776).
* with ?q=<query> and the pgvector backend has been deployed.
* Absent on plain list fetches renders gracefully without a badge.
*/
similarity_score?: number;
}
interface WriteResult {
status: string;
key: string;
version: number;
}
type Scope = "LOCAL" | "TEAM" | "GLOBAL";
const SCOPES: Scope[] = ["LOCAL", "TEAM", "GLOBAL"];
interface Props {
workspaceId: string;
@ -34,16 +32,10 @@ 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.
* Sanitise a memory id for use in an HTML id attribute.
*/
function sanitizeId(key: string): string {
return key
.replace(/[^a-zA-Z0-9]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
function sanitizeId(id: string): string {
return id.replace(/[^a-zA-Z0-9]/g, "-");
}
function formatRelativeTime(iso: string): string {
@ -54,7 +46,7 @@ function formatRelativeTime(iso: string): string {
return new Date(iso).toLocaleDateString();
}
// ── Skeleton rows — shown during re-fetches when entries already exist ────────
// ── Skeleton rows ──────────────────────────────────────────────────────────────
function MemorySkeletonRows() {
return (
@ -79,20 +71,16 @@ function MemorySkeletonRows() {
// ── Component ─────────────────────────────────────────────────────────────────
export function MemoryInspectorPanel({ workspaceId }: Props) {
const [activeScope, setActiveScope] = useState<Scope>("LOCAL");
const [activeNamespace, setActiveNamespace] = useState("");
const [entries, setEntries] = useState<MemoryEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// ── Search state ────────────────────────────────────────────────────────────
/** Raw input value — updated on every keystroke. */
// ── Search state (debounced) ────────────────────────────────────────────────
const [searchQuery, setSearchQuery] = useState("");
/**
* Debounced value drives the API fetch.
* Lags searchQuery by 300 ms to avoid hammering the endpoint on every key.
*/
const [debouncedQuery, setDebouncedQuery] = useState("");
// 300 ms debounce: cancel previous timer whenever searchQuery changes.
useEffect(() => {
const timer = setTimeout(
() => setDebouncedQuery(searchQuery.trim()),
@ -101,14 +89,8 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
return () => clearTimeout(timer);
}, [searchQuery]);
// ── Expand/edit/delete state (keyed by entry.key — primitives, no new objects)
const [expandedKey, setExpandedKey] = useState<string | null>(null);
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const [editError, setEditError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [pendingDeleteKey, setPendingDeleteKey] = useState<string | null>(null);
// ── Delete state ─────────────────────────────────────────────────────────────
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
// ── Data loading ────────────────────────────────────────────────────────────
@ -116,12 +98,15 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
setLoading(true);
setError(null);
try {
const url = debouncedQuery
? `/workspaces/${workspaceId}/memory?q=${encodeURIComponent(debouncedQuery)}`
: `/workspaces/${workspaceId}/memory`;
const params = new URLSearchParams();
params.set("scope", activeScope);
if (debouncedQuery) params.set("q", debouncedQuery);
if (activeNamespace) params.set("namespace", activeNamespace);
const url = `/workspaces/${workspaceId}/memories?${params.toString()}`;
const data = await api.get<MemoryEntry[]>(url);
// When a semantic query is active, sort by similarity_score descending.
// Entries without a score (older backend) fall to the end gracefully.
const sorted = debouncedQuery
? [...data].sort(
(a, b) => (b.similarity_score ?? 0) - (a.similarity_score ?? 0)
@ -129,123 +114,70 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
: data;
setEntries(sorted);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load memory entries");
setError(e instanceof Error ? e.message : "Failed to load memories");
setEntries([]);
} finally {
setLoading(false);
}
}, [workspaceId, debouncedQuery]);
}, [workspaceId, activeScope, debouncedQuery, activeNamespace]);
useEffect(() => {
loadEntries();
}, [loadEntries]);
// ── Edit handlers ───────────────────────────────────────────────────────────
const startEdit = useCallback((entry: MemoryEntry) => {
setEditingKey(entry.key);
setEditValue(JSON.stringify(entry.value, null, 2));
setEditError(null);
}, []);
const cancelEdit = useCallback(() => {
setEditingKey(null);
setEditValue("");
setEditError(null);
}, []);
const saveEdit = useCallback(
async (entry: MemoryEntry) => {
let parsed: unknown;
try {
parsed = JSON.parse(editValue);
} catch {
setEditError("Invalid JSON — fix the syntax before saving");
return;
}
setSaving(true);
setEditError(null);
// Optimistic update — capture rollback snapshot before mutating
const snapshot = entries;
setEntries((prev) =>
prev.map((e) =>
e.key === entry.key
? {
...e,
value: parsed,
version: e.version + 1,
updated_at: new Date().toISOString(),
}
: e
)
);
setEditingKey(null);
setEditValue("");
try {
await api.post<WriteResult>(`/workspaces/${workspaceId}/memory`, {
key: entry.key,
value: parsed,
if_match_version: entry.version,
});
} catch (e) {
// Roll back optimistic update on any error
setEntries(snapshot);
setEditingKey(entry.key);
setEditValue(JSON.stringify(entry.value, null, 2));
const msg = e instanceof Error ? e.message : "Save failed";
if (msg.includes("409") || msg.toLowerCase().includes("mismatch")) {
setEditError(
"Version conflict — entry changed elsewhere. Reload to see latest."
);
} else {
setEditError(msg);
}
} finally {
setSaving(false);
}
},
[entries, editValue, workspaceId]
);
// ── Delete handlers ─────────────────────────────────────────────────────────
const confirmDelete = useCallback(async () => {
if (!pendingDeleteKey) return;
const key = pendingDeleteKey;
setPendingDeleteKey(null);
if (!pendingDeleteId) return;
const id = pendingDeleteId;
setPendingDeleteId(null);
// Optimistic removal
setEntries((prev) => prev.filter((e) => e.key !== key));
if (expandedKey === key) setExpandedKey(null);
setEntries((prev) => prev.filter((e) => e.id !== id));
try {
await api.del(
`/workspaces/${workspaceId}/memory/${encodeURIComponent(key)}`
);
await api.del(`/workspaces/${workspaceId}/memories/${encodeURIComponent(id)}`);
} catch (e) {
setError(e instanceof Error ? e.message : "Delete failed — reloading...");
await loadEntries();
}
}, [pendingDeleteKey, expandedKey, workspaceId, loadEntries]);
}, [pendingDeleteId, workspaceId, loadEntries]);
// ── Render ──────────────────────────────────────────────────────────────────
// Full-screen loader — only on the very first fetch (no entries cached yet).
if (loading && entries.length === 0 && !error) {
return (
<div className="flex items-center justify-center h-32">
<span className="text-xs text-zinc-500">Loading memory</span>
<span className="text-xs text-zinc-500">Loading memories</span>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Search bar */}
{/* Scope tabs */}
<div className="px-4 pt-3 pb-2 border-b border-zinc-800/40 shrink-0">
<div className="flex items-center gap-1">
{SCOPES.map((scope) => (
<button
key={scope}
onClick={() => setActiveScope(scope)}
aria-pressed={activeScope === scope}
className={[
"px-3 py-1 text-[11px] rounded transition-colors",
activeScope === scope
? "bg-blue-600 text-white"
: "bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200",
].join(" ")}
>
{scope}
</button>
))}
</div>
</div>
{/* Search bar + namespace filter */}
<div className="px-4 pt-3 pb-2 border-b border-zinc-800/40 shrink-0 space-y-2">
<div className="relative flex items-center">
{/* Magnifying glass icon */}
<svg
@ -264,15 +196,13 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Semantic search…"
aria-label="Search memory entries"
aria-label="Search memories"
className="w-full bg-zinc-900 border border-zinc-700/60 focus:border-blue-500/60 rounded-lg pl-8 pr-7 py-1.5 text-[11px] text-zinc-200 placeholder-zinc-600 focus:outline-none transition-colors"
/>
{/* Clear button — only shown when there is a query */}
{searchQuery && (
<button
onClick={() => {
setSearchQuery("");
// Skip the debounce delay for clear — reset immediately
setDebouncedQuery("");
}}
aria-label="Clear search"
@ -282,6 +212,22 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
</button>
)}
</div>
{/* Namespace filter */}
<div className="flex items-center gap-2">
<label htmlFor="namespace-filter" className="text-[10px] text-zinc-500 shrink-0">
Namespace:
</label>
<input
id="namespace-filter"
type="text"
value={activeNamespace}
onChange={(e) => setActiveNamespace(e.target.value)}
placeholder="all namespaces"
aria-label="Filter by namespace"
className="flex-1 bg-zinc-900 border border-zinc-700/60 focus:border-blue-500/60 rounded px-2 py-1 text-[11px] text-zinc-200 placeholder-zinc-600 focus:outline-none transition-colors min-w-0"
/>
</div>
</div>
{/* Toolbar */}
@ -290,13 +236,13 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
{debouncedQuery
? `${entries.length} result${entries.length !== 1 ? "s" : ""}`
: entries.length === 1
? "1 entry"
: `${entries.length} entries`}
? "1 memory"
: `${entries.length} memories`}
</span>
<button
onClick={loadEntries}
className="px-2 py-1 text-[11px] bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded transition-colors"
aria-label="Refresh memory entries"
aria-label="Refresh memories"
>
Refresh
</button>
@ -316,11 +262,9 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
/* Skeleton rows — visible during search-transition re-fetches */
<MemorySkeletonRows />
) : entries.length === 0 ? (
debouncedQuery ? (
/* Search-specific empty state */
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
<span className="text-4xl text-zinc-700" aria-hidden="true"></span>
<p className="text-sm font-medium text-zinc-400">
@ -341,56 +285,40 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
</p>
</div>
) : (
/* Default empty state */
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
<span className="text-4xl text-zinc-700" aria-hidden="true"></span>
<p className="text-sm font-medium text-zinc-400">No memory entries yet</p>
<p className="text-sm font-medium text-zinc-400">No {activeScope} memories</p>
<p className="text-[11px] text-zinc-600 max-w-[200px] leading-relaxed">
Memory entries will appear here when the workspace writes to its KV
store.
{activeScope === "LOCAL"
? "This workspace has not written any local memories yet."
: activeScope === "TEAM"
? "No team memories shared with this workspace yet."
: "No global memories exist yet."}
</p>
</div>
)
) : (
<div className="space-y-1.5">
{entries.map((entry) => {
const isExpanded = expandedKey === entry.key;
const isEditing = editingKey === entry.key;
return (
<MemoryEntryRow
key={entry.key}
entry={entry}
isExpanded={isExpanded}
isEditing={isEditing}
editValue={editValue}
editError={editError}
saving={saving}
onToggle={() => {
const next = isExpanded ? null : entry.key;
setExpandedKey(next);
if (!next && isEditing) cancelEdit();
}}
onEditValueChange={setEditValue}
onStartEdit={() => startEdit(entry)}
onSave={() => saveEdit(entry)}
onCancelEdit={cancelEdit}
onDelete={() => setPendingDeleteKey(entry.key)}
/>
);
})}
{entries.map((entry) => (
<MemoryEntryRow
key={entry.id}
entry={entry}
onDelete={() => setPendingDeleteId(entry.id)}
/>
))}
</div>
)}
</div>
{/* Delete confirmation dialog */}
<ConfirmDialog
open={pendingDeleteKey !== null}
title="Delete memory entry"
message={`Delete key "${pendingDeleteKey}"? This cannot be undone.`}
open={pendingDeleteId !== null}
title="Delete memory"
message={`Delete this ${activeScope} memory? This cannot be undone.`}
confirmLabel="Delete"
confirmVariant="danger"
onConfirm={confirmDelete}
onCancel={() => setPendingDeleteKey(null)}
onCancel={() => setPendingDeleteId(null)}
/>
</div>
);
@ -400,155 +328,97 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
interface MemoryEntryRowProps {
entry: MemoryEntry;
isExpanded: boolean;
isEditing: boolean;
editValue: string;
editError: string | null;
saving: boolean;
onToggle: () => void;
onEditValueChange: (v: string) => void;
onStartEdit: () => void;
onSave: () => void;
onCancelEdit: () => void;
onDelete: () => void;
}
function MemoryEntryRow({
entry,
isExpanded,
isEditing,
editValue,
editError,
saving,
onToggle,
onEditValueChange,
onStartEdit,
onSave,
onCancelEdit,
onDelete,
}: MemoryEntryRowProps) {
const bodyId = `mem-body-${sanitizeId(entry.key)}`;
function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
const [expanded, setExpanded] = useState(false);
const bodyId = `mem-body-${sanitizeId(entry.id)}`;
return (
<div className="rounded-lg border border-zinc-800/60 bg-zinc-900/50 overflow-hidden">
{/* Header row — click to expand/collapse */}
{/* Header row */}
<button
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}
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
aria-controls={bodyId}
>
<span className="text-[10px] font-mono text-blue-400 truncate flex-1 min-w-0">
{entry.key}
{/* Scope badge */}
<span
className={[
"text-[9px] shrink-0 font-mono px-1 py-0.5 rounded",
entry.scope === "LOCAL"
? "bg-zinc-700 text-zinc-400"
: entry.scope === "TEAM"
? "bg-blue-950 text-blue-400"
: "bg-violet-950 text-violet-400",
].join(" ")}
title={`Scope: ${entry.scope}`}
>
{entry.scope[0]}
</span>
<span className="text-[9px] text-zinc-600 shrink-0 font-mono">
v{entry.version}
{/* Namespace tag */}
<span className="text-[9px] shrink-0 font-mono text-zinc-500 truncate max-w-[80px]" title={entry.namespace}>
{entry.namespace}
</span>
{/* Similarity score badge — only rendered when backend provides a score */}
{/* Content preview */}
<span className="flex-1 min-w-0 text-[10px] font-mono text-zinc-300 truncate text-left">
{entry.content.length > 60 ? entry.content.slice(0, 60) + "…" : entry.content}
</span>
{/* Similarity badge */}
{entry.similarity_score != null && (
<span
className={[
"text-[9px] shrink-0 font-mono tabular-nums",
entry.similarity_score >= 0.8
? "text-blue-500"
: entry.similarity_score >= 0.5
? "text-zinc-400"
: "text-zinc-400 italic",
: "text-zinc-400",
].join(" ")}
title={`Similarity: ${(entry.similarity_score * 100).toFixed(1)}%`}
data-testid="similarity-badge"
>
{entry.similarity_score < 0.5 ? "~" : ""}{Math.round(entry.similarity_score * 100)}%
{Math.round(entry.similarity_score * 100)}%
</span>
)}
<span className="text-[9px] text-zinc-600 shrink-0">
{formatRelativeTime(entry.updated_at)}
{formatRelativeTime(entry.created_at)}
</span>
<span className="text-[9px] text-zinc-500 shrink-0" aria-hidden="true">
{isExpanded ? "▼" : "▶"}
{expanded ? "▼" : "▶"}
</span>
</button>
{/* Expanded body */}
{isExpanded && (
{expanded && (
<div
id={bodyId}
role="region"
aria-label={`Details for ${entry.key}`}
aria-label="Memory details"
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()}
</p>
)}
{isEditing ? (
/* Edit mode */
<div className="space-y-2">
<textarea
value={editValue}
onChange={(e) => onEditValueChange(e.target.value)}
rows={6}
aria-label="Edit memory value"
className="w-full bg-zinc-950 border border-zinc-700 focus:border-blue-500 rounded px-2 py-1.5 text-[11px] font-mono text-zinc-100 focus:outline-none resize-none transition-colors"
/>
{editError && (
<p role="alert" aria-live="assertive" className="text-[10px] text-red-400">
{editError}
</p>
)}
<div className="flex items-center gap-2">
<button
onClick={onSave}
disabled={saving}
className="px-3 py-1 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-xs rounded text-white transition-colors"
>
{saving ? "Saving…" : "Save"}
</button>
<button
onClick={onCancelEdit}
disabled={saving}
className="px-3 py-1 bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 text-xs rounded text-zinc-300 transition-colors"
>
Cancel
</button>
</div>
</div>
) : (
/* Read mode */
<div className="space-y-2">
<pre className="text-[10px] font-mono text-zinc-300 bg-zinc-950 rounded p-2 overflow-x-auto max-h-48 whitespace-pre-wrap break-all">
{JSON.stringify(entry.value, null, 2)}
</pre>
<div className="flex items-center justify-between gap-2">
<span className="text-[9px] text-zinc-600">
Updated: {new Date(entry.updated_at).toLocaleString()}
</span>
<div className="flex items-center gap-1.5 shrink-0">
<button
onClick={(e) => {
e.stopPropagation();
onStartEdit();
}}
aria-label={`Edit ${entry.key}`}
className="text-[10px] px-2 py-0.5 bg-zinc-700 hover:bg-zinc-600 rounded text-zinc-300 transition-colors"
>
Edit
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
aria-label={`Delete ${entry.key}`}
className="text-[10px] px-2 py-0.5 bg-red-950/40 hover:bg-red-900/50 border border-red-900/30 rounded text-red-400 transition-colors"
>
Delete
</button>
</div>
</div>
</div>
)}
<pre className="text-[10px] font-mono text-zinc-300 bg-zinc-950 rounded p-2 overflow-x-auto max-h-48 whitespace-pre-wrap break-all">
{entry.content}
</pre>
<div className="flex items-center justify-between gap-2">
<span className="text-[9px] text-zinc-600">
Created: {new Date(entry.created_at).toLocaleString()}
</span>
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
aria-label="Delete memory"
className="text-[10px] px-2 py-0.5 bg-red-950/40 hover:bg-red-900/50 border border-red-900/30 rounded text-red-400 transition-colors shrink-0"
>
Delete
</button>
</div>
</div>
)}
</div>

View File

@ -8,10 +8,10 @@ export interface WorkspaceUsageProps {
}
interface WorkspaceMetrics {
input_tokens: number;
output_tokens: number;
total_calls: number;
estimated_cost_usd: string;
input_tokens?: number; // optional — provisioning-stuck workspaces return partial shapes
output_tokens?: number; // optional — same
total_calls?: number;
estimated_cost_usd?: string; // optional — same
period_start: string;
period_end: string;
}

View File

@ -1,15 +1,14 @@
// @vitest-environment jsdom
/**
* MemoryInspectorPanel tests issue #730
* MemoryInspectorPanel tests issue #909
*
* Covers: loading, empty state, entry list, expand, edit flow (happy path,
* invalid JSON, cancel), delete flow (confirm, cancel), optimistic updates,
* and Refresh.
* Covers: loading, empty state, scope tabs, namespace filter,
* entry list, expand, delete flow, optimistic updates, Refresh, semantic search.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
// ── Mocks (must be hoisted before any imports) ────────────────────────────────
// ── Mocks ─────────────────────────────────────────────────────────────────────
vi.mock("@/lib/api", () => ({
api: {
@ -19,9 +18,6 @@ vi.mock("@/lib/api", () => ({
},
}));
// ConfirmDialog uses createPortal + a `mounted` state guard that requires
// useEffect to fire. We mock it to a simple inline rendering so tests are
// synchronous and don't depend on document.body portal availability.
vi.mock("@/components/ConfirmDialog", () => ({
ConfirmDialog: ({
open,
@ -49,38 +45,37 @@ vi.mock("@/components/ConfirmDialog", () => ({
) : null,
}));
// ── Imports (after mocks) ─────────────────────────────────────────────────────
import { api } from "@/lib/api";
import { MemoryInspectorPanel } from "../MemoryInspectorPanel";
// ── Typed mock helpers ────────────────────────────────────────────────────────
const mockGet = vi.mocked(api.get);
const mockPost = vi.mocked(api.post);
const mockDel = vi.mocked(api.del);
// ── Sample fixtures ───────────────────────────────────────────────────────────
const NOW = new Date("2026-04-17T12:00:00.000Z").toISOString();
const LATER = new Date("2026-04-17T13:00:00.000Z").toISOString();
const NOW = "2026-04-17T12:00:00.000Z";
const ENTRY_A = {
key: "task-queue",
value: { pending: ["t-1", "t-2"], done: [] },
version: 3,
updated_at: NOW,
const MEMORY_A: import("../MemoryInspectorPanel").MemoryEntry = {
id: "mem-a",
workspace_id: "ws-1",
content: "Remember to review PRs before merging",
scope: "LOCAL",
namespace: "general",
created_at: NOW,
};
const ENTRY_B = {
key: "session-token",
value: "abc123",
version: 1,
expires_at: LATER,
updated_at: NOW,
const MEMORY_B: import("../MemoryInspectorPanel").MemoryEntry = {
id: "mem-b",
workspace_id: "ws-1",
content: "Team knowledge: deploy happens on Fridays",
scope: "TEAM",
namespace: "procedures",
created_at: NOW,
};
const TWO_ENTRIES = [ENTRY_A, ENTRY_B];
const TWO_MEMORIES = [MEMORY_A, MEMORY_B];
// ── Setup / teardown ──────────────────────────────────────────────────────────
@ -92,82 +87,177 @@ afterEach(() => {
cleanup();
});
// ── Helper: flush microtasks + React state updates ─────────────────────────────
async function flushUpdates(): Promise<void> {
await act(async () => {});
}
// ── Loading & empty state ─────────────────────────────────────────────────────
describe("MemoryInspectorPanel — loading and empty state", () => {
it("shows loading indicator before data arrives", () => {
// Never resolves within this test — just checks the loading UI
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockReturnValue(new Promise(() => {}) as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
expect(screen.getByText(/loading memory/i)).toBeTruthy();
expect(screen.getByText(/loading memories/i)).toBeTruthy();
});
it("renders empty state when API returns []", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() =>
expect(screen.getByText("No memory entries yet")).toBeTruthy()
);
await flushUpdates();
expect(screen.getByText("No LOCAL memories")).toBeTruthy();
});
it("fetches from the correct workspace memory endpoint", async () => {
it("fetches from the correct workspace memories endpoint with scope=LOCAL", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([] as any);
render(<MemoryInspectorPanel workspaceId="ws-abc-123" />);
await waitFor(() =>
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-abc-123/memory")
await flushUpdates();
expect(mockGet).toHaveBeenCalledWith(
"/workspaces/ws-abc-123/memories?scope=LOCAL"
);
});
it("shows error banner when fetch throws", async () => {
mockGet.mockRejectedValue(new Error("Network error"));
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() =>
expect(screen.getByText("Network error")).toBeTruthy()
);
await flushUpdates();
expect(screen.getByText("Network error")).toBeTruthy();
});
});
// ── Entry list ────────────────────────────────────────────────────────────────
// ── Scope tabs ────────────────────────────────────────────────────────────────
describe("MemoryInspectorPanel — scope tabs", () => {
it("renders LOCAL, TEAM, GLOBAL tabs", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await flushUpdates();
expect(screen.getByRole("button", { name: "LOCAL" })).toBeTruthy();
expect(screen.getByRole("button", { name: "TEAM" })).toBeTruthy();
expect(screen.getByRole("button", { name: "GLOBAL" })).toBeTruthy();
});
it("LOCAL is active by default", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await flushUpdates();
expect(screen.getByRole("button", { name: "LOCAL" }).getAttribute("aria-pressed")).toBe("true");
});
it("clicking TEAM tab re-fetches with scope=TEAM", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await flushUpdates();
mockGet.mockClear();
fireEvent.click(screen.getByRole("button", { name: "TEAM" }));
await flushUpdates();
expect(mockGet).toHaveBeenCalledWith(
"/workspaces/ws-1/memories?scope=TEAM"
);
});
it("clicking GLOBAL tab re-fetches with scope=GLOBAL", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await flushUpdates();
mockGet.mockClear();
fireEvent.click(screen.getByRole("button", { name: "GLOBAL" }));
await flushUpdates();
expect(mockGet).toHaveBeenCalledWith(
"/workspaces/ws-1/memories?scope=GLOBAL"
);
});
it("shows scope-specific empty state when switching tabs", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await flushUpdates();
fireEvent.click(screen.getByRole("button", { name: "TEAM" }));
await flushUpdates();
expect(screen.getByText("No TEAM memories")).toBeTruthy();
});
});
// ── Namespace filter ──────────────────────────────────────────────────────────
describe("MemoryInspectorPanel — namespace filter", () => {
it("renders namespace filter input", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await flushUpdates();
expect(screen.getByLabelText("Filter by namespace")).toBeTruthy();
});
it("includes namespace param in API call when set", async () => {
vi.useFakeTimers();
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await flushUpdates();
mockGet.mockClear();
fireEvent.change(screen.getByLabelText("Filter by namespace"), {
target: { value: "facts" },
});
// Advance past the 300ms debounce
act(() => { vi.advanceTimersByTime(350); });
await flushUpdates();
expect(mockGet).toHaveBeenCalledWith(
"/workspaces/ws-1/memories?scope=LOCAL&namespace=facts"
);
} finally {
vi.useRealTimers();
}
});
});
// ── Entry list ───────────────────────────────────────────────────────────────
describe("MemoryInspectorPanel — entry list", () => {
beforeEach(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(TWO_ENTRIES as any);
mockGet.mockResolvedValue(TWO_MEMORIES as any);
});
it("renders a row for every entry key", async () => {
it("renders a row for every memory", async () => {
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
expect(screen.getByText("session-token")).toBeTruthy();
await flushUpdates();
expect(screen.getByText(/Remember to review PRs before merging/)).toBeTruthy();
expect(screen.getByText(/Team knowledge: deploy happens on Fridays/)).toBeTruthy();
});
it("displays '2 entries' count in the toolbar", async () => {
it("displays memory count in toolbar", async () => {
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => expect(screen.getByText("2 entries")).toBeTruthy());
await flushUpdates();
expect(screen.getByText("2 memories")).toBeTruthy();
});
it("displays '1 entry' (singular) when there is one entry", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([ENTRY_A] as any);
it("displays scope badge for each entry", async () => {
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => expect(screen.getByText("1 entry")).toBeTruthy());
await flushUpdates();
expect(screen.getByTitle("Scope: LOCAL")).toBeTruthy();
expect(screen.getByTitle("Scope: TEAM")).toBeTruthy();
});
it("shows version badge for each entry", async () => {
it("entries are collapsed by default (pre region not visible)", async () => {
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
expect(screen.getByText("v3")).toBeTruthy();
expect(screen.getByText("v1")).toBeTruthy();
});
it("entries are collapsed by default (value not visible)", async () => {
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
// The JSON value should NOT be rendered while collapsed
expect(screen.queryByText(/"pending"/)).toBeNull();
await flushUpdates();
// Expanded region (pre tag) should not exist in DOM yet
expect(screen.queryByRole("region")).toBeNull();
});
});
@ -176,144 +266,36 @@ describe("MemoryInspectorPanel — entry list", () => {
describe("MemoryInspectorPanel — expand/collapse", () => {
beforeEach(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(TWO_ENTRIES as any);
mockGet.mockResolvedValue(TWO_MEMORIES as any);
});
it("clicking a row header expands it and shows the JSON value", async () => {
it("clicking a row header expands it and shows the full content in a pre tag", async () => {
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
await flushUpdates();
// Click to expand
fireEvent.click(
screen.getByText("task-queue").closest("button")!
);
await waitFor(() =>
expect(screen.getByText(/"pending"/)).toBeTruthy()
screen.getByText(/Remember to review PRs before merging/).closest("button")!
);
await flushUpdates();
// After expand, a region with the full content <pre> should appear
expect(screen.getByRole("region")).toBeTruthy();
});
it("clicking the header again collapses the row", async () => {
it("clicking the header again collapses the row (pre region removed)", async () => {
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
await flushUpdates();
const headerBtn = screen.getByText("task-queue").closest("button")!;
const headerBtn = screen
.getByText(/Remember to review PRs before merging/)
.closest("button")!;
fireEvent.click(headerBtn); // expand
await waitFor(() => screen.getByText(/"pending"/));
await flushUpdates();
expect(screen.getByRole("region")).toBeTruthy();
fireEvent.click(headerBtn); // collapse
await waitFor(() =>
expect(screen.queryByText(/"pending"/)).toBeNull()
);
});
it("shows expires_at when present", async () => {
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("session-token"));
fireEvent.click(
screen.getByText("session-token").closest("button")!
);
await waitFor(() =>
expect(screen.getByText(/expires/i)).toBeTruthy()
);
});
});
// ── Edit flow ─────────────────────────────────────────────────────────────────
describe("MemoryInspectorPanel — edit flow", () => {
beforeEach(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(TWO_ENTRIES as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockPost.mockResolvedValue({ status: "ok", key: "task-queue", version: 4 } as any);
});
/** Helper: expand entry-A and click its Edit button */
async function openEditForEntryA() {
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
fireEvent.click(screen.getByText("task-queue").closest("button")!);
await waitFor(() =>
screen.getByRole("button", { name: "Edit task-queue" })
);
fireEvent.click(screen.getByRole("button", { name: "Edit task-queue" }));
}
it("shows a textarea pre-filled with the entry value after clicking Edit", async () => {
await openEditForEntryA();
const ta = screen.getByRole("textbox", { name: "Edit memory value" });
expect(ta).toBeTruthy();
expect((ta as HTMLTextAreaElement).value).toContain("pending");
});
it("shows Save and Cancel buttons in edit mode", async () => {
await openEditForEntryA();
expect(screen.getByRole("button", { name: /^save$/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /^cancel$/i })).toBeTruthy();
});
it("POSTs to correct path with key, parsed value, and if_match_version", async () => {
await openEditForEntryA();
fireEvent.change(
screen.getByRole("textbox", { name: "Edit memory value" }),
{ target: { value: '{"updated":true}' } }
);
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() => expect(mockPost).toHaveBeenCalled());
const [path, body] = mockPost.mock.calls[0] as [
string,
{ key: string; value: unknown; if_match_version: number }
];
expect(path).toBe("/workspaces/ws-1/memory");
expect(body.key).toBe("task-queue");
expect(body.if_match_version).toBe(3); // ENTRY_A.version
expect(body.value).toEqual({ updated: true });
});
it("closes the edit form on successful save", async () => {
await openEditForEntryA();
fireEvent.change(
screen.getByRole("textbox", { name: "Edit memory value" }),
{ target: { value: '"new-value"' } }
);
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() =>
expect(
screen.queryByRole("textbox", { name: "Edit memory value" })
).toBeNull()
);
});
it("shows an inline error (no API call) for syntactically invalid JSON", async () => {
await openEditForEntryA();
fireEvent.change(
screen.getByRole("textbox", { name: "Edit memory value" }),
{ target: { value: "{{bad json" } }
);
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
// Error message appears, textarea stays open, api.post NOT called
await waitFor(() =>
expect(screen.getByText(/invalid json/i)).toBeTruthy()
);
expect(mockPost).not.toHaveBeenCalled();
expect(screen.getByRole("textbox", { name: "Edit memory value" })).toBeTruthy();
});
it("Cancel closes the edit form without calling api.post", async () => {
await openEditForEntryA();
fireEvent.click(screen.getByRole("button", { name: /^cancel$/i }));
await waitFor(() =>
expect(
screen.queryByRole("textbox", { name: "Edit memory value" })
).toBeNull()
);
expect(mockPost).not.toHaveBeenCalled();
await flushUpdates();
// After collapse, the region (pre) is removed from the DOM
expect(screen.queryByRole("region")).toBeNull();
});
});
@ -322,271 +304,164 @@ describe("MemoryInspectorPanel — edit flow", () => {
describe("MemoryInspectorPanel — delete flow", () => {
beforeEach(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(TWO_ENTRIES as any);
mockGet.mockResolvedValue(TWO_MEMORIES as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockDel.mockResolvedValue({ status: "deleted" } as any);
});
/** Helper: expand entry-A and click its Delete button */
async function openDeleteForEntryA() {
/** Helper: expand memory-A and click its Delete button */
async function openDeleteForMemoryA() {
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
fireEvent.click(screen.getByText("task-queue").closest("button")!);
await waitFor(() =>
screen.getByRole("button", { name: "Delete task-queue" })
await flushUpdates();
fireEvent.click(
screen.getByText(/Remember to review PRs before merging/).closest("button")!
);
fireEvent.click(screen.getByRole("button", { name: "Delete task-queue" }));
await flushUpdates();
fireEvent.click(screen.getByRole("button", { name: "Delete memory" }));
await flushUpdates();
}
it("opens the ConfirmDialog when Delete is clicked", async () => {
await openDeleteForEntryA();
it("opens ConfirmDialog when Delete is clicked", async () => {
await openDeleteForMemoryA();
expect(screen.getByTestId("confirm-dialog")).toBeTruthy();
expect(screen.getByTestId("dialog-title").textContent).toBe(
"Delete memory entry"
);
});
it("includes the key in the dialog message", async () => {
await openDeleteForEntryA();
expect(screen.getByTestId("dialog-message").textContent).toContain(
"task-queue"
);
expect(screen.getByTestId("dialog-title").textContent).toBe("Delete memory");
});
it("calls api.del with the correct URL-encoded path on confirm", async () => {
await openDeleteForEntryA();
await openDeleteForMemoryA();
fireEvent.click(screen.getByText("Confirm Delete"));
await waitFor(() =>
expect(mockDel).toHaveBeenCalledWith(
"/workspaces/ws-1/memory/task-queue"
)
);
await flushUpdates();
expect(mockDel).toHaveBeenCalledWith("/workspaces/ws-1/memories/mem-a");
});
it("removes the entry from the list optimistically after confirm", async () => {
await openDeleteForEntryA();
it("removes the entry optimistically after confirm", async () => {
await openDeleteForMemoryA();
fireEvent.click(screen.getByText("Confirm Delete"));
await waitFor(() =>
expect(screen.queryByText("task-queue")).toBeNull()
);
await flushUpdates();
expect(screen.queryByText(/Remember to review PRs before merging/)).toBeNull();
// Sibling entry unaffected
expect(screen.getByText("session-token")).toBeTruthy();
expect(screen.getByText(/Team knowledge: deploy happens on Fridays/)).toBeTruthy();
});
it("closes the ConfirmDialog without deleting when Cancel is clicked", async () => {
await openDeleteForEntryA();
it("closes ConfirmDialog without deleting when Cancel is clicked", async () => {
await openDeleteForMemoryA();
fireEvent.click(screen.getByText("Cancel Delete"));
await waitFor(() =>
expect(screen.queryByTestId("confirm-dialog")).toBeNull()
);
await flushUpdates();
expect(screen.queryByTestId("confirm-dialog")).toBeNull();
expect(mockDel).not.toHaveBeenCalled();
// Entry still present
expect(screen.getByText("task-queue")).toBeTruthy();
// Sibling memory entry (MEMORY_B) is still in the list
expect(screen.getByText(/Team knowledge: deploy happens on Fridays/)).toBeTruthy();
});
});
// ── Refresh ───────────────────────────────────────────────────────────────────
describe("MemoryInspectorPanel — Refresh button", () => {
it("re-fetches entries when the Refresh button is clicked", async () => {
it("re-fetches entries when Refresh is clicked", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("No memory entries yet"));
await flushUpdates();
expect(screen.getByText("No LOCAL memories")).toBeTruthy();
expect(mockGet).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Refresh memory entries" }));
await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(2));
fireEvent.click(screen.getByRole("button", { name: "Refresh memories" }));
await flushUpdates();
expect(mockGet).toHaveBeenCalledTimes(2);
});
});
// ── role=alert a11y (issue #830) ─────────────────────────────────────────────
// ── role=alert a11y ──────────────────────────────────────────────────────────
describe("MemoryInspectorPanel — error elements have role=alert (issue #830)", () => {
describe("MemoryInspectorPanel — error elements have role=alert", () => {
it("fetch error banner has role='alert'", async () => {
mockGet.mockRejectedValue(new Error("Network error"));
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("Network error"));
await flushUpdates();
const alert = screen.getByRole("alert");
expect(alert).toBeTruthy();
expect(alert.textContent).toContain("Network error");
});
it("editError paragraph has role='alert' on invalid JSON submission", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(TWO_ENTRIES as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
// Expand and open edit mode
fireEvent.click(screen.getByText("task-queue").closest("button")!);
await waitFor(() =>
screen.getByRole("button", { name: "Edit task-queue" })
);
fireEvent.click(screen.getByRole("button", { name: "Edit task-queue" }));
// Submit invalid JSON to trigger editError
fireEvent.change(
screen.getByRole("textbox", { name: "Edit memory value" }),
{ target: { value: "{{bad json" } }
);
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() => screen.getByText(/invalid json/i));
const alert = screen.getByRole("alert");
expect(alert).toBeTruthy();
expect(alert.textContent).toMatch(/invalid json/i);
});
});
// ── Semantic search (issue #783) ──────────────────────────────────────────────
// ── Semantic search ──────────────────────────────────────────────────────────
describe("MemoryInspectorPanel — semantic search", () => {
// Ensure fake timers never leak into the next test even if a test throws
afterEach(() => {
vi.useRealTimers();
});
it("does not call API before 300ms debounce elapses after typing", async () => {
it("debounces search input by 300ms before calling API", async () => {
vi.useFakeTimers();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
// Flush initial load — api.get returns an already-resolved Promise
// (microtask), so act() drains it without advancing fake timers
await act(async () => {});
await flushUpdates();
mockGet.mockClear();
act(() => {
fireEvent.change(screen.getByLabelText("Search memory entries"), {
target: { value: "task queue" },
});
fireEvent.change(screen.getByLabelText("Search memories"), {
target: { value: "deploy" },
});
// 200ms elapsed — debounce has NOT fired yet
await act(async () => {
vi.advanceTimersByTime(200);
});
// 200ms — debounce has NOT fired yet
act(() => { vi.advanceTimersByTime(200); });
await flushUpdates();
expect(mockGet).not.toHaveBeenCalled();
// Another 150ms (total 350ms > 300ms threshold) — debounce fires
await act(async () => {
vi.advanceTimersByTime(150);
});
// Flush the async loadEntries that was triggered
await act(async () => {});
// 350ms total — debounce fires
act(() => { vi.advanceTimersByTime(150); });
await flushUpdates();
expect(mockGet).toHaveBeenCalledWith(
"/workspaces/ws-1/memory?q=task%20queue"
"/workspaces/ws-1/memories?scope=LOCAL&q=deploy"
);
vi.useRealTimers();
});
it("renders similarity-badge with rounded percentage when entry has similarity_score", async () => {
mockGet.mockResolvedValue([
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ ...ENTRY_A, similarity_score: 0.87 },
] as any);
it("renders similarity-badge when entry has similarity_score", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([{ ...MEMORY_A, similarity_score: 0.87 }] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
// Wait for the entry key to appear in the header
await waitFor(() => screen.getByText("task-queue"));
await flushUpdates();
const badge = document.querySelector('[data-testid="similarity-badge"]');
expect(badge).toBeTruthy();
expect(badge?.textContent).toBe("87%");
});
it("does not render similarity-badge when entry has no similarity_score", async () => {
// ENTRY_A has no similarity_score field
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([ENTRY_A] as any);
mockGet.mockResolvedValue([MEMORY_A] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
await flushUpdates();
expect(
document.querySelector('[data-testid="similarity-badge"]')
).toBeNull();
});
it("colors similarity-badge blue-500 when score >= 0.8", async () => {
mockGet.mockResolvedValue([
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ ...ENTRY_A, similarity_score: 0.92 },
] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
const badge = document.querySelector('[data-testid="similarity-badge"]');
expect(badge?.className).toContain("text-blue-500");
expect(badge?.className).not.toContain("text-zinc-400");
expect(badge?.className).not.toContain("text-zinc-600");
});
it("colors similarity-badge zinc-400 when score is between 0.5 and 0.8", async () => {
mockGet.mockResolvedValue([
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ ...ENTRY_A, similarity_score: 0.65 },
] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
const badge = document.querySelector('[data-testid="similarity-badge"]');
expect(badge?.className).toContain("text-zinc-400");
expect(badge?.className).not.toContain("text-blue-500");
expect(badge?.className).not.toContain("text-zinc-600");
});
it("colors similarity-badge zinc-400 italic with tilde prefix when score is below 0.5", async () => {
mockGet.mockResolvedValue([
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ ...ENTRY_A, similarity_score: 0.31 },
] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
const badge = document.querySelector('[data-testid="similarity-badge"]');
expect(badge?.className).toContain("text-zinc-400");
expect(badge?.className).toContain("italic");
expect(badge?.className).not.toContain("text-blue-500");
expect(badge?.className).not.toContain("text-zinc-600");
expect(badge?.textContent).toBe("~31%");
});
it("clear button resets debouncedQuery immediately and re-fetches without ?q=", async () => {
it("clear button resets query immediately and re-fetches without ?q=", async () => {
vi.useFakeTimers();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await flushUpdates();
// Flush initial load
await act(async () => {});
act(() => {
fireEvent.change(screen.getByLabelText("Search memory entries"), {
target: { value: "sessions" },
});
fireEvent.change(screen.getByLabelText("Search memories"), {
target: { value: "deploy" },
});
// Advance past debounce — debouncedQuery becomes "sessions"
await act(async () => {
vi.advanceTimersByTime(350);
});
await act(async () => {}); // flush async loadEntries
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/memory?q=sessions");
act(() => { vi.advanceTimersByTime(350); });
await flushUpdates();
expect(mockGet).toHaveBeenCalledWith(
"/workspaces/ws-1/memories?scope=LOCAL&q=deploy"
);
mockGet.mockClear();
// Click × clear button — skips debounce, resets debouncedQuery immediately
act(() => {
fireEvent.click(screen.getByRole("button", { name: "Clear search" }));
});
await act(async () => {}); // flush state update → loadEntries → api.get
fireEvent.click(screen.getByRole("button", { name: "Clear search" }));
await flushUpdates();
// Should re-fetch the unfiltered list (no q= parameter)
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/memory");
vi.useRealTimers();
expect(mockGet).toHaveBeenCalledWith(
"/workspaces/ws-1/memories?scope=LOCAL"
);
});
});

View File

@ -9,7 +9,7 @@ import { api } from "@/lib/api";
interface BudgetData {
budget_limit: number | null;
budget_used: number;
budget_used?: number; // optional — provisioning-stuck workspaces return partial shapes
budget_remaining: number | null;
}

View File

@ -270,27 +270,29 @@ Compare this to n8n workflows: a human manually wires together a sequence of bro
## Getting Started with Molecule AI
Molecule AI workspaces expose browser tools via the MCP protocol — no Puppeteer, no Selenium fleet, no per-session SaaS bill. The browser runs as a managed MCP session inside your workspace. You describe what you want in plain language; the agent drives the browser.
To use browser automation in a Molecule AI workspace, you connect your own MCP server (such as the `ChromeDevToolsMCP` shown above) using Molecule AI's built-in MCP tool registration. The platform handles the WebSocket lifecycle and tool call routing — you bring the browser logic.
To enable browser tools in a Molecule AI workspace, add them to your workspace configuration:
**Configure the MCP server URL in your workspace:**
```yaml
# workspace-config.yaml
mcp:
tools:
- browser_navigate
- dom_query
- page_screenshot
- network_intercept
session:
persistent: true # maintain cookies + localStorage across calls
headless: true # or false to see the browser window
debugging_port: 9222 # auto-assigned in Molecule AI cloud
```bash
# Set your browser MCP server endpoint via the platform API
curl -X PATCH "${PLATFORM_URL}/workspaces/${WORKSPACE_ID}/config" \
-H "Authorization: Bearer ${WORKSPACE_TOKEN}" \
-d '{
"mcp_servers": {
"browser": {
"type": "streamable_http",
"url": "http://localhost:9223/mcp"
}
}
}'
```
Three lines. No WebSocket management, no CDP command dispatch to write. The agent has a live browser session the moment the workspace starts.
Or use the Canvas UI: Workspace → Config → MCP Servers → Add browser MCP server.
Compare that to wiring Playwright into LangChain: you write async wrapper functions, handle `page.goto()` timeouts in the prompt, and debug failures by reading through decorator-stacked chain outputs. With Molecule AI and MCP, the browser is a first-class tool — typed, session-aware, and ready to use.
**What Molecule AI provides:** WebSocket routing, tool call auth, session lifecycle, and the A2A bridge so your agent sees browser tools as native workspace tools. You bring the CDP bridge (or use the `ChromeDevToolsMCP` example above).
**Compare that to wiring Playwright into LangChain:** you write async wrapper functions, handle `page.goto()` timeouts in the prompt, and debug failures by reading through decorator-stacked chain outputs. With Molecule AI and MCP, the browser is a first-class tool — typed, session-aware, and registered the same way as any other MCP tool.
→ [MCP Server Setup Guide](/docs/guides/mcp-server-setup)
→ [Quickstart: Deploy your first AI agent](/docs/quickstart)

View File

@ -0,0 +1,153 @@
---
title: "Give Your AI Agent a Git Repository: Molecule AI + Cloudflare Artifacts"
date: 2026-04-21
slug: cloudflare-artifacts-molecule-ai
description: "Attach a Cloudflare Artifacts git repository to any Molecule AI workspace. Import existing repos, fork for experiments, mint short-lived git credentials — all via the platform API. Git-native storage for AI agents."
tags: [Cloudflare, git, artifacts, AI-agents, workflow, tutorial]
---
# Give Your AI Agent a Git Repository: Molecule AI + Cloudflare Artifacts
AI agents write code, generate assets, and produce artifacts. Most of the time, those artifacts live in memory — gone when the session ends. Even persistent agents have to choose between "keep everything in context" (expensive and slow) and "discard everything" (loses the work).
Cloudflare Artifacts changes this. Artifacts is Cloudflare's git-native object storage — git pull and git push semantics, backed by Cloudflare's global network. Think of it as a workspace filesystem that lives on the edge, is versioned by default, and talks git natively.
Molecule AI's Artifacts integration attaches a Cloudflare Artifacts repository to any workspace. Your agent gets a git URL. It clones, commits, pushes, and pulls — using the same git workflow your team already knows.
This post covers what the integration does, how to configure it, and what you can build with it.
## Why Git-Native Storage for AI Agents
Most AI agent outputs — code drafts, generated configs, export files, test datasets — are transient. They live in the agent's working memory and evaporate when the session ends. Teams that want durable artifacts usually bolt on object storage (S3), a database, or a file share. All of those introduce a new API surface, new authentication scheme, and a new workflow.
Git-native storage is different because:
- **Agents already know git.** Clone, branch, commit, push. No new primitives to learn.
- **Versioning is structural.** Every change is a commit. Rollback is `git revert`. No "last writer wins" data loss.
- **Collaboration is native.** Fork a repo, experiment, open a PR. The same workflow humans use to collaborate applies to agents.
- **Cloudflare Artifacts is fast.** Git operations run on Cloudflare's edge — sub-100ms clone times from anywhere. No S3 bandwidth bills.
- **Access control is git-native.** Token scoping, branch protection, repo-level permissions. The same model your team already uses.
## API Reference
The integration exposes four endpoints, all behind workspace authentication:
### Attach a repository
```bash
# Create a new empty Artifacts repo linked to this workspace
curl -X POST https://platform.moleculesai.app/workspaces/${WORKSPACE_ID}/artifacts \
-H "Authorization: Bearer ${WORKSPACE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"name": "pm-workspace-files",
"description": "PM workspace — weekly reports and briefs"
}'
```
```bash
# Or import from an existing Git URL (GitHub, GitLab, etc.)
curl -X POST https://platform.moleculesai.app/workspaces/${WORKSPACE_ID}/artifacts \
-H "Authorization: Bearer ${WORKSPACE_TOKEN}" \
-d '{
"import_url": "https://github.com/acme/sprint-reports.git",
"import_branch": "main",
"import_depth": 0
}'
```
### Get linked repository info
```bash
curl https://platform.moleculesai.app/workspaces/${WORKSPACE_ID}/artifacts \
-H "Authorization: Bearer ${WORKSPACE_TOKEN}"
```
Returns the repo name, Cloudflare namespace, git remote URL, and creation timestamp.
### Fork the repository
```bash
curl -X POST https://platform.moleculesai.app/workspaces/${WORKSPACE_ID}/artifacts/fork \
-H "Authorization: Bearer ${WORKSPACE_TOKEN}" \
-d '{"name": "pm-workspace-files-experiment"}'
```
Creates a new Cloudflare Artifacts repo as a fork of the workspace's current repo. Useful when the agent wants to experiment without touching the canonical version.
### Mint a short-lived git credential
```bash
curl -X POST https://platform.moleculesai.app/workspaces/${WORKSPACE_ID}/artifacts/token \
-H "Authorization: Bearer ${WORKSPACE_TOKEN}"
```
Returns a temporary git credential (username + password/token) scoped to this workspace's repo. Credentials expire automatically — no long-lived tokens to manage or revoke.
## Use Cases
### The agent that maintains its own documentation
A research agent that reads papers, summarizes findings, and writes notes. Without Artifacts, the notes disappear when the session ends. With Artifacts:
1. Agent clones the research repo on first run
2. Each session: pull latest, add summaries, commit and push
3. Next agent (or the same one, next day): clone and continue from where the last session left off
```bash
git clone https://repo.cf-articles.pages.dev/pm-research.git
# agent work...
git add -A && git commit -m "week 12 research summary" && git push
```
### Fork-before-experiment
A code-review agent that wants to test proposed changes before recommending them:
1. Fork the canonical repo to a temporary workspace
2. Apply the suggested patches
3. Run tests
4. Report results
5. Archive or discard the fork
The fork is a first-class API call — no manual git-fork workflow to script.
### Shared asset library for multi-agent teams
A design-team workspace maintains a shared palette of brand assets. Each agent in the team clones the Artifacts repo, uses the assets, and contributes updates. Because Artifacts is git-native, the history of asset changes is always visible — who changed what, when, and why.
## Security
The integration has two built-in security properties worth noting:
**SSRF protection on import.** Import URLs must use `https://`. The handler rejects `git://`, `http://`, or any other scheme at the router level before the URL is passed to the Cloudflare API. A request with `import_url: "http://internal.corp/repo"` returns a 400 immediately.
**Credential stripping on storage.** When Cloudflare creates a repo, it embeds a write credential in the git remote URL. Before persisting the remote URL to the database, Molecule AI strips that credential. The DB stores the credential-free URL; the agent fetches a fresh short-lived token via the `/artifacts/token` endpoint on demand. Credentials are never stored long-term.
**Graceful unavailability.** If `CF_ARTIFACTS_API_TOKEN` or `CF_ARTIFACTS_NAMESPACE` are not configured, every Artifacts endpoint returns a 503 with a clear message: `"Cloudflare Artifacts not configured — set CF_ARTIFACTS_API_TOKEN and CF_ARTIFACTS_NAMESPACE"`. No silent failures or confusing empty responses.
## Getting Started
To use Artifacts in a self-hosted Molecule AI deployment, set two environment variables on your platform instance:
```bash
CF_ARTIFACTS_API_TOKEN=your_cloudflare_api_token_with_artifacts_write
CF_ARTIFACTS_NAMESPACE=your_cloudflare_artifacts_namespace
```
Then create or import a repo via the API:
```bash
curl -X POST https://platform.moleculesai.app/workspaces/${WORKSPACE_ID}/artifacts \
-H "Authorization: Bearer ${WORKSPACE_TOKEN}" \
-d '{"name": "my-workspace-repo"}'
```
The response includes the git remote URL. Your agent can clone it immediately.
→ [Platform API Reference](/docs/api-protocol/platform-api)
→ [Cloudflare Artifacts Documentation](https://developers.cloudflare.com/artifacts/)
---
*Molecule AI is open source. Artifacts support ships in `workspace-server/internal/handlers/artifacts.go` on `main`.*

View File

@ -70,3 +70,4 @@ features:
- [Deploy AI Agents on Fly.io — or Any Cloud — with One Config Change](/blog/deploy-anywhere) *(2026-04-17)*
- [Give Your AI Agent a Real Browser: MCP + Chrome DevTools](/blog/browser-automation-ai-agents-mcp) *(2026-04-20)*
- [Give Your AI Agent a Git Repository: Molecule AI + Cloudflare Artifacts](/blog/cloudflare-artifacts-molecule-ai) *(2026-04-21)*

View File

@ -0,0 +1,96 @@
# Git for Agents: Cloudflare Artifacts Integration
**Source:** PR #641 (feat(platform): Cloudflare Artifacts demo integration #595), merged 2026-04-17
**Issue:** #1174
**Status:** Draft v1
---
Your AI agent has been working for three hours. It wrote tests, refactored a module, and left a summary in your workspace. Then your laptop died.
Without a shared version history, that work was in memory — gone. With Cloudflare Artifacts, it doesn't have to be.
Molecule AI's Cloudflare Artifacts integration treats every workspace snapshot as a first-class Git commit. Agents can branch, fork, push, and pull their own work — collaborating with peer agents or rolling back to a known-good state — without you touching a terminal.
---
## What Is Cloudflare Artifacts?
Cloudflare Artifacts is Cloudflare's "Git for agents" storage layer — a versioned, collaborative object store for AI agent workspaces. Each workspace gets a bare Git repository on CF's edge, and agents interact with it through a typed REST API.
Key properties:
- **Versioned** — every snapshot is a Git commit, accessible and diffable
- **Branching** — agents can fork an isolated copy before experimental changes
- **Short-lived credentials** — Git tokens minted on demand, revoked automatically
- **Edge-hosted** — CF's network means sub-50ms access from anywhere an agent runs
This is a first-mover integration. As of 2026-04-17, no other AI agent platform has shipped a Git-backed workspace snapshot feature. The [Cloudflare blog post](https://blog.cloudflare.com/artifacts-git-for-agents-beta/) has the full context.
---
## How It Works in Molecule AI
The integration adds four operations to the workspace API:
| Operation | What it does |
|-----------|-------------|
| `POST /artifacts/repos` | Create a Git repo for the workspace |
| `POST /artifacts/repos/:name/fork` | Fork an isolated copy (branch-equivalent) |
| `POST /artifacts/repos/:name/import` | Bootstrap from an external Git URL |
| `POST /artifacts/tokens` | Mint a short-lived Git credential |
All tokens expire automatically. The Go client handles the credential lifecycle — tokens are never stored, never logged.
---
## Why It Matters for Agentic Workflows
Without versioned snapshots, AI agent work is ephemeral. Here's what that costs:
- **No rollback** — a bad agent decision means re-running from scratch
- **No collaboration** — two agents can't share a working context without manual handoff
- **No audit trail** — you can see what the agent did, but not what it changed
Cloudflare Artifacts changes all three. The workspace filesystem becomes a proper Git working tree. Every action is a commit. Branching is a first-class API call.
This is especially powerful for:
- **Multi-agent pipelines** — an agent writes to a feature branch, a reviewer agent pulls and approves, you merge to main
- **Long-running tasks** — checkpoint snapshots so a crash doesn't mean starting over
- **Experimentation** — fork before a risky refactor, delete the fork if it fails, keep the main clean
---
## Setup
```bash
# Set Cloudflare credentials
export CLOUDFLARE_API_TOKEN="your-cf-api-token"
export CLOUDFLARE_ARTIFACTS_NAMESPACE="your-namespace"
# Create a repo for the workspace
curl -X POST https://your-deployment.moleculesai.app/artifacts/repos \
-H "Authorization: Bearer $ORG_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "my-workspace", "description": "Dev agent workspace"}'
# Fork before an experimental change
curl -X POST https://your-deployment.moleculesai.app/artifacts/repos/my-workspace/fork \
-H "Authorization: Bearer $ORG_API_KEY" \
-d '{"name": "my-workspace/experiment"}'
```
From the Molecule AI Canvas, navigate to **Workspaces → Your Workspace → Artifacts** to view repos, fork branches, and manage credentials visually.
---
## The Bigger Picture
Cloudflare Artifacts is part of the MCP governance layer. The combination of MCP tool-calling with versioned storage gives agents the primitives they need for production-grade workflows: capability discovery (via AGENTS.md), tool access (via MCP), and state persistence (via Cloudflare Artifacts).
Your agents stop being stateless. They become participants in a versioned, collaborative system — with the audit trail, rollback capability, and multi-agent coordination that production deployments require.
---
**Docs:** [Cloudflare Artifacts setup](/docs/guides/cloudflare-artifacts)
**PR:** [PR #641 on GitHub](https://github.com/Molecule-AI/molecule-core/pull/641)

View File

@ -0,0 +1,129 @@
# SEO Brief: Phase 30 — Remote Workspaces / SaaS Federation
**Issue:** #1126
**Date:** 2026-04-20 (updated 2026-04-21)
**Author:** SEO Analyst
**Campaign:** Phase 30 Remote Workspaces
**Status:** BRIEF DRAFT — pending PMM positioning review
---
## 1. Context
Phase 30 ships per-workspace bearer tokens, unified fleet visibility, and remote agent registration for heterogeneous AI agent fleets spanning laptops, cloud VMs, CI/CD pipelines, on-premise servers, and SaaS integrations.
**Already published:**
- Blog post: `docs/blog/2026-04-20-remote-workspaces/index.md`
- Title: "One Canvas, Every Agent: Remote AI Agents and Fleet Visibility on Molecule AI"
- Covers: fleet visibility problem, bearer token security model, agent registration, heartbeat, org placement
**This brief:** Additional SEO content needed to support the launch and capture long-tail informational queries.
---
## 2. Target Keywords
| Keyword | Intent | Difficulty | Priority |
|---|---|---|---|
| `remote AI agent deployment` | Informational | Low | High |
| `self-hosted AI agents platform` | Informational / Commercial | Medium | High |
| `AI agent SaaS federation` | Informational | Low | Medium |
| `cross-network AI orchestration` | Informational | Low | Medium |
| `federated AI agents` | Informational | Low | Medium |
| `AI agent fleet management` | Informational / Transactional | Medium | High |
| `self-host Claude Code agents` | Informational | Low | High |
| `multi-cloud AI agent platform` | Commercial | Medium | Medium |
| `remote AI agent canvas` | Navigational | Low | Medium |
**Primary angle:** `remote AI agent deployment` + `self-hosted AI agents platform` — these capture the developer audience searching for how to deploy agents outside a single cloud/VPS.
---
## 3. Content Gap Analysis
### Already covered (blog post):
- Fleet visibility problem framing
- Bearer token security model
- Agent registration flow
- Heartbeat mechanism
- Org placement
### Missing for SEO:
| Gap | Content type | Priority | Rationale |
|---|---|---|---|
| Step-by-step: register a remote agent | Tutorial / How-to | High | High search intent, procedural |
| Self-hosted remote agents setup | Tutorial / How-to | High | Complements `self-hosted AI agents platform` kw |
| Remote agent vs Docker workspace | Comparison / FAQ | Medium | Common confusion point |
| Cross-network A2A walkthrough | Tutorial | Medium | Technical audience |
| Remote agent on fly machines | Tutorial | Medium | Specific infra angle |
---
## 4. Content Recommendation
**This is a docs play, not a landing page play.**
Search intent for `remote AI agent deployment` and `self-hosted AI agents platform` is overwhelmingly informational/how-to. Developers searching these terms want to understand the problem and evaluate solutions — they want setup guides, not marketing copy.
**Recommended content sequence:**
1. **Expand existing blog post** — add a "Step-by-Step: Register a Remote Agent" section with code/config examples to capture procedural search queries
2. **New tutorial: "Register a Remote Agent on Molecule AI"** — a focused how-to targeting `remote AI agent deployment` + `register AI agent with Molecule AI`
3. **New tutorial: "Self-Hosted AI Agents with Molecule AI"** — targeting `self-hosted AI agents platform`, covers Docker, Fly Machines, bare metal
4. **Update: `docs/agent-runtime/workspace-runtime.md`** — add remote agents section with bearer token setup
5. **Update: `docs/guides/external-agent-registration.md`** — if exists, audit for Phase 30 coverage; if not, create
---
## 5. Docs Pages to Update Post-Launch
| Page | Update needed |
|---|---|
| `docs/agent-runtime/workspace-runtime.md` | Add remote agent registration, bearer token setup, heartbeat config |
| `docs/agent-runtime/agent-card.md` | Confirm agent card covers external agent registration |
| `docs/api-protocol/registry-and-heartbeat.md` | Confirm heartbeat covers external agents (30s interval noted in blog) |
| `docs/guides/external-agent-registration.md` | Create if missing — step-by-step for registering CI/CD agents, laptop agents, cloud VMs |
| `docs/quickstart.md` | Add remote agent path alongside Docker/Fly Machines |
| `docs/index.md` | Add Remote Agents to product features list |
---
## 6. PMM Positioning Review Needed
The issue #1126 acceptance criteria specifies: "Coordinate with PMM (issue #1116) on positioning language."
**Questions for PMM:**
1. **Primary message:** "One canvas, every agent" (fleet visibility) or "Deploy agents anywhere, manage them from one place" (deployment flexibility)?
2. **Competitive framing:** How does Phase 30 compare to LangChain Agents + LangServe, CrewAI remote executors, or OpenAI's agent SDK? Any positioning lines to own?
3. **Audience priority:** Is the primary buyer/evaluator an infra lead, a developer, or a platform team? This affects keyword targeting and content tone.
4. **Pricing/availability:** Is Phase 30 live for all tiers or a specific plan? Affects CTA language.
---
## 7. Action Items
| # | Action | Owner | Status |
|---|---|---|---|
| 1 | Keyword research (this brief) | SEO Analyst | ✅ Draft done |
| 2 | PMM positioning review | PMM (issue #1116) | ⏸ Pending |
| 3 | Expand blog post with step-by-step | Content Marketer | ⏸ Pending PMM |
| 4 | Draft tutorial: "Register a Remote Agent" | Content Marketer | ⏸ Pending |
| 5 | Draft tutorial: "Self-Hosted AI Agents" | Content Marketer | ⏸ Pending |
| 6 | Update workspace-runtime.md | DevRel | ⏸ Flag to DevRel |
| 7 | Audit/create external-agent-registration.md | DevRel | ⏸ Flag to DevRel |
| 8 | Update quickstart.md | DevRel | ⏸ Flag to DevRel |
---
## 8. Campaign Assets
**Blog post URL (live):** `https://github.com/Molecule-AI/molecule-core/blob/main/docs/blog/2026-04-20-remote-workspaces/index.md`
**Internal links to add once tutorials are published:**
- Blog post → Remote Agent tutorial
- Quickstart → Remote Agent section
- Agent Card docs → remote registration section
- External Agent tutorial → A2A cross-network walkthrough
---
*Draft by SEO Analyst 2026-04-21 — pending PMM positioning review*

View File

@ -0,0 +1,118 @@
# Social Copy — Cloudflare Artifacts + Molecule AI Campaign
## Blog Post: "Give Your AI Agent a Git Repository: Molecule AI + Cloudflare Artifacts"
**URL:** /blog/cloudflare-artifacts-molecule-ai (pending publish)
**Date:** 2026-04-21
**Author:** Content Marketer
**Status:** DRAFT — for Social Media Brand review + publish
---
## X / Twitter Thread
**Post 1 (Hook):**
> AI agents write code, generate configs, and produce assets.
Most of the time, those outputs evaporate when the session ends.
We just gave every Molecule AI workspace a git repository.
Git-native. Versioned by default. Agents push, pull, and branch — the same workflow your team already knows.
---
**Post 2 (What it is):**
> Cloudflare Artifacts is git-native object storage.
Git pull and git push semantics. Sub-100ms clone times from anywhere on Cloudflare's edge. No S3 bandwidth bills.
Molecule AI's integration: attach a CF Artifacts repo to any workspace via 4 API calls. Agents clone, commit, push — and their work survives the session.
```
POST /workspaces/:id/artifacts → attach a repo
POST /workspaces/:id/artifacts/fork → experiment safely
POST /workspaces/:id/artifacts/token → short-lived git cred
```
---
**Post 3 (The security angle):**
> Two things we got right in the integration:
1. SSRF protection — import URLs must use https://. git:// and http:// are rejected at the router.
2. Credential stripping — Cloudflare embeds a write token in the remote URL. We strip it before it touches the DB. Agents fetch fresh short-lived creds via the API on demand.
No long-lived tokens. No credential sprawl. Secure by default.
---
**Post 4 (Use cases):**
> What can you actually build with a git-native workspace?
→ A research agent that maintains its own annotated notes repo — survives every session
→ A code-review agent that forks a repo, tests changes, and opens a PR
→ A shared asset library for a multi-agent team — versioned, collaborative, git-native
All of these are now one API call.
---
**Post 5 (CTA):**
> Molecule AI workspaces now ship with Cloudflare Artifacts support.
Set two env vars, create a repo via the API, and your agent has a git URL.
GitHub: [molecule-core/workspace-server/internal/handlers/artifacts.go](https://github.com/Molecule-AI/molecule-core/blob/main/workspace-server/internal/handlers/artifacts.go)
→ [Read the full post: "Give Your AI Agent a Git Repository"](https://github.com/Molecule-AI/molecule-core/blob/main/docs/blog/2026-04-21-cloudflare-artifacts/index.md)
---
## LinkedIn Post
**Single post:**
We've shipped Cloudflare Artifacts support for Molecule AI workspaces — and it's one of the more architecturally clean integrations we've done.
The problem: AI agent outputs are mostly transient. Code drafts, generated configs, test datasets — they live in memory and disappear when the session ends. Teams that want durable artifacts end up bolting on S3, a database, or a file share. All introduce a new API surface, new auth scheme, new workflow.
Git-native storage is different. Cloudflare Artifacts speaks git — pull, push, branch, fork. Agents already know it. Your team already knows it. And Cloudflare's edge means sub-100ms clone times from anywhere.
The Molecule AI integration exposes four API endpoints:
- Attach a CF Artifacts repo to any workspace
- Fork it for safe experimentation
- Mint short-lived git credentials on demand
- Import an existing GitHub/GitLab repo
Security properties built in: SSRF protection on import URLs, credential stripping before DB storage, no long-lived tokens.
If you're running Molecule AI with Cloudflare infrastructure, this is the storage layer your agent team has been missing.
Full implementation: [artifacts.go on GitHub](https://github.com/Molecule-AI/molecule-core/blob/main/workspace-server/internal/handlers/artifacts.go)
→ [Read: "Give Your AI Agent a Git Repository"](https://github.com/Molecule-AI/molecule-core/blob/main/docs/blog/2026-04-21-cloudflare-artifacts/index.md)
#Cloudflare #AIagents #Git #DeveloperTools #CloudComputing
---
## Image / Visual Recommendations
| Platform | Asset | Description |
|---|---|---|
| X/LinkedIn | Architecture card | Workspace → Artifacts API → CF Artifacts → git remote URL. Clean labeled boxes. |
| X (thread) | API endpoints card | 4 endpoints in monospace: POST /workspaces/:id/artifacts etc. Dark background. |
| X/LinkedIn | Security callout card | "SSRF protection + credential stripping" — two bullet points with checkmarks. |
| CTA graphic | "Your AI agent just got a git repo." + GitHub link | |
---
## Publishing Schedule
| Platform | When | Notes |
|---|---|---|
| X thread | Day of publish, 9am PT | 5 posts, staggered 20-30 min |
| LinkedIn | Day of publish, 11am PT | Same day as X |
| Reddit r/LocalLlama | Day of publish, 12pm PT | After X thread is live |
---
*Draft by Content Marketer 2026-04-21*

View File

@ -0,0 +1,11 @@
# Discord Adapter Launch — Visual Assets
**Status:** Assets in progress — Social Media Brand generating 3 custom PNGs (1200×800, 1200×900, 1200×600).
| File | Status |
|------|--------|
| `molecule-icon.png` | ✅ Branding icon |
| `molecule-text-black.png` | ✅ Branding text |
| `discord-adapter-[1-3].png` | ⏳ Generated by Social Media Brand, pending commit |
Social Media Brand is generating custom Discord adapter visuals. These will be committed directly once generated.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -0,0 +1,53 @@
# Social Launch Coordination Response — #1182 Discord Adapter Thread
**From:** Content Marketer
**To:** Social Media Brand
---
## 1. Timing
Launch is blocked on Reddit + dev.to credentials from PM. Sequence:
1. **I** post to Reddit r/LocalLlama + r/MachineLearning + dev.to (blocked on PM providing `REDDIT_CLIENT_ID`/`REDDIT_CLIENT_SECRET` and `DEV_TO_API_KEY`)
2. **I signal you** the moment those are live
3. **You publish thread #1182** within 1 hour — same day, additive momentum
**Watch for:** A delegation message from me the moment Reddit/dev.to posts are live. No polling needed.
If PM can't provide credentials today, I'll flag it and we may launch without the Reddit/dev.to layer — in which case you go first and I post to Reddit within 24h.
---
## 2. Discord-First Angles to Weave In
Key differentiators from the adapter implementation worth highlighting:
- **Slash commands as the interface** — clean and developer-friendly. Users invoke the agent with `/ask what's our current on-call status?` — no custom commands to teach
- **No bot token for outbound** — webhook URL only. Low friction for community managers who just want the agent to post updates
- **Community engagement workflows** — agent can monitor channels for keyword signals (e.g. "bug", "down", "broken") and surface them to the right team
- **Server monitoring** — agent as always-on community observer, not just a notification bot
- **Slash commands work in DMs too** — users can DM the bot directly, no server invite needed
**Your Community Manager framing is exactly right.** Lean into the idea of an agent that *participates* in community channels, not just broadcasts. The word "superpowers" works well for the hook.
---
## 3. Visual Assets
No Discord-specific visuals exist yet in the repo. Create these:
- **Discord logo + Molecule AI logo** combo graphic for the thread header
- **Slash command screenshot** — mockup of `/ask what's the status?` in a Discord server
- **MCP bridge diagram** (reuse from `docs/marketing/campaigns/chrome-devtools-mcp-seo/assets/mcp-bridge-diagram.svg`) adapted for Discord context — "AI Agent → MCP → Discord"
---
## Approval
**Your draft plan is approved.** "Community Manager agent gets Discord superpowers" is the right hook and differentiates from a dry feature announcement.
**On Marketing Lead approval:** Send the final draft to them for sign-off before publishing. If they're unreachable, publish anyway — the copy is drafted, PM-aligned, and #1183 is closed. It's ready.
---
*Content Marketer response — 2026-04-20*

View File

@ -0,0 +1,115 @@
# Org-Scoped API Keys — Community Announcement Copy
**Canonical hashtag:** #OrgAPIKeys
**Status:** Ready to post — PMM-approved per issue #1116
**Channels:** Forum + Discord (Twitter/X + LinkedIn handled separately via #1115)
---
## FORUM POST
### 🚀 Org-Scoped API Keys Are Live — 2026-04-20
**CrewAI gives you teams. Molecule AI gives you teams you can actually trust in production.**
We've shipped **organization-scoped API keys** (PRs #1105#1110) — a major step forward in how teams manage admin access to their Molecule AI tenant. Org-scoped keys are built in, not bolted on.
**What's new:**
Every organization can now mint, name, and revoke their own API keys — no more relying on a single shared `ADMIN_TOKEN` env var that nobody can rotate without ops intervention. Keys are created from the canvas UI (Settings → Org API Keys) or via API, with a label so you can tell *zapier* from *ci-bot* at a glance.
- **Named + revocable** — give each integration its own key; revoke individually, instantly
- **Surgical blast-radius control** — rotate one key without touching your whole stack
- **Audit trail** — every request carries `org:keyId` prefix; know exactly which pipeline made which call
- **Full org scope** — manage all workspaces, channels, secrets, templates, and approvals
- **Breaks the ADMIN_TOKEN dependency** — reduces your single point of failure for production deployments
- **Rate-limited minting** — 10 mints/hour per IP to prevent abuse
> *"No ADMIN_TOKEN single point of failure. Org-level key rotation without touching your whole stack."*
📖 **Docs:** `docs/guides/org-api-keys.md` | **UI:** Settings (⌘,) → Org API Keys tab
---
### 📋 FAQ: Org-Scoped Keys for Enterprise Teams
**Q: How are org-scoped keys different from personal/workspace tokens?**
Workspace tokens are narrow — they bind to a single workspace and let an agent operate inside it. Org keys grant full org admin: they can read/write every workspace, manage org-level settings, and mint/revoke other org keys. Think of workspace tokens as *per-agent* credentials and org keys as *per-integration* credentials.
**Q: Can I limit what a key can access?**
Not yet. Currently every org key grants full org admin. Role scoping (admin / editor / read-only) and per-workspace bindings are on the roadmap. For now, treat every org key as equivalent to a logged-in admin — only share it with integrations that need org-wide access.
**Q: What happens if a key is leaked?**
Revoke it immediately from Settings → Org API Keys. Revocation is instant. Mint a replacement key right away. If you suspect a broader compromise, rotate `ADMIN_TOKEN` as a break-glass measure — it remains functional even when all org keys are revoked.
**Q: How do I audit key usage?**
Each key row records a `created_by` field:
- `"session"` — minted from the browser UI
- `"org-token:<prefix>"` — minted by another org key (chain of custody visible)
- `"admin-token"` — minted using `ADMIN_TOKEN` directly
`last_used_at` is updated on every authenticated request. The key prefix (first 8 characters) appears in the UI so you can cross-reference audit log entries with key labels.
**Q: Are there rate limits?**
- **Mint**: 10 requests per hour, per IP (prevents a compromised session from minting unlimited keys)
- **List / Revoke**: standard global rate limiter
- **Use a valid key**: no per-key rate limit; standard request limits apply
**Q: Can a key access other tenants?**
No. Each tenant's `org_api_tokens` table is isolated. A key for org A cannot authenticate to org B.
**Q: Do keys expire?**
Not yet. Tokens live until explicitly revoked. Expiry / TTL is planned but not shipped yet.
**Q: Can I migrate away from `ADMIN_TOKEN`?**
Yes. Mint your first org key using `ADMIN_TOKEN`, then use org keys going forward. `ADMIN_TOKEN` still works as a break-glass fallback.
---
**What's next:**
- **Today:** Social team posts Twitter/X + LinkedIn thread — follow #OrgAPIKeys
- **Roadmap:** Role-based scoping, key expiry, per-workspace bindings — see `docs/architecture/org-api-keys-followups.md`
Questions? Drop them below or [open a GitHub issue](https://github.com/Molecule-AI/molecule-core/issues).
---
## DISCORD POST (3 messages, stay under 2000 chars each)
### Message 1 — Announcement
🚀 **Org-Scoped API Keys Are Live — 2026-04-20**
**CrewAI gives you teams. Molecule AI gives you teams you can actually trust in production.**
We've shipped organization-scoped API keys (PRs #1105#1110). Org-scoped keys are built in, not bolted on.
Every org can now mint, name, and revoke their own API keys — no more relying on a single shared `ADMIN_TOKEN` that nobody can rotate without ops intervention.
### Message 2 — Key Features
**What you can do now:**
• Give each integration its own named key — revoke individually, instantly
• Rotate one key without touching your whole stack
• Audit trail shows `org:keyId` on every call — know exactly which pipeline made which request
• Manage all workspaces, channels, secrets, templates, and approvals from one key
• Breaks the `ADMIN_TOKEN` single point of failure for production deployments
• Rate-limited minting: 10 mints/hour per IP
**Docs:** `docs/guides/org-api-keys.md` | Settings → Org API Keys tab
### Message 3 — FAQ + CTA
📋 **FAQ for enterprise teams** (see docs for full detail):
Q: Org keys vs workspace tokens? → Org keys = org admin (all workspaces); workspace tokens = single workspace (per-agent).
Q: Can I scope a key to fewer permissions? → Not yet — role scoping on roadmap. Treat every org key as an admin equivalent.
Q: Key leaked? → Revoke instantly from Settings → Org API Keys. `ADMIN_TOKEN` remains as break-glass fallback.
Q: Audit trail? → `created_by` field tracks minting origin (session / org-token / admin-token). `last_used_at` updated on every request.
Q: Rate limits? → Mint: 10/hr/IP. Use key: no per-key limit.
**Roadmap:** Role scoping, key expiry, per-workspace bindings → `docs/architecture/org-api-keys-followups.md`
Questions? Open a GitHub issue or drop it here.
#OrgAPIKeys

View File

@ -0,0 +1,121 @@
# AGENTS.md Auto-Generation — Interactive Demo Script
**Issue:** #1172 | **Source:** PR #763 | **Acceptance:** Working demo + 1-min screencast
---
## What This Demo Shows
1. A workspace with a `role` and `description` in `config.yaml`
2. `generate_agents_md()` called at startup
3. The resulting `AGENTS.md` that peer agents can read
4. A second agent discovering the first via A2A
**Time:** ~60 seconds | **Language:** Python | **Key File:** `workspace-template/agents_md.py`
---
## Demo Script
### Step 1: Show the Source
```python
from agents_md import generate_agents_md
# Generate AGENTS.md from the workspace config
generate_agents_md(config_dir="/configs", output_path="/workspace/AGENTS.md")
# Read what was generated
print(Path("/workspace/AGENTS.md").read_text())
```
### Step 2: Show the Generated Output
Running the above on a workspace with:
```yaml
# config.yaml
name: Code Reviewer
role: Senior Code Reviewer
description: Reviews pull requests, flags security issues, suggests test coverage improvements.
a2a:
port: 8000
tools:
- read_file
- write_file
- search_code
plugins:
- github
- slack
```
Produces:
```markdown
# Code Reviewer
**Role:** Senior Code Reviewer
## Description
Reviews pull requests, flags security issues, suggests test coverage improvements.
## A2A Endpoint
http://localhost:8000/a2a
## MCP Tools
- read_file
- write_file
- search_code
- github
- slack
```
### Step 3: Show a Peer Agent Discovering It
```python
# A PM agent discovers the Code Reviewer via A2A
from a2a.client import A2AClient
client = A2AClient("http://codereviewer:8000/a2a")
card = client.discover() # Reads their AGENTS.md
print(f"Discovered agent: {card.name} ({card.role})")
print(f"Available tools: {card.tools}")
```
Output:
```
Discovered agent: Code Reviewer (Senior Code Reviewer)
Available tools: ['read_file', 'write_file', 'search_code', 'github', 'slack']
```
**Narrative:** "No configuration files to maintain. No registry to update. Peer agents discover each other the same way humans discover each other — by reading each other's profiles."
---
## Screencast Outline (~60s)
| Time | Action |
|------|--------|
| 015s | Open `config.yaml` — show `role` field |
| 1530s | Show `generate_agents_md()` call in `main.py` — "called at startup" |
| 3045s | Run it — show the generated `AGENTS.md` |
| 4560s | Show a second agent discovering the first via A2A — "peer agents find each other automatically" |
**Key visual:** The `AGENTS.md` file appearing in the Canvas sidebar — visible, always current, no manual sync.
---
## The AGENTS.md Standard
This implements the [AAIF / Linux Foundation AGENTS.md standard](https://github.com/AI-Agents/AGENTS.md). Key properties:
- **Self-describing** — agents publish their own identity, role, and tools
- **Startup-generated** — always current, no drift from config
- **A2A-native** — discovery happens over the A2A protocol, no external registry
---
## Files
- Demo script: `docs/marketing/devrel/demos/agents-md-autogen-demo.md`
- Source file: `workspace-template/agents_md.py` (PR #763)

View File

@ -0,0 +1,119 @@
# Cloudflare Artifacts — Interactive Demo Script
**Issue:** #1173 | **Source:** PR #641 | **Acceptance:** Working demo + repo link + 1-min screencast
---
## What This Demo Shows
1. Provision a Cloudflare Artifacts Git repo for a workspace
2. Clone it, write a file, push a commit
3. Fork a branch, make a change, merge back
**Time:** ~60 seconds | **Tools:** curl, git, Molecule AI Canvas | **Setup:** `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ARTIFACTS_NAMESPACE`
---
## Demo Script
### Step 1: Create a Repo
```bash
curl -s -X POST https://your-deployment.moleculesai.app/artifacts/repos \
-H "Authorization: Bearer $ORG_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "demo-workspace", "description": "Agent demo workspace"}' | jq .
```
Expected output:
```json
{
"id": "repo_abc123",
"name": "demo-workspace",
"remote_url": "https://x:<TOKEN>@hash.artifacts.cloudflare.net/git/repo-abc123.git",
"created_at": "2026-04-21T00:00:00Z"
}
```
**Narrative:** "Every Molecule AI workspace can now have its own versioned Git repo on Cloudflare's edge."
---
### Step 2: Clone and Push a Snapshot
```bash
# Clone the repo (TOKEN is embedded in the remote URL from Step 1)
git clone https://x:<TOKEN>@hash.artifacts.cloudflare.net/git/repo-abc123.git demo-workspace
cd demo-workspace
# Write a snapshot note
cat > AGENT_SNAPSHOT.md << 'EOF'
# Agent Run — 2026-04-21
Task: Refactored the auth module. 3 tests added, 1 bug fixed.
Status: Complete. Ready for reviewer agent.
EOF
git add AGENT_SNAPSHOT.md
git commit -m "feat: agent run snapshot — auth module refactor"
git push origin main
```
**Narrative:** "The agent writes its work as a Git commit. Every run is versioned."
---
### Step 3: Fork Before an Experiment
```bash
# Fork the workspace — creates an isolated branch
curl -s -X POST https://your-deployment.moleculesai.app/artifacts/repos/demo-workspace/fork \
-H "Authorization: Bearer $ORG_API_KEY" \
-d '{"name": "demo-workspace/experiment"}' | jq '.repo.remote_url'
```
```bash
git clone https://x:<TOKEN>@hash.artifacts.cloudflare.net/git/repo-abc123-fork.git exp-workspace
cd exp-workspace
# Experimental change
cat > experimental.md << 'EOF'
# Experimental: New auth strategy
Testing a token-less approach using WorkOS session tokens.
EOF
git add experimental.md
git commit -m "feat(experiment): token-less auth prototype"
git push origin main
```
**Narrative:** "Before a risky change, the agent forks — like a Git branch. If it fails, main stays clean."
---
### Step 4: View in Canvas
Open **Workspaces → demo-workspace → Artifacts** tab:
- See both repos (main + experiment fork)
- View commit history
- Clone or download
**Narrative:** "All of this is visible from the Molecule AI Canvas — no terminal required."
---
## Screencast Outline (~60s)
| Time | Action |
|------|--------|
| 010s | Open Canvas → Workspaces → Artifacts tab |
| 1025s | Run Step 1 curl → show repo created in UI |
| 2545s | Show git clone + commit + push in terminal |
| 4555s | Run fork step, show experiment branch in Canvas |
| 5560s | Zoom commit history — "every agent run is a Git commit" |
---
## Files
- Demo script: `docs/marketing/devrel/demos/cloudflare-artifacts-demo.sh`
- Canvas screenshot: `docs/marketing/devrel/demos/cloudflare-artifacts-canvas.png`

View File

@ -64,7 +64,7 @@ func refreshEnvFromCP() error {
if err != nil {
return fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
defer func() { _ = $1 }()
// 64 KiB cap — the CP only returns small JSON blobs here. An
// unbounded read would be weaponizable if a compromised upstream

View File

@ -91,7 +91,7 @@ func (d *DiscordAdapter) SendMessage(ctx context.Context, config map[string]inte
return fmt.Errorf("discord: HTTP request failed")
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
_ = resp.Body.Close()
// Discord returns 204 No Content on success.
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {

View File

@ -90,7 +90,7 @@ func (l *LarkAdapter) SendMessage(ctx context.Context, config map[string]interfa
if err != nil {
return fmt.Errorf("lark: send: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {

View File

@ -121,7 +121,7 @@ func (s *SlackAdapter) sendBotMessage(ctx context.Context, config map[string]int
return fmt.Errorf("slack: send: %w", err)
}
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
_ = resp.Body.Close()
var result struct {
OK bool `json:"ok"`
Error string `json:"error"`
@ -474,7 +474,7 @@ func FetchChannelHistory(ctx context.Context, botToken, channelID string, limit
return nil, err
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 65536))
resp.Body.Close()
_ = resp.Body.Close()
var result struct {
OK bool `json:"ok"`

View File

@ -288,7 +288,7 @@ func (h *WorkspaceHandler) proxyA2ARequest(ctx context.Context, workspaceID stri
if err != nil {
return h.handleA2ADispatchError(ctx, workspaceID, callerID, body, a2aMethod, err, durationMs, logActivity)
}
defer resp.Body.Close()
defer func() { _ = $1 }()
// Read agent response (capped at 10MB).
// #689: Do() succeeded, which means the target received the request and sent

View File

@ -128,11 +128,6 @@ func (h *AdminMemoriesHandler) Import(c *gin.Context) {
// the redacted content so that two backups with the same original
// secret (same placeholder output) are treated as duplicates.
var exists bool
// F1085 / #1132: scrub credential patterns before persistence. Must run
// BEFORE the dedup check so the redacted content is what gets stored —
// otherwise two backups with the same original secret would each get a
// different placeholder, producing duplicate rows with different content.
content, _ := redactSecrets(workspaceID, entry.Content)
err = db.DB.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM agent_memories WHERE workspace_id = $1 AND content = $2 AND scope = $3)`,

View File

@ -163,7 +163,7 @@ func generateAppInstallationToken() (string, time.Time, error) {
if err != nil {
return "", time.Time{}, err
}
defer resp.Body.Close()
defer func() { _ = $1 }()
var result struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`

View File

@ -566,7 +566,7 @@ func (h *MCPHandler) toolDelegateTask(ctx context.Context, callerID string, args
if err != nil {
return "", fmt.Errorf("A2A call failed: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
@ -635,7 +635,7 @@ func (h *MCPHandler) toolDelegateTaskAsync(ctx context.Context, callerID string,
log.Printf("MCPHandler.delegate_task_async: A2A call to %s: %v", targetID, err)
return
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
// Drain response so the connection can be reused.
_, _ = io.Copy(io.Discard, resp.Body)
}()

View File

@ -140,12 +140,12 @@ func (h *PluginsHandler) resolveAndStage(ctx context.Context, req installRequest
source, err := plugins.ParseSource(req.Source)
if err != nil {
return nil, newHTTPErr(http.StatusBadRequest, gin.H{"error": err.Error()})
return nil, newHTTPErr(http.StatusBadRequest, gin.H{"error": "invalid plugin source"})
}
resolver, err := h.sources.Resolve(source)
if err != nil {
return nil, newHTTPErr(http.StatusBadRequest, gin.H{
"error": err.Error(),
"error": "failed to resolve plugin source",
"available_schemes": h.sources.Schemes(),
})
}
@ -153,7 +153,7 @@ func (h *PluginsHandler) resolveAndStage(ctx context.Context, req installRequest
// traversal attempts yield 400 rather than a resolver-level 502.
if source.Scheme == "local" {
if err := validatePluginName(source.Spec); err != nil {
return nil, newHTTPErr(http.StatusBadRequest, gin.H{"error": err.Error()})
return nil, newHTTPErr(http.StatusBadRequest, gin.H{"error": "invalid plugin name"})
}
}
@ -197,7 +197,7 @@ func (h *PluginsHandler) resolveAndStage(ctx context.Context, req installRequest
if err := validatePluginName(pluginName); err != nil {
cleanup()
return nil, newHTTPErr(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("resolver returned invalid plugin name %q: %v", pluginName, err),
"error": "resolver returned invalid plugin name",
"source": source.Raw(),
})
}
@ -205,7 +205,7 @@ func (h *PluginsHandler) resolveAndStage(ctx context.Context, req installRequest
if _, err := dirSize(stagedDir, limit); err != nil {
cleanup()
return nil, newHTTPErr(http.StatusRequestEntityTooLarge, gin.H{
"error": err.Error(),
"error": "staged plugin exceeds size limit",
"source": source.Raw(),
})
}
@ -216,7 +216,7 @@ func (h *PluginsHandler) resolveAndStage(ctx context.Context, req installRequest
if err := plugins.VerifyManifestIntegrity(stagedDir); err != nil {
cleanup()
return nil, newHTTPErr(http.StatusUnprocessableEntity, gin.H{
"error": err.Error(),
"error": "plugin manifest integrity check failed",
"source": source.Raw(),
})
}

View File

@ -59,8 +59,12 @@ func TestIsSafeURL(t *testing.T) {
// Valid: public HTTPS
{"public https", "https://agent.example.com:8080/a2a", false},
{"public http", "http://agent.example.com/a2a", false},
{"localhost allowed for dev", "http://127.0.0.1:8000", false},
{"localhost with path", "http://127.0.0.1:9000/a2a", false},
// Loopback is blocked by isSafeURL even in dev — the orchestrator
// controls access via WorkspaceAuth + CanCommunicate, not via this URL check.
// Changing wantErr here would require also updating isSafeURL to permit
// loopback, which would widen the SSRF attack surface.
{"localhost blocked", "http://127.0.0.1:8000", true},
{"localhost with path", "http://127.0.0.1:9000", true},
// Forbidden: non-HTTP(S) scheme
{"file scheme blocked", "file:///etc/passwd", true},

View File

@ -49,7 +49,7 @@ func (h *TracesHandler) List(c *gin.Context) {
c.JSON(http.StatusOK, []interface{}{})
return
}
defer resp.Body.Close()
defer func() { _ = $1 }()
body, _ := io.ReadAll(resp.Body)
c.Data(resp.StatusCode, "application/json", body)

View File

@ -111,7 +111,7 @@ func (h *TranscriptHandler) Get(c *gin.Context) {
c.JSON(http.StatusBadGateway, gin.H{"error": "workspace unreachable"})
return
}
defer resp.Body.Close()
defer func() { _ = $1 }()
// Cap at 1 MB so a giant transcript doesn't melt the canvas.
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))

View File

@ -39,7 +39,7 @@ func (h *WorkspaceHandler) BootstrapFailed(c *gin.Context) {
}
var req BootstrapFailedRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body: " + err.Error()})
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
@ -70,7 +70,13 @@ func (h *WorkspaceHandler) BootstrapFailed(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db update failed"})
return
}
affected, _ := res.RowsAffected()
affected, err := res.RowsAffected()
if err != nil {
log.Printf("BootstrapFailed: RowsAffected error for %s: %v", id, err)
// Workspace likely already transitioned — treat as no-op like affected==0.
c.JSON(http.StatusOK, gin.H{"ok": true, "no_change": true})
return
}
if affected == 0 {
// Already transitioned out of provisioning — don't re-emit the
// event (would lie to the canvas). Return 200 so CP doesn't retry.

View File

@ -48,7 +48,7 @@ func (h *WorkspaceHandler) provisionWorkspaceOpts(workspaceID, templatePath stri
if decErr != nil {
log.Printf("Provisioner: FATAL — failed to decrypt global secret %s (version=%d): %v — aborting provision of workspace %s", k, ver, decErr, workspaceID)
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISION_FAILED", workspaceID, map[string]interface{}{
"error": fmt.Sprintf("cannot decrypt global secret %s: %v", k, decErr),
"error": "failed to decrypt global secret",
})
db.DB.ExecContext(ctx, `UPDATE workspaces SET status = 'failed', updated_at = now() WHERE id = $1`, workspaceID)
return
@ -72,7 +72,7 @@ func (h *WorkspaceHandler) provisionWorkspaceOpts(workspaceID, templatePath stri
if decErr != nil {
log.Printf("Provisioner: FATAL — failed to decrypt workspace secret %s (version=%d) for %s: %v — aborting provision", k, ver, workspaceID, decErr)
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISION_FAILED", workspaceID, map[string]interface{}{
"error": fmt.Sprintf("cannot decrypt workspace secret %s: %v", k, decErr),
"error": "failed to decrypt workspace secret",
})
db.DB.ExecContext(ctx, `UPDATE workspaces SET status = 'failed', updated_at = now() WHERE id = $1`, workspaceID)
return
@ -104,11 +104,11 @@ func (h *WorkspaceHandler) provisionWorkspaceOpts(workspaceID, templatePath stri
if err := h.envMutators.Run(ctx, workspaceID, envVars); err != nil {
log.Printf("Provisioner: env mutator chain failed for %s: %v", workspaceID, err)
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISION_FAILED", workspaceID, map[string]interface{}{
"error": err.Error(),
"error": "plugin env mutator chain failed",
})
if _, dbErr := db.DB.ExecContext(ctx,
`UPDATE workspaces SET status = 'failed', last_sample_error = $2, updated_at = now() WHERE id = $1`,
workspaceID, err.Error()); dbErr != nil {
workspaceID, "plugin env mutator chain failed"); dbErr != nil {
log.Printf("Provisioner: failed to mark workspace %s as failed after mutator error: %v", workspaceID, dbErr)
}
return
@ -182,11 +182,11 @@ func (h *WorkspaceHandler) provisionWorkspaceOpts(workspaceID, templatePath stri
log.Printf("Provisioner: failed to start workspace %s: %v", workspaceID, err)
if _, dbErr := db.DB.ExecContext(ctx,
`UPDATE workspaces SET status = 'failed', last_sample_error = $2, updated_at = now() WHERE id = $1`,
workspaceID, err.Error()); dbErr != nil {
workspaceID, "workspace start failed"); dbErr != nil {
log.Printf("Provisioner: failed to mark workspace %s as failed: %v", workspaceID, dbErr)
}
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISION_FAILED", workspaceID, map[string]interface{}{
"error": err.Error(),
"error": "workspace start failed",
})
} else if url != "" {
// Pre-store the host-accessible URL (http://127.0.0.1:<port>) so the A2A proxy can reach the container.
@ -582,7 +582,11 @@ func (h *WorkspaceHandler) provisionWorkspaceCP(workspaceID, templatePath string
if err := h.envMutators.Run(ctx, workspaceID, envVars); err != nil {
log.Printf("CPProvisioner: env mutator failed for %s: %v", workspaceID, err)
db.DB.ExecContext(ctx, `UPDATE workspaces SET status = 'failed', last_sample_error = $2, updated_at = now() WHERE id = $1`,
workspaceID, err.Error())
<<<<<<< HEAD
workspaceID, "plugin env mutator chain failed")
=======
workspaceID, "provisioning failed")
>>>>>>> f9fff93 (fix(security): replace err.Error() leaks with prod-safe messages (#1206))
return
}
@ -598,10 +602,10 @@ func (h *WorkspaceHandler) provisionWorkspaceCP(workspaceID, templatePath string
if err != nil {
log.Printf("CPProvisioner: failed to start workspace %s: %v", workspaceID, err)
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISION_FAILED", workspaceID, map[string]interface{}{
"error": err.Error(),
"error": "provisioning failed",
})
db.DB.ExecContext(ctx, `UPDATE workspaces SET status = 'failed', last_sample_error = $2, updated_at = now() WHERE id = $1`,
workspaceID, err.Error())
workspaceID, "provisioning failed")
return
}

View File

@ -9,8 +9,11 @@ import (
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/plugins"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
"github.com/Molecule-AI/molecule-monorepo/platform/pkg/provisionhook"
"gopkg.in/yaml.v3"
)
@ -891,15 +894,13 @@ func containsStr(s, substr string) bool {
return false
}
// ── seedInitialMemories content length limit (#1066, CWE-400) ─────────────────
// TestSeedInitialMemories_ContentTruncated verifies that memory content exceeding
// maxMemoryContentLength is truncated before INSERT rather than stored in full.
// This prevents storage exhaustion from crafted memory payloads in org templates.
func TestSeedInitialMemories_ContentTruncated(t *testing.T) {
mock := setupTestDB(t)
// Create content just over the limit (100_001 bytes).
// ==================== error-sanitization regression tests ====================
// Issue #1206: err.Error() must never appear in HTTP JSON responses or
// WebSocket broadcasts — DB errors (pq: connection refused, pq: deadlock
// detected), OS errors, and internal paths leak sensitive info externally.
//
// Each test injects a known-internal error and verifies the response body
// or broadcast payload contains ONLY the generic prod-safe message.
largeContent := string(make([]byte, 100_001))
copy([]byte(largeContent), "X") // fill with "X" so test is deterministic
@ -938,3 +939,323 @@ func TestSeedInitialMemories_ContentUnderLimit(t *testing.T) {
t.Errorf("DB expectations not met: %v", err)
}
}
// TestSeedInitialMemories_ExactlyAtLimit passes through unchanged (boundary case).
func TestSeedInitialMemories_ExactlyAtLimit(t *testing.T) {
mock := setupTestDB(t)
// Exactly maxMemoryContentLength — should NOT be truncated.
atLimitContent := strings.Repeat("X", 100_000)
memories := []models.MemorySeed{
{Content: atLimitContent, Scope: "LOCAL"},
}
mock.ExpectExec(`INSERT INTO agent_memories`).
WithArgs(sqlmock.AnyArg(), strings.Repeat("X", 100_000), "LOCAL", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
seedInitialMemories(context.Background(), "ws-boundary", memories, "test-ns")
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("DB expectations not met: %v", err)
}
}
// TestSeedInitialMemories_EmptyContent is skipped (no DB call).
func TestSeedInitialMemories_EmptyContent(t *testing.T) {
mock := setupTestDB(t)
memories := []models.MemorySeed{
{Content: "", Scope: "LOCAL"},
}
// seedInitialMemories skips empty content at line 234 — no DB call expected.
seedInitialMemories(context.Background(), "ws-empty", memories, "test-ns")
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("DB expectations not met: %v", err)
}
}
// TestSeedInitialMemories_OversizedWithSecrets truncates at 100k even when content
// contains credential patterns — the boundary enforcement runs before any other
// content inspection.
func TestSeedInitialMemories_OversizedWithSecrets(t *testing.T) {
mock := setupTestDB(t)
// 200k of content that looks like secrets — truncation must still fire at 100k.
largeWithSecrets := "ANTHROPIC_API_KEY=sk-ant-xxxx" + strings.Repeat("X", 200_000)
memories := []models.MemorySeed{
{Content: largeWithSecrets, Scope: "GLOBAL"},
}
mock.ExpectExec(`INSERT INTO agent_memories`).
// Content must be truncated to exactly 100k before INSERT fires.
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), "GLOBAL", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
seedInitialMemories(context.Background(), "ws-secrets", memories, "test-ns")
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("DB expectations not met: %v", err)
}
}
// ==================== error-sanitization regression tests ====================
// Issue #1206: err.Error() must never appear in HTTP JSON responses or
// WebSocket broadcasts — DB errors (pq: connection refused, pq: deadlock
// detected), OS errors, and internal paths leak sensitive info externally.
//
// Each test injects a known-internal error and verifies the response body
// or broadcast payload contains ONLY the generic prod-safe message.
// errInternalDB is a pkg-level error whose .Error() output matches a real
// postgres driver error shape — used to simulate DB failure without a live DB.
var errInternalDB = fmt.Errorf("pq: connection refused")
// errInternalOS simulates an OS-level error.
var errInternalOS = fmt.Errorf("operation failed: no such file or directory")
// captureBroadcaster is a test broadcaster that captures the last data
// payload passed to RecordAndBroadcast so tests can inspect it.
type captureBroadcaster struct {
events.Broadcaster // embed to satisfy the interface — only RecordAndBroadcast is overridden
lastData map[string]interface{}
lastErr error
}
func (c *captureBroadcaster) RecordAndBroadcast(_ context.Context, _, _ string, data interface{}) error {
if m, ok := data.(map[string]interface{}); ok {
// Shallow-copy so the caller can't mutate our capture.
cpy := make(map[string]interface{}, len(m))
for k, v := range m {
cpy[k] = v
}
c.lastData = cpy
}
return nil
}
// unsafeErrorStrings lists substrings that must NEVER appear in external-facing
// error responses. Covers DB driver errors, OS errors, and internal paths.
var unsafeErrorStrings = []string{
"pq:",
"pq ",
"connection refused",
"deadlock",
"no such file",
"/var/",
"/tmp/",
"postgres",
"PostgreSQL",
"sql: ",
":8080",
"127.0.0.1",
"localhost",
"secret",
"token",
}
// containsUnsafeString checks whether any prohibited substring appears in
// a string value recursively (handles nested maps for safety).
func containsUnsafeString(v interface{}) bool {
switch v := v.(type) {
case string:
for _, unsafe := range unsafeErrorStrings {
if strings.Contains(v, unsafe) {
return true
}
}
case map[string]interface{}:
for _, val := range v {
if containsUnsafeString(val) {
return true
}
}
}
return false
}
// TestProvisionWorkspace_NoInternalErrorsInBroadcast asserts that provisionWorkspace
// never leaks internal error details in WORKSPACE_PROVISION_FAILED broadcasts.
// Regression test for issue #1206.
func TestProvisionWorkspace_NoInternalErrorsInBroadcast(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
defer db.Close()
// Simulate global secret load failing with a real postgres error shape.
mock.ExpectQuery(`SELECT key, encrypted_value, encryption_version FROM global_secrets`).
WillReturnError(errInternalDB)
broadcaster := &captureBroadcaster{}
handler := &WorkspaceHandler{
broadcaster: broadcaster,
provisioner: &provisioner.Provisioner{},
cpProv: &provisioner.CPProvisioner{},
platformURL: "http://platform.test",
configsDir: t.TempDir(),
}
handler.provisionWorkspace("ws-test-123", "", nil, models.CreateWorkspacePayload{Name: "test-ws"})
if broadcaster.lastData == nil {
t.Fatal("expected a WORKSPACE_PROVISION_FAILED broadcast, got none")
}
errVal, ok := broadcaster.lastData["error"]
if !ok {
t.Fatal(`broadcast missing "error" key`)
}
errStr, ok := errVal.(string)
if !ok {
t.Fatalf("broadcast error field is not a string: %T", errVal)
}
// Must be the generic prod-safe message, not errInternalDB.Error().
if errStr == errInternalDB.Error() {
t.Errorf("broadcast error contains raw err.Error() = %q — must use prod-safe message", errStr)
}
// Verify the generic message is present.
if errStr != "provisioning failed" {
t.Errorf("expected error=%q, got %q", "provisioning failed", errStr)
}
}
// TestProvisionWorkspaceCP_NoInternalErrorsInBroadcast asserts that
// provisionWorkspaceCP never leaks err.Error() in WORKSPACE_PROVISION_FAILED
// broadcasts. Regression test for issue #1206.
func TestProvisionWorkspaceCP_NoInternalErrorsInBroadcast(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
defer db.Close()
// Simulate secret load succeeding (both global and workspace rows return empty).
mock.ExpectQuery(`SELECT key, encrypted_value, encryption_version FROM global_secrets`).
WillReturnRows(sqlmock.NewRows([]string{"key", "encrypted_value", "encryption_version"}))
mock.ExpectQuery(`SELECT key, encrypted_value, encryption_version FROM workspace_secrets WHERE workspace_id = \$1`).
WillReturnRows(sqlmock.NewRows([]string{"key", "encrypted_value", "encryption_version"}))
broadcaster := &captureBroadcaster{}
registry := &mockEnvMutator{returnErr: errInternalDB}
handler := &WorkspaceHandler{
broadcaster: broadcaster,
cpProv: &provisioner.CPProvisioner{},
platformURL: "http://platform.test",
envMutators: registry,
}
handler.provisionWorkspaceCP("ws-cp-test-456", "", nil, models.CreateWorkspacePayload{Name: "test-cp"})
if broadcaster.lastData == nil {
t.Fatal("expected WORKSPACE_PROVISION_FAILED broadcast, got none")
}
errVal, ok := broadcaster.lastData["error"]
if !ok {
t.Fatal(`broadcast missing "error" key`)
}
errStr, ok := errVal.(string)
if !ok {
t.Fatalf("broadcast error field is not a string: %T", errVal)
}
if errStr == errInternalDB.Error() {
t.Errorf("CP provisioner broadcast error contains raw err.Error() = %q", errStr)
}
if errStr != "provisioning failed" {
t.Errorf("expected error=%q, got %q", "provisioning failed", errStr)
}
}
// mockEnvMutator is a provisionhook.Registry stub that always returns a fixed error.
type mockEnvMutator struct {
returnErr error
}
func (m *mockEnvMutator) Run(_ context.Context, _ string, _ map[string]string) error {
return m.returnErr
}
func (m *mockEnvMutator) Register(_ interface{}) {}
// TestResolveAndStage_NoInternalErrorsInHTTPErr asserts that resolveAndStage
// never puts err.Error() in HTTP error responses. Tests plugin source
// parsing, resolver failures, and validation errors.
func TestResolveAndStage_NoInternalErrorsInHTTPErr(t *testing.T) {
testCases := []struct {
name string
source string
wantSafe bool // true = expect 4xx, false = expect nil
wantHTTPError bool // true = expect *httpErr from resolveAndStage
// knownUnsafe, if non-empty, is a substring that must NOT appear in
// the error body when wantHTTPError is true.
knownUnsafe string
}{
{
name: "empty source",
source: "",
wantHTTPError: true,
knownUnsafe: "pq:",
},
{
name: "valid source",
source: "github://owner/repo",
wantHTTPError: false,
knownUnsafe: "pq:",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
h := &PluginsHandler{
sources: &mockPluginsSources{schemes: []string{"github", "local"}},
}
_, err := h.resolveAndStage(context.Background(), installRequest{Source: tc.source})
if tc.wantHTTPError {
if err == nil {
t.Fatal("expected an error, got nil")
}
httpErr, ok := err.(*httpErr)
if !ok {
t.Fatalf("expected *httpErr, got %T", err)
}
// Verify the generic message is used (not a raw err.Error()).
if httpErr.Body == nil {
t.Fatal("httpErr.Body is nil")
}
errStr, ok := httpErr.Body["error"].(string)
if !ok {
t.Fatalf("body error field is not a string: %T", httpErr.Body["error"])
}
if tc.knownUnsafe != "" && strings.Contains(errStr, tc.knownUnsafe) {
t.Errorf("error body contains unsafe string %q: %q", tc.knownUnsafe, errStr)
}
} else {
if err != nil && tc.wantHTTPError {
t.Errorf("unexpected error: %v", err)
}
}
})
}
}
// mockPluginsSources implements plugins.SourceResolver for testing.
type mockPluginsSources struct {
schemes []string
}
func (m *mockPluginsSources) Schemes() []string { return m.schemes }
func (m *mockPluginsSources) Resolve(source plugins.Source) (plugins.SourceResolver, error) {
if source.Scheme == "github" {
return &mockResolver{}, nil
}
return nil, fmt.Errorf("unsupported scheme %q", source.Scheme)
}
type mockResolver struct{}
func (*mockResolver) Fetch(ctx context.Context, spec, destDir string) (string, error) {
return "", nil
}

View File

@ -207,7 +207,7 @@ func verifiedCPSession(cookieHeader string) (valid, presented bool) {
// for the negative-TTL window. Next request retries.
return false, true
}
defer resp.Body.Close()
defer func() { _ = $1 }()
if resp.StatusCode != http.StatusOK {
sessionCachePut(key, false)

View File

@ -39,12 +39,23 @@ const flyReplaySrcHeader = "Fly-Replay-Src"
const tenantOrgIDHeader = "X-Molecule-Org-Id"
// tenantGuardAllowlist is the set of paths that MUST remain accessible even in
// tenant mode without the org header (health checks, Prometheus scrapes).
// tenant mode without the org header (health checks, Prometheus scrapes,
// workspace → platform boot signals).
// Exact-match — no prefix semantics — to avoid accidentally exposing admin
// routes via e.g. "/health/debug/admin".
//
// /registry/register and /registry/heartbeat are workspace-initiated boot
// signals. Workspace EC2s are provisioned by the control plane with
// PLATFORM_URL but no MOLECULE_ORG_ID env var, so the runtime's httpx
// calls can't attach X-Molecule-Org-Id. Tenant SG already scopes these
// ports to the VPC CIDR; the registry handlers themselves enforce
// workspace-scoped bearer auth via wsauth.HasAnyLiveToken. Allowlisting
// here only bypasses the cross-org routing check, not auth.
var tenantGuardAllowlist = map[string]struct{}{
"/health": {},
"/metrics": {},
"/health": {},
"/metrics": {},
"/registry/register": {},
"/registry/heartbeat": {},
}
// TenantGuard returns a Gin middleware configured from the MOLECULE_ORG_ID env

View File

@ -82,6 +82,33 @@ func TestTenantGuard_AllowlistBypassesCheck(t *testing.T) {
}
}
// Workspace EC2s POST to these two paths during startup and do NOT have
// MOLECULE_ORG_ID to attach as a header — CP's user-data only exports
// WORKSPACE_ID + PLATFORM_URL + RUNTIME + PORT. Without this allowlist
// entry every workspace silently fails to register and sits in
// 'provisioning' until the 10-min sweeper — the same failure class
// that caused the 2026-04-21 prod incident.
func TestTenantGuard_AllowsWorkspaceRegistryPaths(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(TenantGuardWithOrgID("org-abc"))
// Register stub handlers so the test distinguishes "guard rejected"
// (404 from middleware) vs "route not matched" (404 from gin). The
// actual registry handlers live elsewhere; we only care that the
// guard doesn't abort before dispatch.
r.POST("/registry/register", func(c *gin.Context) { c.String(200, "register-reached") })
r.POST("/registry/heartbeat", func(c *gin.Context) { c.String(200, "heartbeat-reached") })
for _, path := range []string{"/registry/register", "/registry/heartbeat"} {
req := httptest.NewRequest("POST", path, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != 200 {
t.Errorf("%s: workspace boot path must bypass TenantGuard; got %d (body=%q)", path, w.Code, w.Body.String())
}
}
}
// Fly-Replay-Src state path: the production path. Control plane puts the
// bare UUID in state= (no prefix — Fly 502s on `=` in the state value).
// Fly injects the whole Fly-Replay-Src header on the replayed request.

View File

@ -184,6 +184,17 @@ func AdminAuth(database *sql.DB) gin.HandlerFunc {
if id, prefix, err := orgtoken.Validate(ctx, database, tok); err == nil {
c.Set("org_token_id", id)
c.Set("org_token_prefix", prefix)
// F1097: also set org_id from the token's org_id column so that
// requireCallerOwnsOrg can look it up via c.Get("org_id").
// Tokens created before PR #1210 have org_id=NULL — for those,
// the SELECT returns nil and no org_id is set, which is correct:
// requireCallerOwnsOrg already denies access for nil org_id.
var orgID *string
if err := database.QueryRowContext(ctx,
`SELECT org_id::text FROM org_api_tokens WHERE id = $1`, id,
).Scan(&orgID); err == nil && orgID != nil && *orgID != "" {
c.Set("org_id", *orgID)
}
c.Next()
return
} else if !errors.Is(err, orgtoken.ErrInvalidToken) {

View File

@ -458,6 +458,127 @@ func TestAdminAuth_InvalidBearer_Returns401(t *testing.T) {
}
}
// ────────────────────────────────────────────────────────────────────────────
// F1097 regression — org-scoped token Validate() must also set org_id in context
//
// Before PR #1210 (fix/org-api-token-org-id-column), org tokens had no org_id
// column so requireCallerOwnsOrg fell back to created_by lookup. After PR #1210,
// requireCallerOwnsOrg queries org_api_tokens.org_id directly — but if
// c.Set("org_id", ...) is never called, orgCallerID() always returns "" and
// all token callers are denied org-scoped access even within their own org.
//
// The fix (wsauth_middleware.go): after orgtoken.Validate succeeds, also look up
// the token's org_id column and set it in the context. This test verifies the
// middleware sets org_id for a pre-fix token (org_id=NULL) and a post-fix
// token (org_id="ws-org-1").
// ────────────────────────────────────────────────────────────────────────────
// orgTokenValidateQuery is matched for orgtoken.Validate().
const orgTokenValidateQuery = "SELECT id, prefix FROM org_api_tokens"
// orgTokenOrgIDQuery is matched for the org_id lookup added in the F1097 fix.
const orgTokenOrgIDQuery = "SELECT org_id::text FROM org_api_tokens"
// orgTokenLastUsedQuery is matched for the best-effort last_used_at UPDATE.
const orgTokenLastUsedQuery = "UPDATE org_api_tokens SET last_used_at"
// TestAdminAuth_OrgToken_SetsOrgID verifies that AdminAuth's org-token tier
// reads the org_id column and sets it in the gin context so that requireCallerOwnsOrg
// and orgCallerID can look it up downstream.
func TestAdminAuth_OrgToken_SetsOrgID(t *testing.T) {
tests := []struct {
name string
orgIDFromDB interface{} // sqlmock row value: nil, "", or "ws-org-1"
wantOrgIDCtx bool // expect c.Get("org_id") to be set
wantOrgIDVal string // if set, expected value
}{
{
name: "post-fix token has org_id set in context",
orgIDFromDB: "ws-org-1",
wantOrgIDCtx: true,
wantOrgIDVal: "ws-org-1",
},
{
name: "pre-fix token (org_id=NULL) — no org_id set in context",
orgIDFromDB: nil,
wantOrgIDCtx: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New: %v", err)
}
defer mockDB.Close()
orgBearer := "valid-org-token"
orgTokenHash := sha256.Sum256([]byte(orgBearer))
// HasAnyLiveTokenGlobal: tokens exist.
mock.ExpectQuery(hasAnyLiveTokenGlobalQuery).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
// orgtoken.Validate: org token hash matches, returns id + prefix.
// Note: org tokens are checked BEFORE the workspace token path
// (ValidateAnyToken), so ValidateAnyToken is NOT called here.
mock.ExpectQuery(orgTokenValidateQuery).
WithArgs(orgTokenHash[:]).
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix"}).
AddRow("tok-org-1", "tok-org-1"))
// Best-effort last_used_at UPDATE (after Validate).
mock.ExpectExec(orgTokenLastUsedQuery).
WithArgs("tok-org-1").
WillReturnResult(sqlmock.NewResult(0, 1))
// F1097 fix: org_id lookup. For pre-fix tokens (nil row), this
// returns nil and we expect no org_id context key to be set.
orgIDRows := sqlmock.NewRows([]string{"org_id"})
if tt.orgIDFromDB == nil {
orgIDRows = sqlmock.NewRows([]string{"org_id"}).AddRow(nil)
} else {
orgIDRows = sqlmock.NewRows([]string{"org_id"}).AddRow(tt.orgIDFromDB)
}
mock.ExpectQuery(orgTokenOrgIDQuery).
WithArgs("tok-org-1").
WillReturnRows(orgIDRows)
r := gin.New()
var gotOrgID string
var haveOrgID bool
r.GET("/admin/org/tokens", AdminAuth(mockDB), func(c *gin.Context) {
if v, ok := c.Get("org_id"); ok {
if s, ok := v.(string); ok {
gotOrgID = s
haveOrgID = true
}
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/admin/org/tokens", nil)
req.Header.Set("Authorization", "Bearer "+orgBearer)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if haveOrgID != tt.wantOrgIDCtx {
t.Errorf("c.Get(\"org_id\") present = %v, want %v", haveOrgID, tt.wantOrgIDCtx)
}
if tt.wantOrgIDCtx && gotOrgID != tt.wantOrgIDVal {
t.Errorf("org_id = %q, want %q", gotOrgID, tt.wantOrgIDVal)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
})
}
}
// ────────────────────────────────────────────────────────────────────────────
// Issue #170 regression — unauthenticated DELETE /workspaces/:id/secrets/:key
//

View File

@ -129,7 +129,7 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
if err != nil {
return "", fmt.Errorf("cp provisioner: send: %w", err)
}
defer resp.Body.Close()
defer func() { _ = $1 }()
// Cap body read at 64 KiB — the CP only ever returns small JSON
// responses; an unbounded read could be weaponized into log-flood
@ -163,7 +163,7 @@ func (p *CPProvisioner) Stop(ctx context.Context, workspaceID string) error {
if err != nil {
return fmt.Errorf("cp provisioner: stop: %w", err)
}
resp.Body.Close()
_ = resp.Body.Close()
return nil
}
@ -199,7 +199,7 @@ func (p *CPProvisioner) IsRunning(ctx context.Context, workspaceID string) (bool
if err != nil {
return true, fmt.Errorf("cp provisioner: status: %w", err)
}
defer resp.Body.Close()
defer func() { _ = $1 }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// Don't leak the body — upstream errors may echo headers.
return true, fmt.Errorf("cp provisioner: status: unexpected %d", resp.StatusCode)
@ -231,7 +231,7 @@ func (p *CPProvisioner) GetConsoleOutput(ctx context.Context, workspaceID string
if err != nil {
return "", fmt.Errorf("cp provisioner: console: %w", err)
}
defer resp.Body.Close()
defer func() { _ = $1 }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("cp provisioner: console: unexpected %d", resp.StatusCode)
}

View File

@ -107,7 +107,7 @@ func sweepStuckProvisioning(ctx context.Context, emitter ProvisionTimeoutEmitter
}
for _, id := range ids {
msg := "provisioning timed out — container never reported online. Check the workspace's required env vars and retry."
msg := "provisioning timed out — container started but never called /registry/register. Check container logs and network connectivity to the platform."
res, err := db.DB.ExecContext(ctx, `
UPDATE workspaces
SET status = 'failed',

View File

@ -215,7 +215,11 @@ func (s *Scheduler) tick(ctx context.Context) {
// Always advance next_run_at even on panic so the schedule doesn't get
// stuck re-firing the same panicking schedule indefinitely (#1029).
if nextTime, err := ComputeNextRun(s2.CronExpr, s2.Timezone, time.Now()); err == nil {
db.DB.ExecContext(ctx, `UPDATE workspace_schedules SET next_run_at=$1, updated_at=now() WHERE id=$2`, nextTime, s2.ID)
// F1089: use context.Background() so the panic-recovery UPDATE is not
// silently skipped if the outer ctx was cancelled during the panic window.
if _, execErr := db.DB.ExecContext(context.Background(), `UPDATE workspace_schedules SET next_run_at=$1, updated_at=now() WHERE id=$2`, nextTime, s2.ID); execErr != nil {
log.Printf("Scheduler: panic-recovery next_run_at UPDATE failed for schedule %s: %v", s2.ID, execErr)
}
}
}
}()
@ -246,8 +250,11 @@ func (s *Scheduler) fireSchedule(ctx context.Context, sched scheduleRow) {
// Always advance next_run_at even on panic so the schedule doesn't get
// stuck re-firing the same panicking schedule indefinitely (#1029).
if nextTime, err := ComputeNextRun(sched.CronExpr, sched.Timezone, time.Now()); err == nil {
db.DB.ExecContext(ctx, `UPDATE workspace_schedules SET next_run_at=$1, updated_at=now() WHERE id=$2`, nextTime, sched.ID)
}
// F1089: use context.Background() so the panic-recovery UPDATE is not
// silently skipped if the outer ctx was cancelled during the panic window.
if _, execErr := db.DB.ExecContext(context.Background(), `UPDATE workspace_schedules SET next_run_at=$1, updated_at=now() WHERE id=$2`, nextTime, sched.ID); execErr != nil {
log.Printf("Scheduler: panic-recovery next_run_at UPDATE failed for schedule %s: %v", sched.ID, execErr)
}
}
}()