Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fee8b9d86 | |||
| 6d802abcd1 | |||
| b364c16ea6 | |||
| c2a5b62521 | |||
| aa0e30ee76 | |||
| 4c86f047c7 | |||
| 34179e64a3 | |||
| 0c4970cdb7 | |||
| 9eefa5c474 | |||
| 305a38c5bb | |||
| bddfa4e403 |
@@ -15,9 +15,11 @@ test("FilesTab renders after split", async ({ page, request }) => {
|
||||
// Clean slate
|
||||
const { workspaces } = await request
|
||||
.get("http://localhost:8080/workspaces")
|
||||
.then(async (r) => ({ workspaces: (await r.json()) as Array<{ id: string }> }));
|
||||
.then(async (r) => ({ workspaces: (await r.json()) as Array<{ id: string; name: string }> }));
|
||||
for (const w of workspaces) {
|
||||
await request.delete(`http://localhost:8080/workspaces/${w.id}?confirm=true`);
|
||||
await request.delete(`http://localhost:8080/workspaces/${w.id}?confirm=true`, {
|
||||
headers: { "X-Confirm-Name": w.name },
|
||||
});
|
||||
}
|
||||
|
||||
// Create a workspace
|
||||
@@ -80,5 +82,7 @@ test("FilesTab renders after split", async ({ page, request }) => {
|
||||
await expect(editorEmpty.first()).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Cleanup
|
||||
await request.delete(`http://localhost:8080/workspaces/${wsId}?confirm=true`);
|
||||
await request.delete(`http://localhost:8080/workspaces/${wsId}?confirm=true`, {
|
||||
headers: { "X-Confirm-Name": "FilesTab Smoke" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -232,7 +232,10 @@ function CanvasInner() {
|
||||
}
|
||||
state.beginDelete(subtree);
|
||||
try {
|
||||
await api.del(`/workspaces/${id}?confirm=true`);
|
||||
const workspaceName = state.nodes.find((n) => n.id === id)?.data.name ?? "";
|
||||
await api.del(`/workspaces/${id}?confirm=true`, {
|
||||
headers: { "X-Confirm-Name": workspaceName },
|
||||
});
|
||||
// Mirror the server-side cascade locally — drop the parent AND
|
||||
// every descendant in one atomic update. The per-descendant
|
||||
// WORKSPACE_REMOVED WS events still arrive (and are no-ops
|
||||
|
||||
@@ -33,7 +33,51 @@ interface HermesProvider {
|
||||
models: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_CREATE_MODEL = "anthropic:claude-opus-4-7";
|
||||
type LLMAuthMode = "platform" | "api_key" | "oauth";
|
||||
|
||||
interface NativeLLMProvider {
|
||||
id: string;
|
||||
label: string;
|
||||
envVar?: string;
|
||||
defaultModel: string;
|
||||
models: string[];
|
||||
authModes: LLMAuthMode[];
|
||||
}
|
||||
|
||||
export const NATIVE_LLM_PROVIDERS: NativeLLMProvider[] = [
|
||||
{
|
||||
id: "minimax",
|
||||
label: "MiniMax",
|
||||
envVar: "MINIMAX_API_KEY",
|
||||
defaultModel: "MiniMax-M2.7",
|
||||
models: ["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5"],
|
||||
authModes: ["platform", "api_key"],
|
||||
},
|
||||
{
|
||||
id: "kimi-coding",
|
||||
label: "Kimi",
|
||||
envVar: "KIMI_API_KEY",
|
||||
defaultModel: "kimi-for-coding",
|
||||
models: ["kimi-for-coding", "kimi-k2.5", "kimi-k2"],
|
||||
authModes: ["platform", "api_key"],
|
||||
},
|
||||
{
|
||||
id: "anthropic",
|
||||
label: "Anthropic",
|
||||
envVar: "ANTHROPIC_API_KEY",
|
||||
defaultModel: "claude-sonnet-4-6",
|
||||
models: ["claude-sonnet-4-6", "claude-opus-4-7", "claude-haiku-4-5"],
|
||||
authModes: ["platform", "api_key"],
|
||||
},
|
||||
{
|
||||
id: "anthropic-oauth",
|
||||
label: "Claude OAuth",
|
||||
envVar: "CLAUDE_CODE_OAUTH_TOKEN",
|
||||
defaultModel: "sonnet",
|
||||
models: ["sonnet", "opus", "haiku"],
|
||||
authModes: ["oauth"],
|
||||
},
|
||||
];
|
||||
const DEFAULT_HEADLESS_INSTANCE_TYPE = "t3.medium";
|
||||
const DEFAULT_HEADLESS_ROOT_GB = 30;
|
||||
const DEFAULT_DISPLAY_INSTANCE_TYPE = "t3.xlarge";
|
||||
@@ -105,6 +149,10 @@ export function CreateWorkspaceButton() {
|
||||
// (Anthropic), which 401s if the user's key is for a different
|
||||
// provider. Hence: require model when template=hermes.
|
||||
const [hermesModel, setHermesModel] = useState("");
|
||||
const [llmAuthMode, setLLMAuthMode] = useState<LLMAuthMode>("platform");
|
||||
const [llmProvider, setLLMProvider] = useState("minimax");
|
||||
const [llmModel, setLLMModel] = useState("MiniMax-M2.7");
|
||||
const [llmSecret, setLLMSecret] = useState("");
|
||||
|
||||
// Tier picker: on SaaS every workspace gets its own EC2 VM (Full Access
|
||||
// by construction), so we hide the T1/T2/T3 Docker-sandbox tiers and
|
||||
@@ -161,6 +209,14 @@ export function CreateWorkspaceButton() {
|
||||
);
|
||||
|
||||
const isHermes = template.trim().toLowerCase() === "hermes";
|
||||
const nativeLLMProviders = useMemo(
|
||||
() => NATIVE_LLM_PROVIDERS.filter((p) => p.authModes.includes(llmAuthMode)),
|
||||
[llmAuthMode],
|
||||
);
|
||||
const selectedNativeProvider = useMemo(
|
||||
() => nativeLLMProviders.find((p) => p.id === llmProvider) ?? nativeLLMProviders[0],
|
||||
[llmProvider, nativeLLMProviders],
|
||||
);
|
||||
|
||||
// Resolve the selected template's spec from the /templates response.
|
||||
// The `template` input is free-text; templates can be matched by id,
|
||||
@@ -208,6 +264,22 @@ export function CreateWorkspaceButton() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [availableProviders, isHermes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isHermes) return;
|
||||
if (nativeLLMProviders.length === 0) return;
|
||||
if (!nativeLLMProviders.some((p) => p.id === llmProvider)) {
|
||||
setLLMProvider(nativeLLMProviders[0].id);
|
||||
setLLMModel(nativeLLMProviders[0].defaultModel);
|
||||
}
|
||||
}, [isHermes, llmProvider, nativeLLMProviders]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isHermes || !selectedNativeProvider) return;
|
||||
if (!selectedNativeProvider.models.includes(llmModel)) {
|
||||
setLLMModel(selectedNativeProvider.defaultModel);
|
||||
}
|
||||
}, [isHermes, llmModel, selectedNativeProvider]);
|
||||
|
||||
// Auto-fill hermesModel with the provider's defaultModel whenever the
|
||||
// provider changes, but only if the user hasn't already typed their own
|
||||
// slug. Prevents the empty-model → "auto" → Anthropic-default 401 trap.
|
||||
@@ -242,6 +314,10 @@ export function CreateWorkspaceButton() {
|
||||
setExternalRuntime("external");
|
||||
setHermesApiKey("");
|
||||
setHermesModel("");
|
||||
setLLMAuthMode("platform");
|
||||
setLLMProvider("minimax");
|
||||
setLLMModel("MiniMax-M2.7");
|
||||
setLLMSecret("");
|
||||
api
|
||||
.get<WorkspaceOption[]>("/workspaces")
|
||||
.then((ws) => setWorkspaces(ws))
|
||||
@@ -268,12 +344,21 @@ export function CreateWorkspaceButton() {
|
||||
setError("Model is required for Hermes workspaces — provider routing depends on the model slug prefix");
|
||||
return;
|
||||
}
|
||||
if (!isExternal && !isHermes && !llmModel.trim()) {
|
||||
setError("Model is required");
|
||||
return;
|
||||
}
|
||||
if (!isExternal && !isHermes && llmAuthMode !== "platform" && !llmSecret.trim()) {
|
||||
setError(llmAuthMode === "oauth" ? "Claude OAuth token is required" : "API key is required");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
|
||||
const provider = isHermes
|
||||
? HERMES_PROVIDERS.find((p) => p.id === hermesProvider)
|
||||
: undefined;
|
||||
const nativeProvider = !isHermes ? selectedNativeProvider : undefined;
|
||||
|
||||
try {
|
||||
const parsedBudget = budgetLimit.trim()
|
||||
@@ -297,7 +382,15 @@ export function CreateWorkspaceButton() {
|
||||
tier,
|
||||
parent_id: parentId || undefined,
|
||||
budget_limit: parsedBudget,
|
||||
...(!isExternal && !isHermes ? { model: DEFAULT_CREATE_MODEL } : {}),
|
||||
...(!isExternal && !isHermes && nativeProvider
|
||||
? {
|
||||
model: llmModel.trim(),
|
||||
llm_provider: nativeProvider.id,
|
||||
...(llmAuthMode !== "platform" && nativeProvider.envVar
|
||||
? { secrets: { [nativeProvider.envVar]: llmSecret.trim() } }
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
...(!isExternal
|
||||
? {
|
||||
compute: displayEnabled
|
||||
@@ -449,6 +542,82 @@ export function CreateWorkspaceButton() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isExternal && !isHermes && selectedNativeProvider && (
|
||||
<div className="rounded-lg border border-line/50 bg-surface-card/40 p-3 space-y-3">
|
||||
<div className="text-[11px] font-medium text-ink-mid">
|
||||
LLM
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="llm-auth-mode" className="text-[11px] text-ink-mid block mb-1">
|
||||
Auth Mode
|
||||
</label>
|
||||
<select
|
||||
id="llm-auth-mode"
|
||||
value={llmAuthMode}
|
||||
onChange={(e) => setLLMAuthMode(e.target.value as LLMAuthMode)}
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
>
|
||||
<option value="platform">Platform provided</option>
|
||||
<option value="api_key">API key</option>
|
||||
<option value="oauth">Claude OAuth</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="llm-provider-select" className="text-[11px] text-ink-mid block mb-1">
|
||||
Provider
|
||||
</label>
|
||||
<select
|
||||
id="llm-provider-select"
|
||||
value={selectedNativeProvider.id}
|
||||
onChange={(e) => {
|
||||
const next = nativeLLMProviders.find((p) => p.id === e.target.value);
|
||||
setLLMProvider(e.target.value);
|
||||
if (next) setLLMModel(next.defaultModel);
|
||||
}}
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
>
|
||||
{nativeLLMProviders.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="llm-model-input" className="text-[11px] text-ink-mid block mb-1">
|
||||
Model
|
||||
</label>
|
||||
<input
|
||||
id="llm-model-input"
|
||||
type="text"
|
||||
value={llmModel}
|
||||
onChange={(e) => setLLMModel(e.target.value)}
|
||||
list="llm-model-suggestions"
|
||||
spellCheck={false}
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors font-mono"
|
||||
/>
|
||||
<datalist id="llm-model-suggestions">
|
||||
{selectedNativeProvider.models.map((m) => <option key={m} value={m} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
{llmAuthMode !== "platform" && (
|
||||
<div>
|
||||
<label htmlFor="llm-secret-input" className="text-[11px] text-ink-mid block mb-1">
|
||||
{llmAuthMode === "oauth" ? "OAuth Token" : "API Key"}
|
||||
</label>
|
||||
<input
|
||||
id="llm-secret-input"
|
||||
type="password"
|
||||
value={llmSecret}
|
||||
onChange={(e) => setLLMSecret(e.target.value)}
|
||||
autoComplete="off"
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div
|
||||
role="radiogroup"
|
||||
@@ -553,10 +722,11 @@ export function CreateWorkspaceButton() {
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-[11px] text-ink-mid block mb-1">
|
||||
<label htmlFor="parent-workspace-select" className="text-[11px] text-ink-mid block mb-1">
|
||||
Parent Workspace
|
||||
</label>
|
||||
<select
|
||||
id="parent-workspace-select"
|
||||
value={parentId}
|
||||
onChange={(e) => setParentId(e.target.value)}
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
|
||||
@@ -242,10 +242,13 @@ export function ProvisioningTimeout({
|
||||
const handleCancelConfirm = useCallback(async () => {
|
||||
if (!confirmingCancel) return;
|
||||
const workspaceId = confirmingCancel;
|
||||
const workspaceName = timedOut.find((e) => e.workspaceId === workspaceId)?.workspaceName ?? "";
|
||||
setConfirmingCancel(null);
|
||||
setCancelling((prev) => new Set(prev).add(workspaceId));
|
||||
try {
|
||||
await api.del(`/workspaces/${workspaceId}`);
|
||||
await api.del(`/workspaces/${workspaceId}`, {
|
||||
headers: { "X-Confirm-Name": workspaceName },
|
||||
});
|
||||
setTimedOut((prev) => prev.filter((e) => e.workspaceId !== workspaceId));
|
||||
trackingRef.current.delete(workspaceId);
|
||||
showToast("Deployment cancelled", "info");
|
||||
|
||||
@@ -63,7 +63,7 @@ describe("CreateWorkspaceDialog", () => {
|
||||
|
||||
it('first option is "None (root level)" with empty value', async () => {
|
||||
await openDialog();
|
||||
const select = document.querySelector("select") as HTMLSelectElement;
|
||||
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
|
||||
expect(select).toBeTruthy();
|
||||
const firstOption = select.options[0];
|
||||
expect(firstOption.value).toBe("");
|
||||
@@ -73,12 +73,12 @@ describe("CreateWorkspaceDialog", () => {
|
||||
it("populates select with workspace names from GET /workspaces", async () => {
|
||||
await openDialog();
|
||||
await waitFor(() => {
|
||||
const select = document.querySelector("select") as HTMLSelectElement;
|
||||
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
|
||||
const optionValues = Array.from(select.options).map((o) => o.value);
|
||||
expect(optionValues).toContain("ws-1");
|
||||
expect(optionValues).toContain("ws-2");
|
||||
});
|
||||
const select = document.querySelector("select") as HTMLSelectElement;
|
||||
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
|
||||
const optionTexts = Array.from(select.options).map((o) => o.text.trim());
|
||||
expect(optionTexts.some((t) => t.includes("Platform Team"))).toBe(true);
|
||||
expect(optionTexts.some((t) => t.includes("Research Agent"))).toBe(true);
|
||||
@@ -87,7 +87,7 @@ describe("CreateWorkspaceDialog", () => {
|
||||
it("sends parent_id in POST body when a workspace is selected", async () => {
|
||||
await openDialog();
|
||||
await waitFor(() => {
|
||||
const select = document.querySelector("select") as HTMLSelectElement;
|
||||
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
|
||||
expect(select.options.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
@@ -95,7 +95,7 @@ describe("CreateWorkspaceDialog", () => {
|
||||
target: { value: "My Agent" },
|
||||
});
|
||||
|
||||
const select = document.querySelector("select") as HTMLSelectElement;
|
||||
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: "ws-1" } });
|
||||
|
||||
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
|
||||
@@ -112,7 +112,7 @@ describe("CreateWorkspaceDialog", () => {
|
||||
target: { value: "Root Agent" },
|
||||
});
|
||||
|
||||
const select = document.querySelector("select") as HTMLSelectElement;
|
||||
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: "" } });
|
||||
|
||||
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
|
||||
@@ -139,7 +139,9 @@ describe("CreateWorkspaceDialog", () => {
|
||||
volume: { root_gb: 30 },
|
||||
display: { mode: "none" },
|
||||
});
|
||||
expect(body.model).toBe("anthropic:claude-opus-4-7");
|
||||
expect(body.model).toBe("MiniMax-M2.7");
|
||||
expect(body.llm_provider).toBe("minimax");
|
||||
expect(body.secrets).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not send managed compute for external agents", async () => {
|
||||
@@ -170,7 +172,8 @@ describe("CreateWorkspaceDialog", () => {
|
||||
|
||||
await waitFor(() => expect(mockPost).toHaveBeenCalled());
|
||||
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body.model).toBe("anthropic:claude-opus-4-7");
|
||||
expect(body.model).toBe("MiniMax-M2.7");
|
||||
expect(body.llm_provider).toBe("minimax");
|
||||
expect(body.compute).toEqual({
|
||||
instance_type: "t3.xlarge",
|
||||
volume: { root_gb: 80 },
|
||||
@@ -183,13 +186,57 @@ describe("CreateWorkspaceDialog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sends BYOK API key secrets when API key auth mode is selected", async () => {
|
||||
await openDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "BYOK Agent" },
|
||||
});
|
||||
fireEvent.change(document.getElementById("llm-auth-mode") as HTMLSelectElement, {
|
||||
target: { value: "api_key" },
|
||||
});
|
||||
fireEvent.change(document.getElementById("llm-secret-input") as HTMLInputElement, {
|
||||
target: { value: "sk-minimax-test" },
|
||||
});
|
||||
|
||||
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
|
||||
fireEvent.click(createBtn!);
|
||||
|
||||
await waitFor(() => expect(mockPost).toHaveBeenCalled());
|
||||
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body.model).toBe("MiniMax-M2.7");
|
||||
expect(body.llm_provider).toBe("minimax");
|
||||
expect(body.secrets).toEqual({ MINIMAX_API_KEY: "sk-minimax-test" });
|
||||
});
|
||||
|
||||
it("sends Claude OAuth token separately from platform-managed mode", async () => {
|
||||
await openDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "OAuth Agent" },
|
||||
});
|
||||
fireEvent.change(document.getElementById("llm-auth-mode") as HTMLSelectElement, {
|
||||
target: { value: "oauth" },
|
||||
});
|
||||
fireEvent.change(document.getElementById("llm-secret-input") as HTMLInputElement, {
|
||||
target: { value: "oauth-token" },
|
||||
});
|
||||
|
||||
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
|
||||
fireEvent.click(createBtn!);
|
||||
|
||||
await waitFor(() => expect(mockPost).toHaveBeenCalled());
|
||||
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body.model).toBe("sonnet");
|
||||
expect(body.llm_provider).toBe("anthropic-oauth");
|
||||
expect(body.secrets).toEqual({ CLAUDE_CODE_OAUTH_TOKEN: "oauth-token" });
|
||||
});
|
||||
|
||||
it("renders gracefully when GET /workspaces fails", async () => {
|
||||
mockGet.mockRejectedValueOnce(new Error("Network error"));
|
||||
await openDialog();
|
||||
|
||||
// Dialog still renders; select exists with only the root option
|
||||
await waitFor(() => {
|
||||
const select = document.querySelector("select") as HTMLSelectElement;
|
||||
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
|
||||
expect(select.options.length).toBe(1);
|
||||
expect(select.options[0].value).toBe("");
|
||||
});
|
||||
|
||||
@@ -272,7 +272,9 @@ describe("OrgCancelButton — API interactions", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /yes/i }));
|
||||
await act(async () => { /* flush */ });
|
||||
expect(mockApiDel).toHaveBeenCalledWith("/workspaces/root-1?confirm=true");
|
||||
expect(mockApiDel).toHaveBeenCalledWith("/workspaces/root-1?confirm=true", {
|
||||
headers: { "X-Confirm-Name": "Test Org" },
|
||||
});
|
||||
});
|
||||
|
||||
it("shows success toast on DELETE success", async () => {
|
||||
|
||||
@@ -57,6 +57,7 @@ export function OrgCancelButton({ rootId, rootName, workspaceCount }: Props) {
|
||||
try {
|
||||
await api.del<{ status: string }>(
|
||||
`/workspaces/${rootId}?confirm=true`,
|
||||
{ headers: { "X-Confirm-Name": rootName } },
|
||||
);
|
||||
showToast(`Cancelled deployment of "${rootName}"`, "success");
|
||||
// Optimistic local removal — workspace-server broadcasts
|
||||
|
||||
@@ -199,7 +199,9 @@ describe("OrgCancelButton — Yes / cascade delete", () => {
|
||||
});
|
||||
|
||||
// 1) API call hit the cascade-delete endpoint with confirm=true
|
||||
expect(mockApiDel).toHaveBeenCalledWith("/workspaces/ws-root?confirm=true");
|
||||
expect(mockApiDel).toHaveBeenCalledWith("/workspaces/ws-root?confirm=true", {
|
||||
headers: { "X-Confirm-Name": "My Org" },
|
||||
});
|
||||
|
||||
// 2) beginDelete locked the WHOLE subtree (root + 2 children) — NOT the unrelated node
|
||||
expect(mockState.beginDelete).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -93,7 +93,9 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
const handleDelete = async () => {
|
||||
setDeleteError(null);
|
||||
try {
|
||||
await api.del(`/workspaces/${workspaceId}?confirm=true`);
|
||||
await api.del(`/workspaces/${workspaceId}?confirm=true`, {
|
||||
headers: { "X-Confirm-Name": name },
|
||||
});
|
||||
// Mirror the server-side cascade — drop the row + every
|
||||
// descendant locally so the canvas reflects the deletion
|
||||
// immediately, even when the WS is dead and the per-descendant
|
||||
|
||||
@@ -265,6 +265,11 @@ function DisplayControlBar({
|
||||
onAcquire: () => void;
|
||||
onRelease: () => void;
|
||||
}) {
|
||||
const userControl = control?.controller === "user";
|
||||
const adminControl = userControl && control?.controlled_by === "admin-token";
|
||||
const canAcquireUserControl = control?.controller === "none" || (userControl && !hasSession);
|
||||
const canReleaseUserControl = adminControl || (userControl && hasSession);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{control && (
|
||||
@@ -282,8 +287,7 @@ function DisplayControlBar({
|
||||
{controlError && <p className="mt-0.5 text-[10px] text-red-200">{controlError}</p>}
|
||||
</div>
|
||||
)}
|
||||
{(control?.controller === "none" ||
|
||||
(control?.controller === "user" && control.controlled_by === "admin-token" && !hasSession)) && (
|
||||
{canAcquireUserControl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAcquire}
|
||||
@@ -293,7 +297,7 @@ function DisplayControlBar({
|
||||
Take control
|
||||
</button>
|
||||
)}
|
||||
{control?.controller === "user" && control.controlled_by === "admin-token" && (
|
||||
{canReleaseUserControl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRelease}
|
||||
|
||||
@@ -290,7 +290,9 @@ describe("DetailsTab — delete workflow", () => {
|
||||
) as HTMLButtonElement;
|
||||
fireEvent(confirmBtn, new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(mockApi.del).toHaveBeenCalledWith("/workspaces/ws-1?confirm=true");
|
||||
expect(mockApi.del).toHaveBeenCalledWith("/workspaces/ws-1?confirm=true", {
|
||||
headers: { "X-Confirm-Name": "Test Workspace" },
|
||||
});
|
||||
expect(mockRemoveSubtree).toHaveBeenCalledWith("ws-1");
|
||||
expect(mockSelectNode).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
@@ -143,46 +143,30 @@ afterEach(() => {
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Drive the always-show-picker flow to completion: deploy() opens the
|
||||
* modal, then we click "keys added" to fire the actual POST. Centralised
|
||||
* here because as of the always-prompt change, every happy-path test
|
||||
* must click through the modal before asserting on POST.
|
||||
*/
|
||||
async function deployThroughPicker<T>(
|
||||
result: { current: ReturnType<typeof useTemplateDeploy> },
|
||||
rerender: () => void,
|
||||
template: Template,
|
||||
): Promise<void> {
|
||||
await act(async () => {
|
||||
await result.current.deploy(template);
|
||||
});
|
||||
rerender();
|
||||
render(<>{result.current.modal}</>);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("modal-keys-added"));
|
||||
// Let the fire-and-forget executeDeploy resolve.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("useTemplateDeploy — happy path", () => {
|
||||
it("preflight ok → modal opens → keys-added → POST /workspaces → onDeployed fires", async () => {
|
||||
it("preflight ok with no key requirements → POST /workspaces directly → onDeployed fires", async () => {
|
||||
const onDeployed = vi.fn();
|
||||
const { result, rerender } = renderHook(() =>
|
||||
const { result } = renderHook(() =>
|
||||
useTemplateDeploy({ onDeployed }),
|
||||
);
|
||||
|
||||
await deployThroughPicker(result, rerender, makeTemplate());
|
||||
await act(async () => {
|
||||
await result.current.deploy(makeTemplate({
|
||||
id: "seo-agent",
|
||||
name: "SEO Agent",
|
||||
model: "MiniMax-M2.7",
|
||||
}));
|
||||
});
|
||||
|
||||
expect(mockCheckDeploySecrets).toHaveBeenCalledTimes(1);
|
||||
expect(mockApiPost).toHaveBeenCalledWith(
|
||||
"/workspaces",
|
||||
expect.objectContaining({
|
||||
name: "Claude Code",
|
||||
template: "claude-code-default",
|
||||
name: "SEO Agent",
|
||||
template: "seo-agent",
|
||||
tier: 1,
|
||||
model: "MiniMax-M2.7",
|
||||
llm_provider: "minimax",
|
||||
}),
|
||||
);
|
||||
expect(onDeployed).toHaveBeenCalledWith("ws-new");
|
||||
@@ -192,11 +176,13 @@ describe("useTemplateDeploy — happy path", () => {
|
||||
|
||||
it("uses caller-supplied canvasCoords when provided", async () => {
|
||||
const canvasCoords = vi.fn(() => ({ x: 42, y: 99 }));
|
||||
const { result, rerender } = renderHook(() =>
|
||||
const { result } = renderHook(() =>
|
||||
useTemplateDeploy({ canvasCoords }),
|
||||
);
|
||||
|
||||
await deployThroughPicker(result, rerender, makeTemplate());
|
||||
await act(async () => {
|
||||
await result.current.deploy(makeTemplate());
|
||||
});
|
||||
|
||||
expect(canvasCoords).toHaveBeenCalledTimes(1);
|
||||
expect(mockApiPost).toHaveBeenCalledWith(
|
||||
@@ -206,9 +192,11 @@ describe("useTemplateDeploy — happy path", () => {
|
||||
});
|
||||
|
||||
it("falls back to random coords inside [100,500] × [100,400] when canvasCoords omitted", async () => {
|
||||
const { result, rerender } = renderHook(() => useTemplateDeploy());
|
||||
const { result } = renderHook(() => useTemplateDeploy());
|
||||
|
||||
await deployThroughPicker(result, rerender, makeTemplate());
|
||||
await act(async () => {
|
||||
await result.current.deploy(makeTemplate());
|
||||
});
|
||||
|
||||
const body = (mockApiPost as Mock).mock.calls[0]?.[1] as {
|
||||
canvas: { x: number; y: number };
|
||||
@@ -458,16 +446,9 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("single-provider template ALSO opens picker when preflight.ok (always-prompt rule)", async () => {
|
||||
// Default preflight mock: ok=true, providers=[]. claude-code is
|
||||
// single-provider, but the always-prompt rule means the user must
|
||||
// still click through the picker to confirm provider+model — even
|
||||
// when keys are saved and the runtime has only one provider option.
|
||||
// Reason: the user needs an explicit chance to override the
|
||||
// template's default model (e.g. opus vs sonnet vs haiku) before
|
||||
// an EC2 boots and burns billing on the wrong tier.
|
||||
it("template with no provider requirements deploys directly on platform-managed defaults", async () => {
|
||||
const onDeployed = vi.fn();
|
||||
const { result, rerender } = renderHook(() =>
|
||||
const { result } = renderHook(() =>
|
||||
useTemplateDeploy({ onDeployed }),
|
||||
);
|
||||
|
||||
@@ -475,13 +456,18 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
|
||||
await result.current.deploy(makeTemplate());
|
||||
});
|
||||
|
||||
rerender();
|
||||
render(<>{result.current.modal}</>);
|
||||
|
||||
expect(screen.getByTestId("missing-keys-modal")).toBeTruthy();
|
||||
// POST does NOT fire until the user confirms in the picker.
|
||||
expect(mockApiPost).not.toHaveBeenCalled();
|
||||
expect(onDeployed).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("missing-keys-modal")).toBeNull();
|
||||
expect(mockApiPost).toHaveBeenCalledWith(
|
||||
"/workspaces",
|
||||
expect.objectContaining({
|
||||
template: "claude-code-default",
|
||||
model: "claude-sonnet-4-5",
|
||||
llm_provider: "anthropic",
|
||||
}),
|
||||
);
|
||||
expect(onDeployed).toHaveBeenCalledWith("ws-new");
|
||||
expect(result.current.deploying).toBeNull();
|
||||
});
|
||||
|
||||
@@ -519,11 +505,13 @@ describe("useTemplateDeploy — POST failure", () => {
|
||||
it("POST rejection sets error and clears deploying", async () => {
|
||||
mockApiPost.mockRejectedValueOnce(new Error("server 500"));
|
||||
const onDeployed = vi.fn();
|
||||
const { result, rerender } = renderHook(() =>
|
||||
const { result } = renderHook(() =>
|
||||
useTemplateDeploy({ onDeployed }),
|
||||
);
|
||||
|
||||
await deployThroughPicker(result, rerender, makeTemplate());
|
||||
await act(async () => {
|
||||
await result.current.deploy(makeTemplate());
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe("server 500");
|
||||
expect(result.current.deploying).toBeNull();
|
||||
@@ -532,9 +520,11 @@ describe("useTemplateDeploy — POST failure", () => {
|
||||
|
||||
it("non-Error rejection still surfaces a message (defensive)", async () => {
|
||||
mockApiPost.mockRejectedValueOnce("plain string");
|
||||
const { result, rerender } = renderHook(() => useTemplateDeploy());
|
||||
const { result } = renderHook(() => useTemplateDeploy());
|
||||
|
||||
await deployThroughPicker(result, rerender, makeTemplate());
|
||||
await act(async () => {
|
||||
await result.current.deploy(makeTemplate());
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe("Deploy failed");
|
||||
expect(result.current.deploying).toBeNull();
|
||||
|
||||
@@ -55,6 +55,22 @@ interface MissingKeysInfo {
|
||||
preflight: PreflightResult;
|
||||
}
|
||||
|
||||
function nativeProviderForClaudeCodeModel(model: string): string | undefined {
|
||||
const trimmed = model.trim();
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (!trimmed) return undefined;
|
||||
if (lower.startsWith("minimax")) return "minimax";
|
||||
if (lower.startsWith("kimi")) return "kimi-coding";
|
||||
if (lower.startsWith("claude")) return "anthropic";
|
||||
if (/^(sonnet|opus|haiku)$/.test(lower)) return "anthropic-oauth";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isNativeClaudeCodeRuntime(template: Template): boolean {
|
||||
const runtime = template.runtime ?? resolveRuntime(template.id);
|
||||
return runtime === "claude-code";
|
||||
}
|
||||
|
||||
export interface UseTemplateDeployResult {
|
||||
/** Template id currently being deployed (incl. the preflight
|
||||
* network call), or null when idle. Callers pass this to disable
|
||||
@@ -97,6 +113,10 @@ export function useTemplateDeploy(
|
||||
setDeploying(template.id);
|
||||
setError(null);
|
||||
try {
|
||||
const selectedModel = model?.trim() || template.model?.trim();
|
||||
const nativeProvider = isNativeClaudeCodeRuntime(template) && selectedModel
|
||||
? nativeProviderForClaudeCodeModel(selectedModel)
|
||||
: undefined;
|
||||
const coords = canvasCoords
|
||||
? canvasCoords()
|
||||
: {
|
||||
@@ -108,7 +128,8 @@ export function useTemplateDeploy(
|
||||
template: template.id,
|
||||
tier: isSaaSTenant() ? 4 : template.tier,
|
||||
canvas: coords,
|
||||
...(model ? { model } : {}),
|
||||
...(selectedModel ? { model: selectedModel } : {}),
|
||||
...(nativeProvider ? { llm_provider: nativeProvider } : {}),
|
||||
});
|
||||
onDeployed?.(ws.id);
|
||||
} catch (e) {
|
||||
@@ -144,8 +165,13 @@ export function useTemplateDeploy(
|
||||
setDeploying(null);
|
||||
return;
|
||||
}
|
||||
// Always open the picker — every deploy goes through an
|
||||
// explicit confirm-provider/model step. Reasons:
|
||||
if (preflight.ok && preflight.providers.length === 0) {
|
||||
await executeDeploy(template);
|
||||
return;
|
||||
}
|
||||
// Open the picker whenever a template declares provider/key choices.
|
||||
// Templates with no provider requirements deploy directly on the
|
||||
// platform-managed default above. Reasons to keep the picker here:
|
||||
// 1. Multi-provider templates (e.g. hermes) need a per-
|
||||
// workspace pick or the adapter falls back to its
|
||||
// compiled-in default and 500s with "No LLM provider
|
||||
@@ -164,7 +190,7 @@ export function useTemplateDeploy(
|
||||
setMissingKeysInfo({ template, preflight });
|
||||
setDeploying(null);
|
||||
},
|
||||
[],
|
||||
[executeDeploy],
|
||||
);
|
||||
|
||||
// No useCallback here — consumers call this on every render anyway
|
||||
|
||||
@@ -23,6 +23,7 @@ const DEFAULT_TIMEOUT_MS = 35_000;
|
||||
|
||||
export interface RequestOptions {
|
||||
timeoutMs?: number;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,6 +77,7 @@ async function request<T>(
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...platformAuthHeaders(),
|
||||
...(options?.headers ?? {}),
|
||||
};
|
||||
// Re-read slug locally for the 401 handler below — `headers` already
|
||||
// has it, but the 401 branch needs the bare value to gate the
|
||||
|
||||
@@ -337,8 +337,11 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
|
||||
},
|
||||
batchDelete: async () => {
|
||||
const ids = Array.from(get().selectedNodeIds);
|
||||
const names = new Map(get().nodes.map((node) => [node.id, node.data.name]));
|
||||
const results = await Promise.allSettled(
|
||||
ids.map((id) => api.del(`/workspaces/${id}`))
|
||||
ids.map((id) => api.del(`/workspaces/${id}`, {
|
||||
headers: { "X-Confirm-Name": names.get(id) ?? "" },
|
||||
}))
|
||||
);
|
||||
const failed: string[] = [];
|
||||
results.forEach((r, i) => {
|
||||
|
||||
@@ -26,7 +26,7 @@ Full contract: `docs/runbooks/admin-auth.md`.
|
||||
|--------|------|---------|
|
||||
| GET | /health | inline |
|
||||
| GET | /metrics | metrics.Handler() — Prometheus text format; no auth, scrape-safe |
|
||||
| POST/GET/PATCH/DELETE | /workspaces[/:id] | workspace.go — `GET /workspaces`, `POST /workspaces`, and `DELETE /workspaces/:id` require `AdminAuth`. `PATCH /workspaces/:id` enforces field-level authz: cosmetic fields (name, role, x, y, canvas) pass through; sensitive fields (tier, parent_id, runtime, workspace_dir) require a valid bearer token when any live token exists. |
|
||||
| POST/GET/PATCH/DELETE | /workspaces[/:id] | workspace.go — `GET /workspaces`, `POST /workspaces`, and `DELETE /workspaces/:id` require `AdminAuth`. `DELETE /workspaces/:id` also requires `X-Confirm-Name: <workspace name>`; cascading deletes still require `?confirm=true`. `PATCH /workspaces/:id` enforces field-level authz: cosmetic fields (name, role, x, y, canvas) pass through; sensitive fields (tier, parent_id, runtime, workspace_dir) require a valid bearer token when any live token exists. |
|
||||
| GET/PATCH | /workspaces/:id/config | workspace.go |
|
||||
| GET/POST | /workspaces/:id/memory | workspace.go |
|
||||
| DELETE | /workspaces/:id/memory/:key | workspace.go |
|
||||
|
||||
+24
-5
@@ -45,12 +45,31 @@ e2e_mint_workspace_token() {
|
||||
printf '%s' "$json" | python3 -c "import json,sys; print(json.load(sys.stdin)['auth_token'])"
|
||||
}
|
||||
|
||||
e2e_cleanup_all_workspaces() {
|
||||
for _wid in $(curl -s "$BASE/workspaces" | python3 -c "import json,sys
|
||||
e2e_delete_workspace() {
|
||||
local wid="$1"
|
||||
local name="${2:-}"
|
||||
shift 2 || true
|
||||
local curl_args=("$@")
|
||||
if [ -z "$wid" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ -z "$name" ]; then
|
||||
name=$(curl -s "$BASE/workspaces/$wid" "${curl_args[@]}" | python3 -c "import json,sys
|
||||
try:
|
||||
[print(w['id']) for w in json.load(sys.stdin)]
|
||||
print(json.load(sys.stdin).get('name',''))
|
||||
except Exception:
|
||||
pass" 2>/dev/null); do
|
||||
curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" > /dev/null || true
|
||||
pass" 2>/dev/null || true)
|
||||
fi
|
||||
curl -s -X DELETE "$BASE/workspaces/$wid?confirm=true" \
|
||||
-H "X-Confirm-Name: $name" "${curl_args[@]}" > /dev/null || true
|
||||
}
|
||||
|
||||
e2e_cleanup_all_workspaces() {
|
||||
curl -s "$BASE/workspaces" | python3 -c "import json,sys
|
||||
try:
|
||||
[print(f\"{w.get('id','')}\\t{w.get('name','')}\") for w in json.load(sys.stdin)]
|
||||
except Exception:
|
||||
pass" 2>/dev/null | while IFS=$'\t' read -r _wid _name; do
|
||||
e2e_delete_workspace "$_wid" "$_name"
|
||||
done
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ R=$(curl -s --max-time 10 -X POST "$BASE/workspaces/$OFFLINE_ID/a2a" \
|
||||
-d '{"method":"message/send","params":{"message":{"role":"user","parts":[{"type":"text","text":"test"}]}}}')
|
||||
check "Offline workspace returns error" '"error"' "$R"
|
||||
# Clean up
|
||||
curl -s -X DELETE "$BASE/workspaces/$OFFLINE_ID" >/dev/null
|
||||
e2e_delete_workspace "$OFFLINE_ID" "Offline Test"
|
||||
echo ""
|
||||
|
||||
# ========================================
|
||||
|
||||
@@ -235,7 +235,7 @@ R=$(curl -s "$BASE/workspaces/$TEMP_ID/activity")
|
||||
check "Activity in correct workspace" 'Temp workspace log' "$R"
|
||||
|
||||
# Cleanup
|
||||
curl -s -X DELETE "$BASE/workspaces/$TEMP_ID" > /dev/null
|
||||
e2e_delete_workspace "$TEMP_ID" "Activity Test Workspace"
|
||||
|
||||
# ---------- Edge Cases ----------
|
||||
echo ""
|
||||
|
||||
@@ -289,7 +289,9 @@ R=$(curl -s "$BASE/workspaces" -H "Authorization: Bearer $ECHO_TOKEN")
|
||||
check "current_task in list response" '"current_task"' "$R"
|
||||
|
||||
# Test 21: Delete
|
||||
R=$(acurl -X DELETE "$BASE/workspaces/$ECHO_ID" -H "Authorization: Bearer $ECHO_TOKEN")
|
||||
R=$(acurl -X DELETE "$BASE/workspaces/$ECHO_ID?confirm=true" \
|
||||
-H "Authorization: Bearer $ECHO_TOKEN" \
|
||||
-H "X-Confirm-Name: Echo Agent v2")
|
||||
check "DELETE /workspaces/:id" '"status":"removed"' "$R"
|
||||
|
||||
R=$(curl -s "$BASE/workspaces" -H "Authorization: Bearer $SUM_TOKEN")
|
||||
@@ -310,7 +312,9 @@ ORIG_TIER=$(echo "$BUNDLE" | python3 -c "import sys,json; print(json.load(sys.st
|
||||
|
||||
# Delete the workspace — use SUM_TOKEN (per-workspace) for WorkspaceAuth
|
||||
# and ADMIN_TOKEN for the AdminAuth layer.
|
||||
R=$(curl -s -X DELETE "$BASE/workspaces/$SUM_ID" -H "Authorization: Bearer $SUM_TOKEN")
|
||||
R=$(curl -s -X DELETE "$BASE/workspaces/$SUM_ID?confirm=true" \
|
||||
-H "Authorization: Bearer $SUM_TOKEN" \
|
||||
-H "X-Confirm-Name: Summarizer Agent")
|
||||
check "Delete before re-import" '"status":"removed"' "$R"
|
||||
|
||||
# After deleting both workspaces, all per-workspace tokens are revoked.
|
||||
@@ -381,7 +385,7 @@ REBUNDLE=$(curl -s "$BASE/bundles/export/$NEW_ID" -H "Authorization: Bearer $NEW
|
||||
check "Re-exported bundle has agent_card" '"agent_card"' "$REBUNDLE"
|
||||
|
||||
# Clean up — use the token just issued to the re-imported workspace
|
||||
curl -s -X DELETE "$BASE/workspaces/$NEW_ID" -H "Authorization: Bearer $NEW_TOKEN" > /dev/null
|
||||
e2e_delete_workspace "$NEW_ID" "$ORIG_NAME" -H "Authorization: Bearer $NEW_TOKEN"
|
||||
|
||||
echo ""
|
||||
echo "=== Results: $PASS passed, $FAIL failed ==="
|
||||
|
||||
@@ -39,6 +39,7 @@ cleanup() {
|
||||
set +e
|
||||
if [ -n "$PARENT" ]; then
|
||||
curl -sS -X DELETE "$BASE/workspaces/$PARENT?confirm=true&purge=true" \
|
||||
-H "X-Confirm-Name: e2e-chat-upload" \
|
||||
${PARENT_TOK:+-H "Authorization: Bearer $PARENT_TOK"} >/dev/null 2>&1
|
||||
fi
|
||||
exit $rc
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
set -euo pipefail
|
||||
|
||||
PLATFORM="http://localhost:8080"
|
||||
export BASE="$PLATFORM"
|
||||
source "$(dirname "$0")/_lib.sh"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
ERRORS=""
|
||||
@@ -38,9 +40,7 @@ else
|
||||
fi
|
||||
|
||||
# --- Clean existing workspaces ---
|
||||
for id in $(curl -s $PLATFORM/workspaces | python3 -c "import sys,json; [print(w['id']) for w in json.load(sys.stdin)]" 2>/dev/null); do
|
||||
curl -s -X DELETE "$PLATFORM/workspaces/$id" > /dev/null
|
||||
done
|
||||
e2e_cleanup_all_workspaces
|
||||
# shellcheck disable=SC2046 # Intentional word-split over container IDs
|
||||
docker stop $(docker ps -q --filter "name=ws-") 2>/dev/null || true
|
||||
# shellcheck disable=SC2046
|
||||
|
||||
@@ -228,10 +228,12 @@ else
|
||||
fi
|
||||
|
||||
# Clean up runtime test workspaces
|
||||
for rt_id in $RT_CC_ID $RT_CX_ID $RT_HM_ID; do
|
||||
curl -s -X DELETE "$BASE/workspaces/$rt_id?confirm=true" > /dev/null 2>&1
|
||||
sleep 0.3
|
||||
done
|
||||
e2e_delete_workspace "$RT_CC_ID" "RT Claude"
|
||||
sleep 0.3
|
||||
e2e_delete_workspace "$RT_CX_ID" "RT Codex"
|
||||
sleep 0.3
|
||||
e2e_delete_workspace "$RT_HM_ID" "RT Hermes"
|
||||
sleep 0.3
|
||||
|
||||
# ============================================================
|
||||
# Section 3: Registry & Heartbeat
|
||||
@@ -550,16 +552,21 @@ check "Import bundle" '"status"' "$R"
|
||||
echo ""
|
||||
echo "--- Section 14: Cleanup & Delete ---"
|
||||
|
||||
# Delete with children — should require confirmation
|
||||
# Delete without name confirmation should be rejected before cascade.
|
||||
R=$(curl -s -X DELETE "$BASE/workspaces/$PM_ID")
|
||||
check "Delete PM requires confirmation" '"confirmation_required"' "$R"
|
||||
check "Delete PM requires name confirmation" '"destructive_action_requires_confirmation"' "$R"
|
||||
|
||||
# Delete with name confirmation but without cascade confirmation should
|
||||
# still require explicit child confirmation.
|
||||
R=$(curl -s -X DELETE "$BASE/workspaces/$PM_ID" -H "X-Confirm-Name: Test PM")
|
||||
check "Delete PM requires cascade confirmation" '"confirmation_required"' "$R"
|
||||
|
||||
# Delete with confirmation
|
||||
R=$(curl -s -X DELETE "$BASE/workspaces/$PM_ID?confirm=true")
|
||||
R=$(curl -s -X DELETE "$BASE/workspaces/$PM_ID?confirm=true" -H "X-Confirm-Name: Test PM")
|
||||
check "Delete PM cascades" '"cascade_deleted"' "$R"
|
||||
|
||||
# Delete outsider
|
||||
curl -s -X DELETE "$BASE/workspaces/$OUTSIDER_ID?confirm=true" > /dev/null
|
||||
e2e_delete_workspace "$OUTSIDER_ID" "Test Outsider"
|
||||
|
||||
# Clean up remaining workspaces (bundle imports, runtime test workspaces, etc.)
|
||||
sleep 2
|
||||
@@ -568,7 +575,7 @@ import json, sys, subprocess, time
|
||||
ws = json.load(sys.stdin)
|
||||
for w in ws:
|
||||
time.sleep(0.5) # avoid rate limit
|
||||
subprocess.run(['curl', '-s', '-X', 'DELETE', '$BASE/workspaces/' + w['id'] + '?confirm=true'], capture_output=True)
|
||||
subprocess.run(['curl', '-s', '-X', 'DELETE', '$BASE/workspaces/' + w['id'] + '?confirm=true', '-H', 'X-Confirm-Name: ' + w.get('name','')], capture_output=True)
|
||||
" 2>/dev/null
|
||||
|
||||
# Poll for clean state up to 30s — DB cascade + container stop is async on busy systems
|
||||
|
||||
@@ -134,7 +134,7 @@ fi
|
||||
# ----------------------------------------------------------------------
|
||||
# Cleanup
|
||||
# ----------------------------------------------------------------------
|
||||
curl -s -X DELETE "$BASE/workspaces/$WS_ID?confirm=true" > /dev/null || true
|
||||
e2e_delete_workspace "$WS_ID" "Dev-Mode-Test"
|
||||
|
||||
echo ""
|
||||
echo "=== Results: $PASS passed, $FAIL failed ==="
|
||||
|
||||
@@ -32,7 +32,7 @@ cleanup() {
|
||||
# Workspace teardown — best-effort, ignore errors so an unrelated CP
|
||||
# outage doesn't shadow a real test failure.
|
||||
if [ -n "$WSID" ]; then
|
||||
curl -s -X DELETE "$BASE/workspaces/$WSID?confirm=true" > /dev/null || true
|
||||
e2e_delete_workspace "$WSID" "Notify E2E"
|
||||
fi
|
||||
# /tmp scratch — pre-fix only ran on success path (the unconditional
|
||||
# rm at the bottom of the script). Trap-based path lets the file leak
|
||||
@@ -89,7 +89,7 @@ except Exception:
|
||||
')
|
||||
for _wid in $PRIOR; do
|
||||
echo "Sweeping leftover Notify E2E workspace: $_wid"
|
||||
curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" > /dev/null || true
|
||||
e2e_delete_workspace "$_wid" "Notify E2E"
|
||||
done
|
||||
|
||||
# model is required at the Create boundary (CTO 2026-05-22 SSOT — see
|
||||
|
||||
@@ -113,7 +113,7 @@ teardown() {
|
||||
log "[teardown] deleting ${#CREATED_WSIDS[@]} workspace(s) this run created (scoped)"
|
||||
for wid in ${CREATED_WSIDS[@]+"${CREATED_WSIDS[@]}"}; do
|
||||
[ -n "$wid" ] || continue
|
||||
curl -s -X DELETE "$BASE/workspaces/$wid?confirm=true" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} >/dev/null 2>&1 || true
|
||||
e2e_delete_workspace "$wid" "" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"}
|
||||
done
|
||||
exit $rc
|
||||
}
|
||||
@@ -131,7 +131,7 @@ except Exception:
|
||||
' 2>/dev/null)
|
||||
for _wid in $PRIOR; do
|
||||
log "Pre-sweeping prior PV-Local workspace: $_wid"
|
||||
curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} >/dev/null 2>&1 || true
|
||||
e2e_delete_workspace "$_wid" "" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"}
|
||||
done
|
||||
|
||||
# ─── Local-stack preflight ─────────────────────────────────────────────
|
||||
|
||||
@@ -48,8 +48,8 @@ TMPDIR_E2E=$(mktemp -d -t poll-chat-upload-e2e-XXXXXX)
|
||||
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
curl -s -X DELETE "$BASE/workspaces/$WS_A?confirm=true" >/dev/null 2>&1 || true
|
||||
curl -s -X DELETE "$BASE/workspaces/$WS_B?confirm=true" >/dev/null 2>&1 || true
|
||||
e2e_delete_workspace "$WS_A" "poll-chat-upload-test-a"
|
||||
e2e_delete_workspace "$WS_B" "poll-chat-upload-test-b"
|
||||
rm -rf "$TMPDIR_E2E"
|
||||
exit $rc
|
||||
}
|
||||
|
||||
@@ -43,8 +43,8 @@ INVALID_PROBE_ID="$(gen_uuid)"
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
# Best-effort delete; non-fatal if the row was never created.
|
||||
curl -s -X DELETE "$BASE/workspaces/$POLL_WS_ID" >/dev/null || true
|
||||
curl -s -X DELETE "$BASE/workspaces/$CALLER_WS_ID" >/dev/null || true
|
||||
e2e_delete_workspace "$POLL_WS_ID" "poll-mode-test"
|
||||
e2e_delete_workspace "$CALLER_WS_ID" "poll-cross-test"
|
||||
exit $rc
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
@@ -53,7 +53,7 @@ cleanup() {
|
||||
# ${VAR[@]+"…"} form expands to nothing when the array is unset/empty
|
||||
# so the loop body is skipped cleanly. Hits the skip-no-keys path.
|
||||
for wid in ${CREATED_WSIDS[@]+"${CREATED_WSIDS[@]}"}; do
|
||||
[ -n "$wid" ] && curl -s -X DELETE "$BASE/workspaces/$wid?confirm=true" > /dev/null || true
|
||||
[ -n "$wid" ] && e2e_delete_workspace "$wid" ""
|
||||
done
|
||||
}
|
||||
trap cleanup EXIT
|
||||
@@ -74,7 +74,7 @@ except Exception:
|
||||
')
|
||||
for _wid in $PRIOR; do
|
||||
echo "Sweeping prior workspace: $_wid"
|
||||
curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" > /dev/null || true
|
||||
e2e_delete_workspace "$_wid" ""
|
||||
done
|
||||
|
||||
# Block until $1 reaches one of $2 (space-separated states), or $3 sec elapse.
|
||||
|
||||
@@ -364,7 +364,13 @@ for wid in "${WS_A_ID:-}" "${WS_B_ID:-}"; do
|
||||
DELETE_AUTH=("${WS_B_AUTH[@]}")
|
||||
fi
|
||||
fi
|
||||
curl -s -X DELETE "$BASE/workspaces/$wid?confirm=true" "${DELETE_AUTH[@]}" > /dev/null || true
|
||||
if [ "$wid" = "${WS_A_ID:-}" ]; then
|
||||
e2e_delete_workspace "$wid" "$WS_A_NAME" "${DELETE_AUTH[@]}"
|
||||
elif [ "$wid" = "${WS_B_ID:-}" ]; then
|
||||
e2e_delete_workspace "$wid" "$WS_B_NAME" "${DELETE_AUTH[@]}"
|
||||
else
|
||||
e2e_delete_workspace "$wid" "" "${DELETE_AUTH[@]}"
|
||||
fi
|
||||
echo "deleted $wid"
|
||||
done
|
||||
|
||||
|
||||
@@ -31,7 +31,11 @@ RECEIVER_TOKEN=""
|
||||
cleanup() {
|
||||
for wid in "$SENDER_ID" "$RECEIVER_ID"; do
|
||||
if [ -n "$wid" ]; then
|
||||
curl -s -X DELETE "$BASE/workspaces/$wid?confirm=true" > /dev/null || true
|
||||
if [ "$wid" = "$SENDER_ID" ]; then
|
||||
e2e_delete_workspace "$wid" "Abilities Sender"
|
||||
else
|
||||
e2e_delete_workspace "$wid" "Abilities Receiver"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
@@ -88,7 +92,7 @@ except Exception:
|
||||
")
|
||||
for _wid in $PRIOR; do
|
||||
echo "Sweeping leftover '$NAME' workspace: $_wid"
|
||||
curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" > /dev/null || true
|
||||
e2e_delete_workspace "$_wid" "$NAME"
|
||||
done
|
||||
done
|
||||
|
||||
|
||||
@@ -64,10 +64,10 @@ var ErrTalkToUserDisabled = errors.New("agent_message: talk_to_user disabled")
|
||||
// distinct so the writer's API doesn't import a handler type with HTTP
|
||||
// binding tags.
|
||||
type AgentMessageAttachment struct {
|
||||
URI string
|
||||
Name string
|
||||
MimeType string
|
||||
Size int64
|
||||
URI string `json:"uri"`
|
||||
Name string `json:"name"`
|
||||
MimeType string `json:"mimeType,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
}
|
||||
|
||||
// AgentMessageWriter persists + broadcasts agent → user messages. Construct
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
@@ -9,8 +10,8 @@ import (
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
// AgentMessageWriter is the SSOT for agent → user chat delivery
|
||||
@@ -302,6 +303,30 @@ func TestAgentMessageWriter_Send_BroadcastsAgentMessageEvent(t *testing.T) {
|
||||
if pl["attachments"] == nil {
|
||||
t.Error("payload.attachments missing on attachment-bearing send")
|
||||
}
|
||||
payloadJSON, err := json.Marshal(pl)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal broadcast payload: %v", err)
|
||||
}
|
||||
var wire struct {
|
||||
Attachments []struct {
|
||||
URI string `json:"uri"`
|
||||
Name string `json:"name"`
|
||||
MimeType string `json:"mimeType,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
} `json:"attachments"`
|
||||
}
|
||||
if err := json.Unmarshal(payloadJSON, &wire); err != nil {
|
||||
t.Fatalf("unmarshal broadcast payload: %v", err)
|
||||
}
|
||||
if len(wire.Attachments) != 1 {
|
||||
t.Fatalf("payload.attachments length = %d, want 1; json=%s", len(wire.Attachments), string(payloadJSON))
|
||||
}
|
||||
if wire.Attachments[0].URI != "workspace://a.txt" || wire.Attachments[0].Name != "a.txt" {
|
||||
t.Fatalf("payload.attachments[0] = %+v, want lowercase uri/name fields; json=%s", wire.Attachments[0], string(payloadJSON))
|
||||
}
|
||||
if bytes.Contains(payloadJSON, []byte(`"URI"`)) || bytes.Contains(payloadJSON, []byte(`"Name"`)) {
|
||||
t.Fatalf("broadcast payload used Go field names instead of canvas wire names: %s", string(payloadJSON))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentMessageWriter_Send_DBErrorOnLookupReturnsWrapped pins the
|
||||
|
||||
@@ -21,6 +21,8 @@ func TestExtended_WorkspaceDelete(t *testing.T) {
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", "/tmp/configs")
|
||||
|
||||
expectWorkspaceDeleteLookup(mock, wsDelID, "Delete Me", 0, "running")
|
||||
|
||||
// Expect children query — no children
|
||||
mock.ExpectQuery("SELECT id, name FROM workspaces WHERE parent_id").
|
||||
WithArgs(wsDelID).
|
||||
@@ -59,6 +61,7 @@ func TestExtended_WorkspaceDelete(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: wsDelID}}
|
||||
c.Request = httptest.NewRequest("DELETE", "/workspaces/"+wsDelID+"?confirm=true", nil)
|
||||
c.Request.Header.Set("X-Confirm-Name", "Delete Me")
|
||||
|
||||
handler.Delete(c)
|
||||
|
||||
|
||||
@@ -612,7 +612,11 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
if err := setModelSecret(ctx, id, payload.Model); err != nil {
|
||||
log.Printf("Create workspace %s: failed to persist MODEL_PROVIDER %q: %v (non-fatal)", id, payload.Model, err)
|
||||
}
|
||||
if derived := deriveProviderFromModelSlug(payload.Model); derived != "" {
|
||||
if explicitProvider := strings.TrimSpace(payload.LLMProvider); explicitProvider != "" {
|
||||
if err := setProviderSecret(ctx, id, explicitProvider); err != nil {
|
||||
log.Printf("Create workspace %s: failed to persist LLM_PROVIDER %q: %v (non-fatal)", id, explicitProvider, err)
|
||||
}
|
||||
} else if derived := deriveProviderFromModelSlug(payload.Model); derived != "" {
|
||||
if err := setProviderSecret(ctx, id, derived); err != nil {
|
||||
log.Printf("Create workspace %s: failed to persist LLM_PROVIDER %q: %v (non-fatal)", id, derived, err)
|
||||
}
|
||||
|
||||
@@ -325,6 +325,37 @@ func (h *WorkspaceHandler) Delete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var workspaceName, workspaceStatus string
|
||||
var activeTasks int
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT name, COALESCE(active_tasks, 0), status FROM workspaces WHERE id = $1`, id,
|
||||
).Scan(&workspaceName, &activeTasks, &workspaceStatus); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||
return
|
||||
}
|
||||
log.Printf("Delete: workspace lookup failed for %s: %v", id, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check workspace"})
|
||||
return
|
||||
}
|
||||
if workspaceStatus == string(models.StatusRemoved) {
|
||||
c.JSON(http.StatusGone, gin.H{"error": "workspace removed", "id": id})
|
||||
return
|
||||
}
|
||||
|
||||
if c.GetHeader("X-Confirm-Name") != workspaceName {
|
||||
childCount, scheduleCount := destructiveDeleteCounts(ctx, id)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "destructive_action_requires_confirmation",
|
||||
"hint": "Re-send the same request with header X-Confirm-Name: " + workspaceName,
|
||||
"workspace_name": workspaceName,
|
||||
"active_tasks": activeTasks,
|
||||
"child_count": childCount,
|
||||
"schedule_count": scheduleCount,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check for children
|
||||
rows, err := db.DB.QueryContext(ctx,
|
||||
`SELECT id, name FROM workspaces WHERE parent_id = $1 AND status != 'removed'`, id)
|
||||
@@ -450,6 +481,22 @@ func (h *WorkspaceHandler) Delete(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "removed", "cascade_deleted": len(descendantIDs)})
|
||||
}
|
||||
|
||||
func destructiveDeleteCounts(ctx context.Context, id string) (childCount int, scheduleCount int) {
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM workspaces WHERE parent_id = $1 AND status != 'removed'`, id,
|
||||
).Scan(&childCount); err != nil {
|
||||
log.Printf("Delete: child count failed for %s: %v", id, err)
|
||||
childCount = 0
|
||||
}
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM workspace_schedules WHERE workspace_id = $1 AND enabled = true`, id,
|
||||
).Scan(&scheduleCount); err != nil {
|
||||
log.Printf("Delete: schedule count failed for %s: %v", id, err)
|
||||
scheduleCount = 0
|
||||
}
|
||||
return childCount, scheduleCount
|
||||
}
|
||||
|
||||
// CascadeDelete performs the cascade-removal sequence used by the HTTP
|
||||
// DELETE handler and by OrgImport's reconcile mode: walk descendants, mark
|
||||
// self+descendants 'removed' first (#73 race guard), stop containers / EC2s,
|
||||
|
||||
@@ -44,6 +44,13 @@ func expectWorkspaceLiveTokenCount(mock sqlmock.Sqlmock, count int) {
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(count))
|
||||
}
|
||||
|
||||
func expectWorkspaceDeleteLookup(mock sqlmock.Sqlmock, id, name string, activeTasks int, status string) {
|
||||
mock.ExpectQuery(`SELECT name, COALESCE\(active_tasks, 0\), status FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(id).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "active_tasks", "status"}).
|
||||
AddRow(name, activeTasks, status))
|
||||
}
|
||||
|
||||
// ---------- State ----------
|
||||
|
||||
func TestState_LegacyWorkspaceNoLiveToken(t *testing.T) {
|
||||
@@ -304,12 +311,15 @@ func TestDelete_HasChildrenWithoutConfirm(t *testing.T) {
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r.DELETE("/workspaces/:id", h.Delete)
|
||||
|
||||
expectWorkspaceDeleteLookup(mock, wsID, "Parent Workspace", 0, "running")
|
||||
|
||||
mock.ExpectQuery(`SELECT id, name FROM workspaces WHERE parent_id = \$1 AND status != 'removed'`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).
|
||||
AddRow("child-1", "Child Workspace"))
|
||||
|
||||
req, _ := http.NewRequest("DELETE", "/workspaces/"+wsID, nil)
|
||||
req.Header.Set("X-Confirm-Name", "Parent Workspace")
|
||||
// No ?confirm=true
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
@@ -330,17 +340,59 @@ func TestDelete_HasChildrenWithoutConfirm(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete_LeafWithoutConfirmName(t *testing.T) {
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
mock, r := setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r.DELETE("/workspaces/:id", h.Delete)
|
||||
|
||||
expectWorkspaceDeleteLookup(mock, wsID, "SEO Agent", 3, "running")
|
||||
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspaces WHERE parent_id = \$1 AND status != 'removed'`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_schedules WHERE workspace_id = \$1 AND enabled = true`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(11))
|
||||
|
||||
req, _ := http.NewRequest("DELETE", "/workspaces/"+wsID, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if resp["error"] != "destructive_action_requires_confirmation" {
|
||||
t.Errorf("error should require destructive confirmation, got %v", resp["error"])
|
||||
}
|
||||
if resp["workspace_name"] != "SEO Agent" {
|
||||
t.Errorf("workspace_name should be surfaced for confirmation")
|
||||
}
|
||||
if resp["active_tasks"] != float64(3) {
|
||||
t.Errorf("active_tasks should be 3, got %v", resp["active_tasks"])
|
||||
}
|
||||
if resp["schedule_count"] != float64(11) {
|
||||
t.Errorf("schedule_count should be 11, got %v", resp["schedule_count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete_ChildrenCheckQueryError(t *testing.T) {
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
mock, r := setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r.DELETE("/workspaces/:id", h.Delete)
|
||||
|
||||
expectWorkspaceDeleteLookup(mock, wsID, "Workspace", 0, "running")
|
||||
mock.ExpectQuery(`SELECT id, name FROM workspaces WHERE parent_id = \$1 AND status != 'removed'`).
|
||||
WithArgs(wsID).
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
req, _ := http.NewRequest("DELETE", "/workspaces/"+wsID, nil)
|
||||
req.Header.Set("X-Confirm-Name", "Workspace")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
|
||||
@@ -337,11 +337,14 @@ func displayControlActor(c *gin.Context) (string, bool) {
|
||||
return actorOrgTokenPrefix + s, true
|
||||
}
|
||||
}
|
||||
if v, ok := c.Get("cp_session_actor"); ok {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
if displayControlIsAdminToken(c) {
|
||||
return actorAdminToken, true
|
||||
}
|
||||
// Browser session auth is intentionally observe-only until AdminAuth
|
||||
// exposes a stable per-user or per-session identity in gin.Context.
|
||||
return "", false
|
||||
}
|
||||
|
||||
|
||||
@@ -258,6 +258,48 @@ func TestWorkspaceDisplayControlAcquire_RejectsCoarseSessionActor(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplayControlAcquire_AcceptsVerifiedBrowserSessionActor(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
t.Setenv("DISPLAY_SESSION_SIGNING_SECRET", "display-session-test-secret")
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
expiresAt := time.Date(2026, 5, 23, 18, 30, 0, 0, time.UTC)
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-display").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
|
||||
mock.ExpectQuery(`INSERT INTO workspace_display_control_locks`).
|
||||
WithArgs("ws-display", "user", "session:abc123", 300).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"controller", "controlled_by", "expires_at"}).
|
||||
AddRow("user", "session:abc123", expiresAt))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/acquire", bytes.NewBufferString(`{"controller":"user","ttl_seconds":300}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Set("cp_session_actor", "session:abc123")
|
||||
|
||||
handler.AcquireDisplayControl(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp["controller"] != "user" || resp["controlled_by"] != "session:abc123" {
|
||||
t.Fatalf("lock response = %#v, want user/session actor", resp)
|
||||
}
|
||||
sessionURL, ok := resp["session_url"].(string)
|
||||
if !ok || !strings.HasPrefix(sessionURL, "/workspaces/ws-display/display/session/websockify#token=") {
|
||||
t.Fatalf("session_url = %#v, want signed websockify URL fragment", resp["session_url"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplayControlRelease_RemovesCallerLock(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
@@ -912,13 +912,14 @@ func applyRuntimeModelEnv(envVars map[string]string, runtime, model string) {
|
||||
|
||||
// applyPlatformManagedLLMEnv wires the control-plane LLM proxy into a
|
||||
// workspace only when the org is in platform-managed mode. Provider keys
|
||||
// never enter the tenant; OPENAI_API_KEY is the tenant token for the CP
|
||||
// OpenAI-compatible proxy.
|
||||
func applyPlatformManagedLLMEnv(envVars map[string]string, _ string, model string) {
|
||||
// never enter the tenant; provider SDK API-key envs receive the tenant token
|
||||
// for the CP proxy only when the workspace has not supplied BYOK/OAuth auth.
|
||||
func applyPlatformManagedLLMEnv(envVars map[string]string, runtime string, model string) {
|
||||
if strings.ToLower(strings.TrimSpace(os.Getenv("MOLECULE_LLM_BILLING_MODE"))) != "platform_managed" {
|
||||
return
|
||||
}
|
||||
baseURL := firstNonEmptyEnv("MOLECULE_LLM_BASE_URL", "OPENAI_BASE_URL")
|
||||
anthropicBaseURL := firstNonEmptyEnv("MOLECULE_LLM_ANTHROPIC_BASE_URL", "ANTHROPIC_BASE_URL")
|
||||
token := firstNonEmptyEnv("MOLECULE_LLM_USAGE_TOKEN", "OPENAI_API_KEY")
|
||||
if baseURL == "" || token == "" {
|
||||
return
|
||||
@@ -927,14 +928,21 @@ func applyPlatformManagedLLMEnv(envVars map[string]string, _ string, model strin
|
||||
envVars["MOLECULE_LLM_BILLING_MODE"] = "platform_managed"
|
||||
envVars["MOLECULE_LLM_BASE_URL"] = baseURL
|
||||
envVars["MOLECULE_LLM_USAGE_TOKEN"] = token
|
||||
if anthropicBaseURL != "" {
|
||||
envVars["MOLECULE_LLM_ANTHROPIC_BASE_URL"] = anthropicBaseURL
|
||||
}
|
||||
if usageURL := strings.TrimSpace(os.Getenv("MOLECULE_LLM_USAGE_URL")); usageURL != "" {
|
||||
envVars["MOLECULE_LLM_USAGE_URL"] = usageURL
|
||||
}
|
||||
|
||||
if strings.TrimSpace(envVars["OPENAI_API_KEY"]) == "" {
|
||||
if strings.TrimSpace(envVars["OPENAI_API_KEY"]) == "" && !runtimeUsesAnthropicNativeProxy(runtime) {
|
||||
envVars["OPENAI_API_KEY"] = token
|
||||
envVars["OPENAI_BASE_URL"] = baseURL
|
||||
}
|
||||
if runtimeUsesAnthropicNativeProxy(runtime) && anthropicBaseURL != "" && workspaceHasNoAnthropicAuth(envVars) {
|
||||
envVars["ANTHROPIC_API_KEY"] = token
|
||||
envVars["ANTHROPIC_BASE_URL"] = anthropicBaseURL
|
||||
}
|
||||
|
||||
if model == "" && strings.TrimSpace(envVars["MOLECULE_MODEL"]) == "" && strings.TrimSpace(envVars["MODEL"]) == "" {
|
||||
if defaultModel := strings.TrimSpace(os.Getenv("MOLECULE_LLM_DEFAULT_MODEL")); defaultModel != "" {
|
||||
@@ -943,6 +951,27 @@ func applyPlatformManagedLLMEnv(envVars map[string]string, _ string, model strin
|
||||
}
|
||||
}
|
||||
|
||||
func runtimeUsesAnthropicNativeProxy(runtime string) bool {
|
||||
return strings.TrimSpace(strings.ToLower(runtime)) == "claude-code"
|
||||
}
|
||||
|
||||
func workspaceHasNoAnthropicAuth(envVars map[string]string) bool {
|
||||
for _, key := range []string{
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"MINIMAX_API_KEY",
|
||||
"KIMI_API_KEY",
|
||||
"GLM_API_KEY",
|
||||
"DEEPSEEK_API_KEY",
|
||||
} {
|
||||
if strings.TrimSpace(envVars[key]) != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func firstNonEmptyEnv(names ...string) string {
|
||||
for _, name := range names {
|
||||
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
|
||||
|
||||
@@ -964,7 +964,7 @@ func TestApplyRuntimeModelEnv_SetsUniversalMODELForAllRuntimes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPlatformManagedLLMEnv_DefaultsOpenAIProxyWhenNoWorkspaceKey(t *testing.T) {
|
||||
func TestApplyPlatformManagedLLMEnv_NonClaudeRuntimeDefaultsOpenAIProxyWhenNoWorkspaceKey(t *testing.T) {
|
||||
t.Setenv("MOLECULE_LLM_BILLING_MODE", "platform_managed")
|
||||
t.Setenv("MOLECULE_LLM_BASE_URL", "https://api.example.test/api/v1/internal/llm/openai/v1")
|
||||
t.Setenv("MOLECULE_LLM_USAGE_TOKEN", "tenant-admin-token")
|
||||
@@ -972,8 +972,8 @@ func TestApplyPlatformManagedLLMEnv_DefaultsOpenAIProxyWhenNoWorkspaceKey(t *tes
|
||||
t.Setenv("MOLECULE_LLM_DEFAULT_MODEL", "moonshot/kimi-k2.6")
|
||||
|
||||
envVars := map[string]string{}
|
||||
applyPlatformManagedLLMEnv(envVars, "claude-code", "")
|
||||
applyRuntimeModelEnv(envVars, "claude-code", "")
|
||||
applyPlatformManagedLLMEnv(envVars, "codex", "")
|
||||
applyRuntimeModelEnv(envVars, "codex", "")
|
||||
|
||||
if got := envVars["OPENAI_BASE_URL"]; got != "https://api.example.test/api/v1/internal/llm/openai/v1" {
|
||||
t.Fatalf("OPENAI_BASE_URL = %q", got)
|
||||
@@ -1018,6 +1018,75 @@ func TestApplyPlatformManagedLLMEnv_DoesNotOverrideWorkspaceOpenAIKey(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPlatformManagedLLMEnv_ClaudeCodeUsesAnthropicProxyWithoutOverwritingOAuth(t *testing.T) {
|
||||
t.Setenv("MOLECULE_LLM_BILLING_MODE", "platform_managed")
|
||||
t.Setenv("MOLECULE_LLM_BASE_URL", "https://api.example.test/api/v1/internal/llm/openai/v1")
|
||||
t.Setenv("MOLECULE_LLM_ANTHROPIC_BASE_URL", "https://api.example.test/api/v1/internal/llm/anthropic/v1")
|
||||
t.Setenv("MOLECULE_LLM_USAGE_TOKEN", "tenant-admin-token")
|
||||
|
||||
envVars := map[string]string{
|
||||
"CLAUDE_CODE_OAUTH_TOKEN": "user-oauth-token",
|
||||
"MODEL": "sonnet",
|
||||
}
|
||||
applyPlatformManagedLLMEnv(envVars, "claude-code", "")
|
||||
|
||||
if got := envVars["CLAUDE_CODE_OAUTH_TOKEN"]; got != "user-oauth-token" {
|
||||
t.Fatalf("CLAUDE_CODE_OAUTH_TOKEN was overwritten: %q", got)
|
||||
}
|
||||
if _, ok := envVars["ANTHROPIC_API_KEY"]; ok {
|
||||
t.Fatalf("ANTHROPIC_API_KEY should not be set when Claude OAuth is present")
|
||||
}
|
||||
if got := envVars["MOLECULE_LLM_ANTHROPIC_BASE_URL"]; got != "https://api.example.test/api/v1/internal/llm/anthropic/v1" {
|
||||
t.Fatalf("MOLECULE_LLM_ANTHROPIC_BASE_URL = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPlatformManagedLLMEnv_ClaudeCodeInjectsAnthropicProxyWhenNoWorkspaceKey(t *testing.T) {
|
||||
t.Setenv("MOLECULE_LLM_BILLING_MODE", "platform_managed")
|
||||
t.Setenv("MOLECULE_LLM_BASE_URL", "https://api.example.test/api/v1/internal/llm/openai/v1")
|
||||
t.Setenv("MOLECULE_LLM_ANTHROPIC_BASE_URL", "https://api.example.test/api/v1/internal/llm/anthropic/v1")
|
||||
t.Setenv("MOLECULE_LLM_USAGE_TOKEN", "tenant-admin-token")
|
||||
|
||||
envVars := map[string]string{}
|
||||
applyPlatformManagedLLMEnv(envVars, "claude-code", "minimax/MiniMax-M2.7")
|
||||
|
||||
if got := envVars["ANTHROPIC_BASE_URL"]; got != "https://api.example.test/api/v1/internal/llm/anthropic/v1" {
|
||||
t.Fatalf("ANTHROPIC_BASE_URL = %q", got)
|
||||
}
|
||||
if got := envVars["ANTHROPIC_API_KEY"]; got != "tenant-admin-token" {
|
||||
t.Fatalf("ANTHROPIC_API_KEY = %q", got)
|
||||
}
|
||||
if got := envVars["MOLECULE_LLM_USAGE_TOKEN"]; got != "tenant-admin-token" {
|
||||
t.Fatalf("MOLECULE_LLM_USAGE_TOKEN = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPlatformManagedLLMEnv_ClaudeCodeDoesNotOverrideVendorBYOK(t *testing.T) {
|
||||
t.Setenv("MOLECULE_LLM_BILLING_MODE", "platform_managed")
|
||||
t.Setenv("MOLECULE_LLM_BASE_URL", "https://api.example.test/api/v1/internal/llm/openai/v1")
|
||||
t.Setenv("MOLECULE_LLM_ANTHROPIC_BASE_URL", "https://api.example.test/api/v1/internal/llm/anthropic/v1")
|
||||
t.Setenv("MOLECULE_LLM_USAGE_TOKEN", "tenant-admin-token")
|
||||
|
||||
envVars := map[string]string{
|
||||
"MINIMAX_API_KEY": "user-minimax-key",
|
||||
"MODEL": "MiniMax-M2.7",
|
||||
}
|
||||
applyPlatformManagedLLMEnv(envVars, "claude-code", "")
|
||||
|
||||
if got := envVars["MINIMAX_API_KEY"]; got != "user-minimax-key" {
|
||||
t.Fatalf("MINIMAX_API_KEY was overwritten: %q", got)
|
||||
}
|
||||
if _, ok := envVars["ANTHROPIC_API_KEY"]; ok {
|
||||
t.Fatalf("ANTHROPIC_API_KEY should not be set when vendor BYOK is present")
|
||||
}
|
||||
if _, ok := envVars["ANTHROPIC_BASE_URL"]; ok {
|
||||
t.Fatalf("ANTHROPIC_BASE_URL should not be set when vendor BYOK is present")
|
||||
}
|
||||
if got := envVars["MOLECULE_LLM_USAGE_TOKEN"]; got != "tenant-admin-token" {
|
||||
t.Fatalf("MOLECULE_LLM_USAGE_TOKEN = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPlatformManagedLLMEnv_NoopsOutsidePlatformManaged(t *testing.T) {
|
||||
t.Setenv("MOLECULE_LLM_BILLING_MODE", "byok")
|
||||
t.Setenv("MOLECULE_LLM_BASE_URL", "https://api.example.test/api/v1/internal/llm/openai/v1")
|
||||
|
||||
@@ -1009,6 +1009,8 @@ func TestWorkspaceDelete_ConfirmationRequired(t *testing.T) {
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
expectWorkspaceDeleteLookup(mock, "cccccccc-0007-0000-0000-000000000000", "Parent Workspace", 0, "running")
|
||||
|
||||
// Children query returns 2 children
|
||||
mock.ExpectQuery("SELECT id, name FROM workspaces WHERE parent_id").
|
||||
WithArgs("cccccccc-0007-0000-0000-000000000000").
|
||||
@@ -1021,6 +1023,7 @@ func TestWorkspaceDelete_ConfirmationRequired(t *testing.T) {
|
||||
c.Params = gin.Params{{Key: "id", Value: "cccccccc-0007-0000-0000-000000000000"}}
|
||||
// No ?confirm=true
|
||||
c.Request = httptest.NewRequest("DELETE", "/workspaces/ws-parent", nil)
|
||||
c.Request.Header.Set("X-Confirm-Name", "Parent Workspace")
|
||||
|
||||
handler.Delete(c)
|
||||
|
||||
@@ -1054,6 +1057,8 @@ func TestWorkspaceDelete_CascadeWithChildren(t *testing.T) {
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
expectWorkspaceDeleteLookup(mock, "cccccccc-000a-0000-0000-000000000000", "Parent Delete", 0, "running")
|
||||
|
||||
// Children query returns 1 child
|
||||
mock.ExpectQuery("SELECT id, name FROM workspaces WHERE parent_id").
|
||||
WithArgs("cccccccc-000a-0000-0000-000000000000").
|
||||
@@ -1089,6 +1094,7 @@ func TestWorkspaceDelete_CascadeWithChildren(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "cccccccc-000a-0000-0000-000000000000"}}
|
||||
c.Request = httptest.NewRequest("DELETE", "/workspaces/ws-parent-del?confirm=true", nil)
|
||||
c.Request.Header.Set("X-Confirm-Name", "Parent Delete")
|
||||
|
||||
handler.Delete(c)
|
||||
|
||||
@@ -1124,6 +1130,8 @@ func TestWorkspaceDelete_DisablesSchedules(t *testing.T) {
|
||||
|
||||
wsID := "dddddddd-0001-0000-0000-000000000000"
|
||||
|
||||
expectWorkspaceDeleteLookup(mock, wsID, "Scheduled Workspace", 0, "running")
|
||||
|
||||
// No children
|
||||
mock.ExpectQuery("SELECT id, name FROM workspaces WHERE parent_id").
|
||||
WithArgs(wsID).
|
||||
@@ -1155,6 +1163,7 @@ func TestWorkspaceDelete_DisablesSchedules(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: wsID}}
|
||||
c.Request = httptest.NewRequest("DELETE", "/workspaces/"+wsID+"?confirm=true", nil)
|
||||
c.Request.Header.Set("X-Confirm-Name", "Scheduled Workspace")
|
||||
|
||||
handler.Delete(c)
|
||||
|
||||
@@ -1180,6 +1189,8 @@ func TestWorkspaceDelete_CascadeDisablesDescendantSchedules(t *testing.T) {
|
||||
childID := "dddddddd-0003-0000-0000-000000000000"
|
||||
grandchildID := "dddddddd-0004-0000-0000-000000000000"
|
||||
|
||||
expectWorkspaceDeleteLookup(mock, parentID, "Parent Scheduled Workspace", 0, "running")
|
||||
|
||||
// Children query returns 1 direct child
|
||||
mock.ExpectQuery("SELECT id, name FROM workspaces WHERE parent_id").
|
||||
WithArgs(parentID).
|
||||
@@ -1219,6 +1230,7 @@ func TestWorkspaceDelete_CascadeDisablesDescendantSchedules(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: parentID}}
|
||||
c.Request = httptest.NewRequest("DELETE", "/workspaces/"+parentID+"?confirm=true", nil)
|
||||
c.Request.Header.Set("X-Confirm-Name", "Parent Scheduled Workspace")
|
||||
|
||||
handler.Delete(c)
|
||||
|
||||
@@ -1252,6 +1264,8 @@ func TestWorkspaceDelete_ScheduleDisableOnlyTargetsDeletedWorkspace(t *testing.T
|
||||
wsA := "dddddddd-0005-0000-0000-000000000000"
|
||||
// wsB is "dddddddd-0006-0000-0000-000000000000" — NOT part of the delete
|
||||
|
||||
expectWorkspaceDeleteLookup(mock, wsA, "Workspace A", 0, "running")
|
||||
|
||||
// No children for workspace A
|
||||
mock.ExpectQuery("SELECT id, name FROM workspaces WHERE parent_id").
|
||||
WithArgs(wsA).
|
||||
@@ -1282,6 +1296,7 @@ func TestWorkspaceDelete_ScheduleDisableOnlyTargetsDeletedWorkspace(t *testing.T
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: wsA}}
|
||||
c.Request = httptest.NewRequest("DELETE", "/workspaces/"+wsA+"?confirm=true", nil)
|
||||
c.Request.Header.Set("X-Confirm-Name", "Workspace A")
|
||||
|
||||
handler.Delete(c)
|
||||
|
||||
@@ -1304,6 +1319,8 @@ func TestWorkspaceDelete_ChildrenQueryError(t *testing.T) {
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
expectWorkspaceDeleteLookup(mock, "cccccccc-000c-0000-0000-000000000000", "Error Workspace", 0, "running")
|
||||
|
||||
mock.ExpectQuery("SELECT id, name FROM workspaces WHERE parent_id").
|
||||
WithArgs("cccccccc-000c-0000-0000-000000000000").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
@@ -1312,6 +1329,7 @@ func TestWorkspaceDelete_ChildrenQueryError(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "cccccccc-000c-0000-0000-000000000000"}}
|
||||
c.Request = httptest.NewRequest("DELETE", "/workspaces/ws-err-del?confirm=true", nil)
|
||||
c.Request.Header.Set("X-Confirm-Name", "Error Workspace")
|
||||
|
||||
handler.Delete(c)
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -196,6 +198,7 @@ func AdminAuth(database *sql.DB) gin.HandlerFunc {
|
||||
// bearer-only path unchanged.
|
||||
if cookieHeader := c.GetHeader("Cookie"); cookieHeader != "" {
|
||||
if ok, _ := VerifiedCPSession(cookieHeader); ok {
|
||||
c.Set("cp_session_actor", cpSessionActor(cookieHeader))
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
@@ -260,6 +263,11 @@ func AdminAuth(database *sql.DB) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func cpSessionActor(cookieHeader string) string {
|
||||
sum := sha256.Sum256([]byte(tenantSlug() + "\x00" + cookieHeader))
|
||||
return "session:" + hex.EncodeToString(sum[:])[:16]
|
||||
}
|
||||
|
||||
// CanvasOrBearer is a softer admin-auth variant used ONLY for cosmetic
|
||||
// canvas routes where forging the request has zero security impact (PUT
|
||||
// /canvas/viewport: worst case an attacker resets the shared viewport
|
||||
|
||||
@@ -284,6 +284,54 @@ func TestAdminAuth_FailOpen_NoTokensGlobally(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAuth_VerifiedCPSession_SetsSessionActor(t *testing.T) {
|
||||
resetSessionCache()
|
||||
srv, hits := mockCPServer(t, http.StatusOK, `{"member":true,"user_id":"u_1","role":"owner","org_id":"org_1"}`)
|
||||
t.Setenv("CP_UPSTREAM_URL", srv.URL)
|
||||
t.Setenv("MOLECULE_ORG_SLUG", "acme")
|
||||
t.Setenv("ADMIN_TOKEN", "admin-secret")
|
||||
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New: %v", err)
|
||||
}
|
||||
defer mockDB.Close()
|
||||
|
||||
mock.ExpectQuery(hasAnyLiveTokenGlobalQuery).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
|
||||
const cookie = "session=valid-browser-session"
|
||||
r := gin.New()
|
||||
r.GET("/workspaces", AdminAuth(mockDB), func(c *gin.Context) {
|
||||
actor, ok := c.Get("cp_session_actor")
|
||||
if !ok {
|
||||
t.Fatalf("expected cp_session_actor in context")
|
||||
}
|
||||
if actor != cpSessionActor(cookie) {
|
||||
t.Fatalf("cp_session_actor = %q, want stable hashed actor", actor)
|
||||
}
|
||||
if actor == cookie {
|
||||
t.Fatalf("cp_session_actor must not expose the raw cookie")
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/workspaces", nil)
|
||||
req.Header.Set("Cookie", cookie)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for verified CP session, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if hits.Load() != 1 {
|
||||
t.Fatalf("expected one CP verification request, got %d", hits.Load())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminAuth_C10_NoBearer_Returns401 — C10 critical path: when at least
|
||||
// one workspace has tokens, GET /admin/secrets without a bearer → 401.
|
||||
func TestAdminAuth_C10_NoBearer_Returns401(t *testing.T) {
|
||||
|
||||
@@ -176,9 +176,13 @@ type CreateWorkspacePayload struct {
|
||||
Template string `json:"template"` // workspace-configs-templates folder name
|
||||
Tier int `json:"tier"`
|
||||
Model string `json:"model"`
|
||||
Runtime string `json:"runtime"` // "claude-code" (default), "codex", etc.
|
||||
External bool `json:"external"` // true = no Docker container, just a registered URL
|
||||
URL string `json:"url"` // for external workspaces: the A2A endpoint URL (push mode only — omit for poll)
|
||||
// LLMProvider is the optional provider slug paired with Model. Runtimes
|
||||
// such as claude-code need a bare model id plus explicit provider slug;
|
||||
// hermes can still derive provider from slash-prefixed model ids.
|
||||
LLMProvider string `json:"llm_provider"`
|
||||
Runtime string `json:"runtime"` // "claude-code" (default), "codex", etc.
|
||||
External bool `json:"external"` // true = no Docker container, just a registered URL
|
||||
URL string `json:"url"` // for external workspaces: the A2A endpoint URL (push mode only — omit for poll)
|
||||
// DeliveryMode: "push" (default) sends inbound A2A to URL synchronously;
|
||||
// "poll" records inbound to activity_logs for the agent to consume via
|
||||
// GET /activity?since_id=. Poll mode does not require a URL. See #2339.
|
||||
|
||||
Reference in New Issue
Block a user