Merge remote-tracking branch 'origin/main' into docs/move-marketing-strategy-to-internal
This commit is contained in:
commit
0582651284
@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useId } from "react";
|
||||
import { useState, useEffect, useRef, useCallback, useId, useMemo } from "react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { api } from "@/lib/api";
|
||||
import { isSaaSTenant } from "@/lib/tenant";
|
||||
|
||||
interface WorkspaceOption {
|
||||
id: string;
|
||||
@ -39,7 +40,6 @@ export function CreateWorkspaceButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [role, setRole] = useState("");
|
||||
const [tier, setTier] = useState(1);
|
||||
const [template, setTemplate] = useState("");
|
||||
const [parentId, setParentId] = useState("");
|
||||
const [budgetLimit, setBudgetLimit] = useState("");
|
||||
@ -51,13 +51,33 @@ export function CreateWorkspaceButton() {
|
||||
const [hermesProvider, setHermesProvider] = useState("anthropic");
|
||||
const [hermesApiKey, setHermesApiKey] = 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
|
||||
// lock to T4 — the full-host access tier, which maps to t3.large at the
|
||||
// CP level. On self-hosted we still offer T1/T2/T3 because the Docker-
|
||||
// sandbox distinction is a real choice there; T4 is available too for
|
||||
// operators who want the full-host tier.
|
||||
//
|
||||
// SSR-safe via isSaaSTenant() contract (returns false on server); first
|
||||
// client render may flip the picker — acceptable one-frame reflow.
|
||||
const isSaaS = useMemo(() => isSaaSTenant(), []);
|
||||
const TIERS = useMemo(
|
||||
() =>
|
||||
isSaaS
|
||||
? [{ value: 4, label: "T4", desc: "Full Access" }]
|
||||
: [
|
||||
{ value: 1, label: "T1", desc: "Sandboxed" },
|
||||
{ value: 2, label: "T2", desc: "Standard" },
|
||||
{ value: 3, label: "T3", desc: "Privileged" },
|
||||
{ value: 4, label: "T4", desc: "Full Access" },
|
||||
],
|
||||
[isSaaS],
|
||||
);
|
||||
const defaultTier = isSaaS ? 4 : 1;
|
||||
const [tier, setTier] = useState(defaultTier);
|
||||
|
||||
// Refs for roving tabIndex on the tier radio group (WCAG 2.1 arrow-key nav)
|
||||
const radioRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const TIERS = [
|
||||
{ value: 1, label: "T1", desc: "Sandboxed" },
|
||||
{ value: 2, label: "T2", desc: "Standard" },
|
||||
{ value: 3, label: "T3", desc: "Full Access" },
|
||||
];
|
||||
|
||||
const handleRadioKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent, currentIndex: number) => {
|
||||
@ -85,7 +105,7 @@ export function CreateWorkspaceButton() {
|
||||
if (!open) return;
|
||||
setName("");
|
||||
setRole("");
|
||||
setTier(1);
|
||||
setTier(defaultTier);
|
||||
setTemplate("");
|
||||
setParentId("");
|
||||
setBudgetLimit("");
|
||||
@ -96,6 +116,9 @@ export function CreateWorkspaceButton() {
|
||||
.get<WorkspaceOption[]>("/workspaces")
|
||||
.then((ws) => setWorkspaces(ws))
|
||||
.catch(() => {});
|
||||
// defaultTier is stable for the session (derived from window.location),
|
||||
// safe to omit from deps.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
@ -209,10 +232,10 @@ export function CreateWorkspaceButton() {
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Workspace tier"
|
||||
className="grid grid-cols-3 gap-1.5"
|
||||
className={`grid gap-1.5 ${isSaaS ? "grid-cols-1" : "grid-cols-4"}`}
|
||||
>
|
||||
<div className="col-span-3 text-[11px] text-zinc-400 mb-1">
|
||||
Tier
|
||||
<div className={`text-[11px] text-zinc-400 mb-1 ${isSaaS ? "" : "col-span-4"}`}>
|
||||
Tier{isSaaS ? " — dedicated VM" : ""}
|
||||
</div>
|
||||
{TIERS.map((t, idx) => (
|
||||
<button
|
||||
|
||||
@ -77,7 +77,9 @@ describe("CreateWorkspaceDialog — accessibility", () => {
|
||||
it("tier buttons have role=radio and aria-checked reflects selection", async () => {
|
||||
await openDialog();
|
||||
const radios = screen.getAllByRole("radio");
|
||||
expect(radios.length).toBe(3);
|
||||
// Non-SaaS build (jsdom hostname is localhost) shows all four tiers:
|
||||
// T1 Sandboxed, T2 Standard, T3 Privileged, T4 Full Access.
|
||||
expect(radios.length).toBe(4);
|
||||
// T1 is default selection
|
||||
const t1 = radios.find((r) => r.textContent?.includes("T1"));
|
||||
const t2 = radios.find((r) => r.textContent?.includes("T2"));
|
||||
@ -98,10 +100,12 @@ describe("CreateWorkspaceDialog — accessibility", () => {
|
||||
const t1 = radios.find((r) => r.textContent?.includes("T1"))!;
|
||||
const t2 = radios.find((r) => r.textContent?.includes("T2"))!;
|
||||
const t3 = radios.find((r) => r.textContent?.includes("T3"))!;
|
||||
// T1 is default selected
|
||||
const t4 = radios.find((r) => r.textContent?.includes("T4"))!;
|
||||
// T1 is default selected (non-SaaS test env; SaaS would default to T4)
|
||||
expect(t1.getAttribute("tabindex")).toBe("0");
|
||||
expect(t2.getAttribute("tabindex")).toBe("-1");
|
||||
expect(t3.getAttribute("tabindex")).toBe("-1");
|
||||
expect(t4.getAttribute("tabindex")).toBe("-1");
|
||||
});
|
||||
|
||||
it("ArrowDown moves selection from T1 to T2", async () => {
|
||||
@ -127,15 +131,15 @@ describe("CreateWorkspaceDialog — accessibility", () => {
|
||||
await waitFor(() => expect(t3.getAttribute("aria-checked")).toBe("true"));
|
||||
});
|
||||
|
||||
it("ArrowDown wraps from T3 back to T1", async () => {
|
||||
it("ArrowDown wraps from T4 back to T1", async () => {
|
||||
await openDialog();
|
||||
const radios = screen.getAllByRole("radio");
|
||||
const t1 = radios.find((r) => r.textContent?.includes("T1"))!;
|
||||
const t3 = radios.find((r) => r.textContent?.includes("T3"))!;
|
||||
fireEvent.click(t3); // select T3 first
|
||||
await waitFor(() => expect(t3.getAttribute("aria-checked")).toBe("true"));
|
||||
t3.focus();
|
||||
fireEvent.keyDown(t3, { key: "ArrowDown" });
|
||||
const t4 = radios.find((r) => r.textContent?.includes("T4"))!;
|
||||
fireEvent.click(t4); // select T4 (last) first
|
||||
await waitFor(() => expect(t4.getAttribute("aria-checked")).toBe("true"));
|
||||
t4.focus();
|
||||
fireEvent.keyDown(t4, { key: "ArrowDown" });
|
||||
await waitFor(() => expect(t1.getAttribute("aria-checked")).toBe("true"));
|
||||
});
|
||||
|
||||
@ -151,14 +155,14 @@ describe("CreateWorkspaceDialog — accessibility", () => {
|
||||
await waitFor(() => expect(t1.getAttribute("aria-checked")).toBe("true"));
|
||||
});
|
||||
|
||||
it("ArrowLeft wraps from T1 back to T3", async () => {
|
||||
it("ArrowLeft wraps from T1 back to T4", async () => {
|
||||
await openDialog();
|
||||
const radios = screen.getAllByRole("radio");
|
||||
const t1 = radios.find((r) => r.textContent?.includes("T1"))!;
|
||||
const t3 = radios.find((r) => r.textContent?.includes("T3"))!;
|
||||
const t4 = radios.find((r) => r.textContent?.includes("T4"))!;
|
||||
t1.focus();
|
||||
fireEvent.keyDown(t1, { key: "ArrowLeft" });
|
||||
await waitFor(() => expect(t3.getAttribute("aria-checked")).toBe("true"));
|
||||
await waitFor(() => expect(t4.getAttribute("aria-checked")).toBe("true"));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import type { SecretGroup } from '@/types/secrets';
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import { useSecretsStore } from '@/stores/secrets-store';
|
||||
import { KeyValueField } from '@/components/ui/KeyValueField';
|
||||
import { ValidationHint } from '@/components/ui/ValidationHint';
|
||||
@ -10,7 +9,7 @@ import {
|
||||
isValidKeyName,
|
||||
inferGroup,
|
||||
} from '@/lib/validation/secret-formats';
|
||||
import { SERVICES, SERVICE_GROUP_ORDER, getDefaultKeyName } from '@/lib/services';
|
||||
import { SERVICES, KEY_NAME_SUGGESTIONS } from '@/lib/services';
|
||||
|
||||
const VALIDATION_DEBOUNCE_MS = 400;
|
||||
|
||||
@ -23,9 +22,21 @@ interface AddKeyFormProps {
|
||||
/**
|
||||
* Inline-expanding form for adding a new API key.
|
||||
*
|
||||
* Flow (from spec §4.2):
|
||||
* Form Open → select service → key name auto-fills → type value →
|
||||
* optional Test Connection → Save
|
||||
* Design note (2026-04-22): the form used to open with a Service
|
||||
* dropdown (GitHub / Anthropic / OpenRouter / Other) gating what to
|
||||
* do next. That added friction — the storage layer only cares about
|
||||
* (key_name, value), and the provider can always be inferred from the
|
||||
* key name itself. We removed the dropdown and rely on:
|
||||
*
|
||||
* - A datalist of common key-name suggestions so autocomplete
|
||||
* replaces "pick a provider then the name auto-fills"
|
||||
* - inferGroup(keyName) to classify the secret for validation +
|
||||
* list-view grouping + test-connection routing, derived at render
|
||||
* time from what the user actually typed
|
||||
*
|
||||
* Result: fewer fields, provider-agnostic by design, no UI code change
|
||||
* needed to onboard a new provider (MiniMax, DeepSeek, etc. just work
|
||||
* as soon as you type their canonical env var name).
|
||||
*/
|
||||
export function AddKeyForm({
|
||||
workspaceId,
|
||||
@ -34,8 +45,7 @@ export function AddKeyForm({
|
||||
}: AddKeyFormProps) {
|
||||
const createSecret = useSecretsStore((s) => s.createSecret);
|
||||
|
||||
const [selectedGroup, setSelectedGroup] = useState<SecretGroup>('github');
|
||||
const [keyName, setKeyName] = useState(getDefaultKeyName('github'));
|
||||
const [keyName, setKeyName] = useState('');
|
||||
const [value, setValue] = useState('');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [keyNameError, setKeyNameError] = useState<string | null>(null);
|
||||
@ -43,23 +53,13 @@ export function AddKeyForm({
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const service = SERVICES[selectedGroup];
|
||||
|
||||
// Auto-fill key name when service changes
|
||||
const handleServiceChange = useCallback(
|
||||
(group: SecretGroup) => {
|
||||
setSelectedGroup(group);
|
||||
const defaultName = getDefaultKeyName(group);
|
||||
if (defaultName) {
|
||||
setKeyName(defaultName);
|
||||
}
|
||||
// Reset validation
|
||||
setValidationError(null);
|
||||
setKeyNameError(null);
|
||||
setSaveError(null);
|
||||
},
|
||||
[],
|
||||
);
|
||||
// Group is derived, not selected. Falls back to 'custom' for any
|
||||
// key name that doesn't match a known provider pattern — validation
|
||||
// and test-connection still work, just without provider-specific
|
||||
// format hints.
|
||||
const inferredGroup = useMemo(() => inferGroup(keyName || ''), [keyName]);
|
||||
const service = SERVICES[inferredGroup];
|
||||
|
||||
// Validate key name
|
||||
useEffect(() => {
|
||||
@ -78,7 +78,7 @@ export function AddKeyForm({
|
||||
setKeyNameError(null);
|
||||
}, [keyName, existingNames]);
|
||||
|
||||
// Debounced value validation
|
||||
// Debounced value validation against the inferred provider's format.
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setValidationError(null);
|
||||
@ -86,18 +86,17 @@ export function AddKeyForm({
|
||||
}
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setValidationError(validateSecretValue(value, selectedGroup));
|
||||
setValidationError(validateSecretValue(value, inferredGroup));
|
||||
}, VALIDATION_DEBOUNCE_MS);
|
||||
return () => clearTimeout(debounceRef.current);
|
||||
}, [value, selectedGroup]);
|
||||
}, [value, inferredGroup]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
// Final validation pass
|
||||
if (!isValidKeyName(keyName)) {
|
||||
setKeyNameError('Key name must be UPPER_SNAKE_CASE');
|
||||
return;
|
||||
}
|
||||
const valErr = validateSecretValue(value, selectedGroup);
|
||||
const valErr = validateSecretValue(value, inferredGroup);
|
||||
if (valErr) {
|
||||
setValidationError(valErr);
|
||||
return;
|
||||
@ -114,32 +113,21 @@ export function AddKeyForm({
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [keyName, value, selectedGroup, createSecret, workspaceId]);
|
||||
}, [keyName, value, inferredGroup, createSecret, workspaceId]);
|
||||
|
||||
const canSave = keyName && value && !keyNameError && !validationError && !isSaving;
|
||||
|
||||
// Show the provider-specific docs hint only when the key name
|
||||
// matches a known provider. For 'custom' (unknown key name) we stay
|
||||
// quiet — no false-structure prompt.
|
||||
const showProviderHint = inferredGroup !== 'custom' && service.docsUrl;
|
||||
|
||||
return (
|
||||
<div className="add-key-form">
|
||||
<div className="add-key-form__header">Add New Key</div>
|
||||
|
||||
{/* Service selector */}
|
||||
<label className="add-key-form__label">
|
||||
Service
|
||||
<select
|
||||
value={selectedGroup}
|
||||
onChange={(e) => handleServiceChange(e.target.value as SecretGroup)}
|
||||
disabled={isSaving}
|
||||
className="add-key-form__select"
|
||||
>
|
||||
{SERVICE_GROUP_ORDER.map((group) => (
|
||||
<option key={group} value={group}>
|
||||
{SERVICES[group].label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* Key name */}
|
||||
{/* Key name — autocomplete replaces the old Service dropdown.
|
||||
inferGroup(keyName) derives classification at render time. */}
|
||||
<label className="add-key-form__label">
|
||||
Key name
|
||||
<input
|
||||
@ -147,14 +135,32 @@ export function AddKeyForm({
|
||||
value={keyName}
|
||||
onChange={(e) => setKeyName(e.target.value.toUpperCase())}
|
||||
disabled={isSaving}
|
||||
placeholder="MY_API_KEY"
|
||||
placeholder="e.g. ANTHROPIC_API_KEY, MINIMAX_API_KEY, GITHUB_TOKEN"
|
||||
className="add-key-form__input"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
list="add-key-name-suggestions"
|
||||
/>
|
||||
</label>
|
||||
{keyNameError && (
|
||||
<ValidationHint error={keyNameError} />
|
||||
<datalist id="add-key-name-suggestions">
|
||||
{KEY_NAME_SUGGESTIONS.map((name) => (
|
||||
<option key={name} value={name} />
|
||||
))}
|
||||
</datalist>
|
||||
{keyNameError && <ValidationHint error={keyNameError} />}
|
||||
{showProviderHint && (
|
||||
<div className="add-key-form__hint" data-testid="provider-hint">
|
||||
<span className="add-key-form__hint-label">{service.label}</span>
|
||||
{' — '}
|
||||
<a
|
||||
href={service.docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="add-key-form__hint-link"
|
||||
>
|
||||
get a key
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key value */}
|
||||
@ -172,22 +178,21 @@ export function AddKeyForm({
|
||||
showValid={!validationError && value.length > 0}
|
||||
/>
|
||||
|
||||
{/* Test connection (only for supported services) */}
|
||||
{/* Test connection (only when the inferred group supports it AND
|
||||
value looks format-valid). */}
|
||||
{service.testSupported && value && !validationError && (
|
||||
<TestConnectionButton
|
||||
provider={selectedGroup}
|
||||
provider={inferredGroup}
|
||||
secretValue={value}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Save error */}
|
||||
{saveError && (
|
||||
<div className="add-key-form__error" role="alert">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="add-key-form__actions">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -1,10 +1,18 @@
|
||||
import type { ServiceConfig, SecretGroup } from '@/types/secrets';
|
||||
|
||||
/**
|
||||
* Static service registry. Each known provider maps to its display
|
||||
* properties, expected key names, and whether test-connection is supported.
|
||||
* Static service registry — used for LIST-view rendering: the
|
||||
* per-group icon, the "get a key" docs link shown as a hint once
|
||||
* the user types a matching key name, and the test-connection
|
||||
* routing for the 3 providers with backend test endpoints.
|
||||
*
|
||||
* Keys not matching any known service fall into the "custom" catch-all.
|
||||
*
|
||||
* Note (2026-04-22): the Add-Key form no longer uses this as a
|
||||
* user-facing dropdown. It reads keyNames[0] via getDefaultKeyName
|
||||
* — still referenced by a couple of legacy call sites — and the
|
||||
* Add form's autocomplete source lives in KEY_NAME_SUGGESTIONS
|
||||
* below. SERVICES is purely for post-save display + test routing.
|
||||
*/
|
||||
export const SERVICES: Record<SecretGroup, ServiceConfig> = {
|
||||
github: {
|
||||
@ -49,3 +57,43 @@ export const SERVICE_GROUP_ORDER: SecretGroup[] = [
|
||||
export function getDefaultKeyName(group: SecretGroup): string {
|
||||
return SERVICES[group].keyNames[0] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Autocomplete suggestions for the Add-Key form's key-name input.
|
||||
*
|
||||
* Covers the providers hermes-agent supports natively + the common
|
||||
* infra keys (GitHub, platform-side). Adding a new provider here is
|
||||
* a one-line change — the Add form picks it up via <datalist>, and
|
||||
* classification (for validation + list grouping) comes from
|
||||
* inferGroup in lib/validation/secret-formats.ts.
|
||||
*
|
||||
* Order: alphabetical for stable display in autocomplete popups.
|
||||
*/
|
||||
export const KEY_NAME_SUGGESTIONS: readonly string[] = [
|
||||
'AI_GATEWAY_API_KEY',
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ARCEEAI_API_KEY',
|
||||
'COPILOT_GITHUB_TOKEN',
|
||||
'DASHSCOPE_API_KEY',
|
||||
'DEEPSEEK_API_KEY',
|
||||
'GEMINI_API_KEY',
|
||||
'GH_TOKEN',
|
||||
'GITHUB_TOKEN',
|
||||
'GLM_API_KEY',
|
||||
'GOOGLE_API_KEY',
|
||||
'HERMES_API_KEY',
|
||||
'HF_TOKEN',
|
||||
'KILOCODE_API_KEY',
|
||||
'KIMI_API_KEY',
|
||||
'KIMI_CN_API_KEY',
|
||||
'MINIMAX_API_KEY',
|
||||
'MINIMAX_CN_API_KEY',
|
||||
'NOUS_API_KEY',
|
||||
'NVIDIA_API_KEY',
|
||||
'OLLAMA_API_KEY',
|
||||
'OPENAI_API_KEY',
|
||||
'OPENCODE_GO_API_KEY',
|
||||
'OPENCODE_ZEN_API_KEY',
|
||||
'OPENROUTER_API_KEY',
|
||||
'XIAOMI_API_KEY',
|
||||
] as const;
|
||||
|
||||
@ -54,3 +54,18 @@ export function getTenantSlug(): string {
|
||||
if (reservedSubdomains.has(slug)) return "";
|
||||
return slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* isSaaSTenant reports whether the canvas is running as the UI for a
|
||||
* SaaS tenant (served at <slug>.moleculesai.app). Use for client-side
|
||||
* UX branches that should behave differently on SaaS vs self-hosted —
|
||||
* e.g. the workspace tier picker hides T1/T2 sandbox tiers because every
|
||||
* SaaS workspace gets its own EC2 VM (inherently T3 Full Access).
|
||||
*
|
||||
* SSR-safe: returns false on the server to avoid hydration drift; call
|
||||
* sites should tolerate a flip from false→true on first client render.
|
||||
*/
|
||||
export function isSaaSTenant(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return getTenantSlug() !== "";
|
||||
}
|
||||
|
||||
@ -12,12 +12,16 @@ import (
|
||||
// preventing A2A requests from being redirected to internal/cloud-metadata
|
||||
// infrastructure (SSRF, CWE-918). Workspace URLs come from DB/Redis caches
|
||||
// so we validate before making any outbound HTTP call.
|
||||
//
|
||||
// SaaS relaxation: when saasMode() is true, RFC-1918 private ranges and
|
||||
// IPv6 ULA are considered safe because workspaces live on sibling EC2s in
|
||||
// the same VPC and register by their VPC-private IP. Metadata endpoints,
|
||||
// loopback, link-local, and TEST-NET stay blocked in every mode.
|
||||
func isSafeURL(rawURL string) error {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
// Reject non-HTTP(S) schemes.
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("forbidden scheme: %s (only http/https allowed)", u.Scheme)
|
||||
}
|
||||
@ -25,20 +29,17 @@ func isSafeURL(rawURL string) error {
|
||||
if host == "" {
|
||||
return fmt.Errorf("empty hostname")
|
||||
}
|
||||
// Block direct IP addresses.
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() {
|
||||
return fmt.Errorf("forbidden loopback/unspecified IP: %s", ip)
|
||||
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() {
|
||||
return fmt.Errorf("forbidden loopback/unspecified/link-local IP: %s", ip)
|
||||
}
|
||||
if isPrivateOrMetadataIP(ip) {
|
||||
return fmt.Errorf("forbidden private/metadata IP: %s", ip)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// For hostnames, resolve and validate each returned IP.
|
||||
addrs, err := net.LookupHost(host)
|
||||
if err != nil {
|
||||
// DNS resolution failure — block it. Could be an internal hostname.
|
||||
return fmt.Errorf("DNS resolution blocked for hostname: %s (%v)", host, err)
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
@ -46,38 +47,112 @@ func isSafeURL(rawURL string) error {
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
ip := net.ParseIP(addr)
|
||||
if ip != nil && (ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || isPrivateOrMetadataIP(ip)) {
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() {
|
||||
return fmt.Errorf("hostname %s resolves to forbidden link-local/loopback IP: %s", host, ip)
|
||||
}
|
||||
if isPrivateOrMetadataIP(ip) {
|
||||
return fmt.Errorf("hostname %s resolves to forbidden IP: %s", host, ip)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isPrivateOrMetadataIP returns true for RFC-1918 private, carrier-grade NAT,
|
||||
// link-local, and cloud metadata ranges.
|
||||
// isPrivateOrMetadataIP returns true for IPs that must not be reached via A2A.
|
||||
//
|
||||
// Always blocked (both modes):
|
||||
// - 169.254.0.0/16 link-local (cloud metadata endpoints)
|
||||
// - 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24 (TEST-NET RFC-5737)
|
||||
// - 100.64.0.0/10 (carrier-grade NAT)
|
||||
// - IPv6 loopback ::1, link-local fe80::/10, and ULA fc00::/7 in strict mode
|
||||
//
|
||||
// Allowed in SaaS mode only (saasMode() == true):
|
||||
// - 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 (RFC-1918)
|
||||
// - fd00::/8 (IPv6 ULA subset of fc00::/7)
|
||||
//
|
||||
// Rationale: SaaS tenants run workspaces on sibling EC2s in the same VPC
|
||||
// and register them by VPC-private IP. The control plane provisions these
|
||||
// instances, so intra-VPC routing is trusted. On self-hosted / single-
|
||||
// container deployments the relaxation is off and every private range
|
||||
// stays blocked.
|
||||
func isPrivateOrMetadataIP(ip net.IP) bool {
|
||||
var privateRanges = []net.IPNet{
|
||||
{IP: net.ParseIP("10.0.0.0"), Mask: net.CIDRMask(8, 32)},
|
||||
{IP: net.ParseIP("172.16.0.0"), Mask: net.CIDRMask(12, 32)},
|
||||
{IP: net.ParseIP("192.168.0.0"), Mask: net.CIDRMask(16, 32)},
|
||||
{IP: net.ParseIP("169.254.0.0"), Mask: net.CIDRMask(16, 32)},
|
||||
{IP: net.ParseIP("100.64.0.0"), Mask: net.CIDRMask(10, 32)},
|
||||
{IP: net.ParseIP("192.0.2.0"), Mask: net.CIDRMask(24, 32)},
|
||||
{IP: net.ParseIP("198.51.100.0"), Mask: net.CIDRMask(24, 32)},
|
||||
{IP: net.ParseIP("203.0.113.0"), Mask: net.CIDRMask(24, 32)},
|
||||
}
|
||||
ip = ip.To4()
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
for _, r := range privateRanges {
|
||||
if r.Contains(ip) {
|
||||
saas := saasMode()
|
||||
|
||||
// IPv4 path.
|
||||
if ip4 := ip.To4(); ip4 != nil {
|
||||
// Metadata link-local — always blocked.
|
||||
if metadataV4.Contains(ip4) {
|
||||
return true
|
||||
}
|
||||
// TEST-NET / documentation — always blocked.
|
||||
for _, r := range docRangesV4 {
|
||||
if r.Contains(ip4) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Carrier-grade NAT — always blocked.
|
||||
if cgnatV4.Contains(ip4) {
|
||||
return true
|
||||
}
|
||||
// RFC-1918 private — blocked strict, allowed in SaaS.
|
||||
for _, r := range privateV4 {
|
||||
if r.Contains(ip4) {
|
||||
return !saas
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IPv6 path — .To4() was nil so this is a real v6 address.
|
||||
// ::1 (loopback) — treat as blocked here too for defense-in-depth.
|
||||
if ip.IsLoopback() {
|
||||
return true
|
||||
}
|
||||
// Link-local fe80::/10 — always blocked.
|
||||
if ip.IsLinkLocalUnicast() {
|
||||
return true
|
||||
}
|
||||
// ULA fc00::/7. fd00::/8 is the "locally assigned" half AWS hands out;
|
||||
// fc00::/8 is reserved. We treat the whole fc00::/7 as private, then
|
||||
// let SaaS relax fd00::/8 (matches the tests).
|
||||
if ulaV6.Contains(ip) {
|
||||
if saas && fd00V6.Contains(ip) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var (
|
||||
metadataV4 = mustCIDR("169.254.0.0/16")
|
||||
cgnatV4 = mustCIDR("100.64.0.0/10")
|
||||
|
||||
privateV4 = []net.IPNet{
|
||||
mustCIDR("10.0.0.0/8"),
|
||||
mustCIDR("172.16.0.0/12"),
|
||||
mustCIDR("192.168.0.0/16"),
|
||||
}
|
||||
docRangesV4 = []net.IPNet{
|
||||
mustCIDR("192.0.2.0/24"),
|
||||
mustCIDR("198.51.100.0/24"),
|
||||
mustCIDR("203.0.113.0/24"),
|
||||
}
|
||||
|
||||
ulaV6 = mustCIDR("fc00::/7")
|
||||
fd00V6 = mustCIDR("fd00::/8")
|
||||
)
|
||||
|
||||
func mustCIDR(s string) net.IPNet {
|
||||
_, n, err := net.ParseCIDR(s)
|
||||
if err != nil {
|
||||
panic("ssrf: bad CIDR " + s + ": " + err.Error())
|
||||
}
|
||||
return *n
|
||||
}
|
||||
|
||||
// validateRelPath checks that a file path is relative and does not escape
|
||||
// the destination via absolute paths or ".." traversal. Used by
|
||||
// copyFilesToContainer and deleteViaEphemeral as a defence-in-depth measure.
|
||||
@ -87,4 +162,4 @@ func validateRelPath(filePath string) error {
|
||||
return fmt.Errorf("path traversal or absolute path not allowed: %s", filePath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
182
workspace-server/internal/handlers/template_files_eic.go
Normal file
182
workspace-server/internal/handlers/template_files_eic.go
Normal file
@ -0,0 +1,182 @@
|
||||
package handlers
|
||||
|
||||
// template_files_eic.go — SSH-backed file write for SaaS workspaces
|
||||
// (EC2-per-workspace). Pairs with the existing Docker-path in templates.go
|
||||
// (WriteFile) and template_import.go (ReplaceFiles).
|
||||
//
|
||||
// Flow for a single file write:
|
||||
// 1. Generate ephemeral ed25519 keypair (on-disk for ≤ write duration).
|
||||
// 2. Push the public key via `aws ec2-instance-connect send-ssh-public-key`
|
||||
// so the target sshd accepts it for the next 60s.
|
||||
// 3. Open a TLS-tunnelled TCP port via `aws ec2-instance-connect open-tunnel`
|
||||
// from a local free port → workspace's sshd on 22.
|
||||
// 4. Pipe content to `ssh ... "install -D -m 0644 /dev/stdin <abs path>"`.
|
||||
// `install -D` creates any missing parent dirs atomically. File is owned
|
||||
// by whichever $OSUser we authenticated as (ubuntu by default).
|
||||
// 5. Close tunnel + wipe keydir.
|
||||
//
|
||||
// All the AWS calls + ssh tunnel exec go through the same package-level
|
||||
// func vars defined in terminal.go (openTunnelCmd, sendSSHPublicKey) so
|
||||
// tests can stub them the same way the terminal tests do.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// workspaceFilePathPrefix maps a runtime name to the absolute base path on
|
||||
// the workspace EC2 where the Files API's relative paths land. New runtimes
|
||||
// can be added here without touching handler code.
|
||||
//
|
||||
// Keep these stable — changing the base path for an existing runtime
|
||||
// without a migration shim will make previously-saved files disappear from
|
||||
// the runtime's POV.
|
||||
var workspaceFilePathPrefix = map[string]string{
|
||||
"hermes": "/home/ubuntu/.hermes",
|
||||
"langgraph": "/opt/configs",
|
||||
"external": "/opt/configs",
|
||||
// Default for unknown / future runtimes is /opt/configs — most
|
||||
// conservative place that doesn't collide with system or runtime-
|
||||
// private directories.
|
||||
}
|
||||
|
||||
func resolveWorkspaceFilePath(runtime, relPath string) (string, error) {
|
||||
if err := validateRelPath(relPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
base, ok := workspaceFilePathPrefix[strings.ToLower(strings.TrimSpace(runtime))]
|
||||
if !ok {
|
||||
base = "/opt/configs"
|
||||
}
|
||||
return filepath.Join(base, filepath.Clean(relPath)), nil
|
||||
}
|
||||
|
||||
// eicFileWriteTimeout bounds the whole dance. Key push is <500ms, tunnel
|
||||
// is 1-2s, ssh + write is <2s. 30s gives headroom for slow pulls without
|
||||
// hanging the Files API forever under EIC misconfiguration.
|
||||
const eicFileWriteTimeout = 30 * time.Second
|
||||
|
||||
// writeFileViaEIC writes a single file to the workspace EC2 at the
|
||||
// absolute path that resolveWorkspaceFilePath computed. On success,
|
||||
// optionally invokes the runtime's reload hook (not implemented yet —
|
||||
// tracked as follow-up; for today the canvas issues a separate Restart
|
||||
// after Save).
|
||||
//
|
||||
// instanceID: AWS EC2 instance id from workspaces.instance_id.
|
||||
// runtime: used only for path-prefix resolution.
|
||||
// relPath: the relative path the caller validated (no /, no ..).
|
||||
// content: file body bytes.
|
||||
func writeFileViaEIC(ctx context.Context, instanceID, runtime, relPath string, content []byte) error {
|
||||
if instanceID == "" {
|
||||
return fmt.Errorf("workspace has no instance_id — not a SaaS EC2 workspace")
|
||||
}
|
||||
absPath, err := resolveWorkspaceFilePath(runtime, relPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
|
||||
osUser := os.Getenv("WORKSPACE_EC2_OS_USER")
|
||||
if osUser == "" {
|
||||
osUser = "ubuntu"
|
||||
}
|
||||
region := os.Getenv("AWS_REGION")
|
||||
if region == "" {
|
||||
region = "us-east-2"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, eicFileWriteTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Ephemeral keypair.
|
||||
keyDir, err := os.MkdirTemp("", "molecule-filewrite-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("keydir mkdir: %w", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(keyDir) }()
|
||||
keyPath := keyDir + "/id"
|
||||
if out, kerr := exec.CommandContext(ctx, "ssh-keygen",
|
||||
"-t", "ed25519", "-f", keyPath, "-N", "", "-q",
|
||||
"-C", "molecule-filewrite",
|
||||
).CombinedOutput(); kerr != nil {
|
||||
return fmt.Errorf("ssh-keygen: %w (%s)", kerr, strings.TrimSpace(string(out)))
|
||||
}
|
||||
pubKey, err := os.ReadFile(keyPath + ".pub")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read pubkey: %w", err)
|
||||
}
|
||||
|
||||
// 1. Push key.
|
||||
if err := sendSSHPublicKey(ctx, region, instanceID, osUser, strings.TrimSpace(string(pubKey))); err != nil {
|
||||
return fmt.Errorf("send-ssh-public-key: %w", err)
|
||||
}
|
||||
|
||||
// 2. Open tunnel on an OS-picked free port.
|
||||
localPort, err := pickFreePort()
|
||||
if err != nil {
|
||||
return fmt.Errorf("pick free port: %w", err)
|
||||
}
|
||||
opts := eicSSHOptions{
|
||||
InstanceID: instanceID,
|
||||
OSUser: osUser,
|
||||
Region: region,
|
||||
LocalPort: localPort,
|
||||
PrivateKeyPath: keyPath,
|
||||
}
|
||||
tunnel := openTunnelCmd(opts)
|
||||
tunnel.Env = os.Environ()
|
||||
if err := tunnel.Start(); err != nil {
|
||||
return fmt.Errorf("open-tunnel start: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if tunnel.Process != nil {
|
||||
_ = tunnel.Process.Kill()
|
||||
}
|
||||
_ = tunnel.Wait()
|
||||
}()
|
||||
if err := waitForPort(ctx, "127.0.0.1", localPort, 10*time.Second); err != nil {
|
||||
return fmt.Errorf("tunnel never listened: %w", err)
|
||||
}
|
||||
|
||||
// 3. SSH + install -D. `install` creates any missing parent dirs and
|
||||
// writes the file atomically via temp-file-rename. Permissions 0644
|
||||
// match the existing tar-unpack defaults on the Docker path.
|
||||
//
|
||||
// The remote command is fully deterministic — no user-controlled
|
||||
// input reaches a shell eval (absPath is built from a map + Clean()).
|
||||
sshArgs := []string{
|
||||
"-i", keyPath,
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"-o", "ServerAliveInterval=15",
|
||||
"-p", fmt.Sprintf("%d", localPort),
|
||||
fmt.Sprintf("%s@127.0.0.1", osUser),
|
||||
fmt.Sprintf("install -D -m 0644 /dev/stdin %s", shellQuote(absPath)),
|
||||
}
|
||||
sshCmd := exec.CommandContext(ctx, "ssh", sshArgs...)
|
||||
sshCmd.Env = os.Environ()
|
||||
sshCmd.Stdin = bytes.NewReader(content)
|
||||
var stderr bytes.Buffer
|
||||
sshCmd.Stderr = &stderr
|
||||
if err := sshCmd.Run(); err != nil {
|
||||
return fmt.Errorf("ssh install: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
log.Printf("writeFileViaEIC: ws instance=%s runtime=%s wrote %d bytes → %s",
|
||||
instanceID, runtime, len(content), absPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// shellQuote wraps a value in single quotes + escapes embedded single
|
||||
// quotes for POSIX sh. Used for the sole piece of variable data in the
|
||||
// remote ssh command. (absPath is already built from a map + Clean() so
|
||||
// traversal is blocked regardless; this is defence-in-depth against
|
||||
// future refactor that might accept user paths here.)
|
||||
func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestResolveWorkspaceFilePath_KnownRuntimes — the runtime → base-path
|
||||
// map is the source of truth for where saved files land on the workspace
|
||||
// EC2. Changing a base path without a migration shim silently orphans
|
||||
// previously-saved files; this test pins the current contract.
|
||||
func TestResolveWorkspaceFilePath_KnownRuntimes(t *testing.T) {
|
||||
cases := []struct {
|
||||
runtime string
|
||||
relPath string
|
||||
want string
|
||||
}{
|
||||
{"hermes", "config.yaml", "/home/ubuntu/.hermes/config.yaml"},
|
||||
{"HERMES", "config.yaml", "/home/ubuntu/.hermes/config.yaml"}, // case-insensitive
|
||||
{"hermes", "nested/a.yaml", "/home/ubuntu/.hermes/nested/a.yaml"},
|
||||
{"langgraph", "config.yaml", "/opt/configs/config.yaml"},
|
||||
{"external", "skills.json", "/opt/configs/skills.json"},
|
||||
{"", "config.yaml", "/opt/configs/config.yaml"}, // empty → default
|
||||
{"unknown", "config.yaml", "/opt/configs/config.yaml"}, // unknown → default
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.runtime+"/"+tc.relPath, func(t *testing.T) {
|
||||
got, err := resolveWorkspaceFilePath(tc.runtime, tc.relPath)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("resolveWorkspaceFilePath(%q,%q) = %q, want %q",
|
||||
tc.runtime, tc.relPath, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveWorkspaceFilePath_RejectsTraversal — any attempt to escape
|
||||
// the runtime base path via .. or absolute paths must return an error
|
||||
// before the ssh install runs. validateRelPath uses filepath.Clean then
|
||||
// checks for `..` or absolute prefix, so cases like `a/../b` are
|
||||
// NORMALIZED to `b` and accepted (still safe — stays inside base).
|
||||
// We only assert the cases that Clean() can't rescue.
|
||||
func TestResolveWorkspaceFilePath_RejectsTraversal(t *testing.T) {
|
||||
bad := []string{
|
||||
"../etc/shadow", // escapes base via ..
|
||||
"/etc/shadow", // absolute path
|
||||
"./../../etc", // multiple ..
|
||||
"a/../../etc", // escapes via deeper ..
|
||||
}
|
||||
for _, rel := range bad {
|
||||
t.Run(rel, func(t *testing.T) {
|
||||
_, err := resolveWorkspaceFilePath("hermes", rel)
|
||||
if err == nil {
|
||||
t.Errorf("resolveWorkspaceFilePath(hermes, %q) should have errored, got nil", rel)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestShellQuote — the sole piece of variable data in the remote ssh
|
||||
// command is the absolute path. It's already built from a map + Clean()
|
||||
// so traversal is impossible, but we still single-quote as defence-in-
|
||||
// depth. Verify the shell-quoting helper handles the single-quote edge
|
||||
// case and is always wrapped in single quotes.
|
||||
func TestShellQuote(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"/home/ubuntu/.hermes/config.yaml": "'/home/ubuntu/.hermes/config.yaml'",
|
||||
"": "''",
|
||||
"a'b": `'a'\''b'`,
|
||||
}
|
||||
for in, want := range cases {
|
||||
t.Run(in, func(t *testing.T) {
|
||||
got := shellQuote(in)
|
||||
if got != want {
|
||||
t.Errorf("shellQuote(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
if !strings.HasPrefix(got, "'") || !strings.HasSuffix(got, "'") {
|
||||
t.Errorf("shellQuote(%q) = %q must be single-quote wrapped", in, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -174,8 +174,11 @@ func (h *TemplatesHandler) ReplaceFiles(c *gin.Context) {
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
var wsName string
|
||||
if err := db.DB.QueryRowContext(ctx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName); err != nil {
|
||||
var wsName, instanceID, runtime string
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT name, COALESCE(instance_id, ''), COALESCE(runtime, '') FROM workspaces WHERE id = $1`,
|
||||
workspaceID,
|
||||
).Scan(&wsName, &instanceID, &runtime); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||
return
|
||||
}
|
||||
@ -188,6 +191,28 @@ func (h *TemplatesHandler) ReplaceFiles(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// SaaS workspace (EC2-per-workspace) — route bulk write through the
|
||||
// EIC endpoint, one SSH session per file. Per-file cost is ~3s
|
||||
// (key push + tunnel + install), so up to 10 files is fine; above
|
||||
// that we should reuse the tunnel across multiple writes — tracked
|
||||
// as a follow-up.
|
||||
if instanceID != "" {
|
||||
for relPath, content := range body.Files {
|
||||
if err := writeFileViaEIC(ctx, instanceID, runtime, relPath, []byte(content)); err != nil {
|
||||
log.Printf("ReplaceFiles EIC for %s path=%s: %v", workspaceID, relPath, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to write file %s: %v", relPath, err)})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "replaced",
|
||||
"workspace": workspaceID,
|
||||
"files": len(body.Files),
|
||||
"source": "ec2-ssh",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Write via Docker CopyToContainer when container is running
|
||||
if containerName := h.findContainer(ctx, workspaceID); containerName != "" {
|
||||
if err := h.copyFilesToContainer(ctx, containerName, "/configs", body.Files); err != nil {
|
||||
|
||||
@ -353,13 +353,28 @@ func (h *TemplatesHandler) WriteFile(c *gin.Context) {
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
var wsName string
|
||||
if err := db.DB.QueryRowContext(ctx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName); err != nil {
|
||||
var wsName, instanceID, runtime string
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT name, COALESCE(instance_id, ''), COALESCE(runtime, '') FROM workspaces WHERE id = $1`,
|
||||
workspaceID,
|
||||
).Scan(&wsName, &instanceID, &runtime); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Write via Docker CopyToContainer when container is running
|
||||
// SaaS workspace (EC2-per-workspace) — no Docker on this tenant. Write
|
||||
// via SSH through the EIC endpoint to the runtime-specific path.
|
||||
if instanceID != "" {
|
||||
if err := writeFileViaEIC(ctx, instanceID, runtime, filePath, []byte(body.Content)); err != nil {
|
||||
log.Printf("WriteFile EIC for %s path=%s: %v", workspaceID, filePath, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to write file: %v", err)})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "saved", "path": filePath})
|
||||
return
|
||||
}
|
||||
|
||||
// Local Docker path — write via CopyToContainer when container is running
|
||||
if containerName := h.findContainer(ctx, workspaceID); containerName != "" {
|
||||
singleFile := map[string]string{filePath: body.Content}
|
||||
if err := h.copyFilesToContainer(ctx, containerName, "/configs", singleFile); err != nil {
|
||||
|
||||
@ -142,8 +142,13 @@ func Validate(ctx context.Context, db *sql.DB, plaintext string) (id, prefix, or
|
||||
// symptom of abuse or a bug — the hard cap prevents one runaway
|
||||
// minting loop from O(N) pageloads in the admin UI.
|
||||
func List(ctx context.Context, db *sql.DB) ([]Token, error) {
|
||||
// org_id is a UUID column — COALESCE must cast to text first,
|
||||
// otherwise Postgres rejects the empty-string literal with
|
||||
// "pq: invalid input syntax for type uuid: ''". sqlmock doesn't
|
||||
// exercise pq type coercion, so this bug only surfaces against
|
||||
// a real Postgres (prod).
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, prefix, COALESCE(name,''), COALESCE(org_id,''),
|
||||
SELECT id, prefix, COALESCE(name,''), COALESCE(org_id::text,''),
|
||||
COALESCE(created_by,''), created_at, last_used_at
|
||||
FROM org_api_tokens
|
||||
WHERE revoked_at IS NULL
|
||||
|
||||
@ -18,11 +18,12 @@ import (
|
||||
//
|
||||
// Auto-activated when MOLECULE_ORG_ID is set (SaaS tenant).
|
||||
type CPProvisioner struct {
|
||||
baseURL string
|
||||
orgID string
|
||||
sharedSecret string // Authorization: Bearer — platform-wide gate
|
||||
adminToken string // X-Molecule-Admin-Token — per-tenant identity (controlplane #118/#130)
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
orgID string
|
||||
sharedSecret string // Authorization: Bearer — gates /cp/workspaces/* (provision routes)
|
||||
adminToken string // X-Molecule-Admin-Token — per-tenant identity (controlplane #118/#130)
|
||||
cpAdminAPIKey string // Authorization: Bearer — gates /cp/admin/* (read-only ops routes; distinct secret from sharedSecret)
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewCPProvisioner creates a provisioner that delegates to the control plane.
|
||||
@ -58,17 +59,26 @@ func NewCPProvisioner() (*CPProvisioner, error) {
|
||||
// bootstrap path). Without it, post-#118 CP rejects every
|
||||
// /cp/workspaces/* call with 401.
|
||||
adminToken := os.Getenv("ADMIN_TOKEN")
|
||||
// CP_ADMIN_API_TOKEN gates /cp/admin/* (distinct from the provision
|
||||
// shared secret so a compromised tenant's provision creds can't read
|
||||
// other tenants' serial console). Falls back to sharedSecret only for
|
||||
// dev / legacy self-hosted deployments that don't split the two.
|
||||
cpAdminAPIKey := os.Getenv("CP_ADMIN_API_TOKEN")
|
||||
if cpAdminAPIKey == "" {
|
||||
cpAdminAPIKey = sharedSecret
|
||||
}
|
||||
|
||||
return &CPProvisioner{
|
||||
baseURL: baseURL,
|
||||
orgID: orgID,
|
||||
sharedSecret: sharedSecret,
|
||||
adminToken: adminToken,
|
||||
httpClient: &http.Client{Timeout: 120 * time.Second},
|
||||
baseURL: baseURL,
|
||||
orgID: orgID,
|
||||
sharedSecret: sharedSecret,
|
||||
adminToken: adminToken,
|
||||
cpAdminAPIKey: cpAdminAPIKey,
|
||||
httpClient: &http.Client{Timeout: 120 * time.Second},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// authHeaders sets both auth headers on the outbound request:
|
||||
// provisionAuthHeaders sets the auth headers for /cp/workspaces/* routes:
|
||||
// - Authorization: Bearer <shared secret> — platform gate
|
||||
// - X-Molecule-Admin-Token: <per-tenant token> — identity gate
|
||||
//
|
||||
@ -76,7 +86,7 @@ func NewCPProvisioner() (*CPProvisioner, error) {
|
||||
// deployments without a real CP still work (those don't hit a CP that
|
||||
// enforces either gate). In prod both are set by the controlplane
|
||||
// bootstrap, so both headers land on every outbound call.
|
||||
func (p *CPProvisioner) authHeaders(req *http.Request) {
|
||||
func (p *CPProvisioner) provisionAuthHeaders(req *http.Request) {
|
||||
if p.sharedSecret != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+p.sharedSecret)
|
||||
}
|
||||
@ -85,6 +95,23 @@ func (p *CPProvisioner) authHeaders(req *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// adminAuthHeaders sets the auth header for /cp/admin/* routes. The CP
|
||||
// gates this route family with CP_ADMIN_API_TOKEN — a distinct secret
|
||||
// from the provision-route shared secret so a compromised tenant can't
|
||||
// read other tenants' serial console via /cp/admin/workspaces/:id/console.
|
||||
//
|
||||
// The per-tenant X-Molecule-Admin-Token is still included for parity
|
||||
// with the provision path (CP may cross-check it for audit attribution
|
||||
// even on admin calls).
|
||||
func (p *CPProvisioner) adminAuthHeaders(req *http.Request) {
|
||||
if p.cpAdminAPIKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+p.cpAdminAPIKey)
|
||||
}
|
||||
if p.adminToken != "" {
|
||||
req.Header.Set("X-Molecule-Admin-Token", p.adminToken)
|
||||
}
|
||||
}
|
||||
|
||||
type cpProvisionRequest struct {
|
||||
OrgID string `json:"org_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
@ -123,7 +150,7 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
|
||||
return "", fmt.Errorf("cp provisioner: create request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
p.authHeaders(httpReq)
|
||||
p.provisionAuthHeaders(httpReq)
|
||||
|
||||
resp, err := p.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
@ -158,7 +185,7 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
|
||||
func (p *CPProvisioner) Stop(ctx context.Context, workspaceID string) error {
|
||||
url := fmt.Sprintf("%s/cp/workspaces/%s?instance_id=%s", p.baseURL, workspaceID, workspaceID)
|
||||
req, _ := http.NewRequestWithContext(ctx, "DELETE", url, nil)
|
||||
p.authHeaders(req)
|
||||
p.provisionAuthHeaders(req)
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cp provisioner: stop: %w", err)
|
||||
@ -194,7 +221,7 @@ func (p *CPProvisioner) Stop(ctx context.Context, workspaceID string) error {
|
||||
func (p *CPProvisioner) IsRunning(ctx context.Context, workspaceID string) (bool, error) {
|
||||
url := fmt.Sprintf("%s/cp/workspaces/%s/status?instance_id=%s", p.baseURL, workspaceID, workspaceID)
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
p.authHeaders(req)
|
||||
p.provisionAuthHeaders(req)
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return true, fmt.Errorf("cp provisioner: status: %w", err)
|
||||
@ -226,7 +253,7 @@ func (p *CPProvisioner) IsRunning(ctx context.Context, workspaceID string) (bool
|
||||
func (p *CPProvisioner) GetConsoleOutput(ctx context.Context, workspaceID string) (string, error) {
|
||||
url := fmt.Sprintf("%s/cp/admin/workspaces/%s/console", p.baseURL, workspaceID)
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
p.authHeaders(req)
|
||||
p.adminAuthHeaders(req)
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cp provisioner: console: %w", err)
|
||||
|
||||
@ -40,13 +40,13 @@ func TestNewCPProvisioner_FallsBackToProvisionSharedSecret(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthHeaders_NoopWhenBothEmpty — the self-hosted path that
|
||||
// doesn't gate /cp/workspaces/* must not add stray auth headers
|
||||
// TestProvisionAuthHeaders_NoopWhenBothEmpty — the self-hosted path
|
||||
// that doesn't gate /cp/workspaces/* must not add stray auth headers
|
||||
// (bearer-like content would surprise non-bearer intermediaries).
|
||||
func TestAuthHeaders_NoopWhenBothEmpty(t *testing.T) {
|
||||
func TestProvisionAuthHeaders_NoopWhenBothEmpty(t *testing.T) {
|
||||
p := &CPProvisioner{sharedSecret: "", adminToken: ""}
|
||||
req := httptest.NewRequest("GET", "http://x/", nil)
|
||||
p.authHeaders(req)
|
||||
p.provisionAuthHeaders(req)
|
||||
if got := req.Header.Get("Authorization"); got != "" {
|
||||
t.Errorf("Authorization set to %q with empty secret; want unset", got)
|
||||
}
|
||||
@ -55,13 +55,13 @@ func TestAuthHeaders_NoopWhenBothEmpty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthHeaders_SetsBothWhenBothProvided — happy path for SaaS
|
||||
// tenants. Both the platform-wide shared secret and the per-tenant
|
||||
// TestProvisionAuthHeaders_SetsBothWhenBothProvided — happy path for
|
||||
// SaaS tenants. Both the platform-wide shared secret and the per-tenant
|
||||
// admin_token land on every outbound call.
|
||||
func TestAuthHeaders_SetsBothWhenBothProvided(t *testing.T) {
|
||||
func TestProvisionAuthHeaders_SetsBothWhenBothProvided(t *testing.T) {
|
||||
p := &CPProvisioner{sharedSecret: "the-secret", adminToken: "tok-abc"}
|
||||
req := httptest.NewRequest("GET", "http://x/", nil)
|
||||
p.authHeaders(req)
|
||||
p.provisionAuthHeaders(req)
|
||||
if got := req.Header.Get("Authorization"); got != "Bearer the-secret" {
|
||||
t.Errorf("Authorization = %q, want %q", got, "Bearer the-secret")
|
||||
}
|
||||
@ -70,14 +70,14 @@ func TestAuthHeaders_SetsBothWhenBothProvided(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthHeaders_OnlyAdminTokenWhenSecretEmpty — in the transition
|
||||
// window where the tenant has admin_token but PROVISION_SHARED_SECRET
|
||||
// isn't set, still send the admin token. CP middleware decides whether
|
||||
// the shared secret is required.
|
||||
func TestAuthHeaders_OnlyAdminTokenWhenSecretEmpty(t *testing.T) {
|
||||
// TestProvisionAuthHeaders_OnlyAdminTokenWhenSecretEmpty — in the
|
||||
// transition window where the tenant has admin_token but
|
||||
// PROVISION_SHARED_SECRET isn't set, still send the admin token. CP
|
||||
// middleware decides whether the shared secret is required.
|
||||
func TestProvisionAuthHeaders_OnlyAdminTokenWhenSecretEmpty(t *testing.T) {
|
||||
p := &CPProvisioner{sharedSecret: "", adminToken: "tok-abc"}
|
||||
req := httptest.NewRequest("GET", "http://x/", nil)
|
||||
p.authHeaders(req)
|
||||
p.provisionAuthHeaders(req)
|
||||
if got := req.Header.Get("Authorization"); got != "" {
|
||||
t.Errorf("Authorization = %q, want unset", got)
|
||||
}
|
||||
@ -86,6 +86,75 @@ func TestAuthHeaders_OnlyAdminTokenWhenSecretEmpty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminAuthHeaders_UsesCPAdminAPIKeyNotSharedSecret — /cp/admin/*
|
||||
// routes are gated by CP_ADMIN_API_TOKEN on the CP side (distinct from
|
||||
// PROVISION_SHARED_SECRET). The tenant must send the admin key as the
|
||||
// bearer on these routes or CP returns 401.
|
||||
func TestAdminAuthHeaders_UsesCPAdminAPIKeyNotSharedSecret(t *testing.T) {
|
||||
p := &CPProvisioner{
|
||||
sharedSecret: "provision-secret",
|
||||
adminToken: "tok-abc",
|
||||
cpAdminAPIKey: "admin-api-key",
|
||||
}
|
||||
req := httptest.NewRequest("GET", "http://x/", nil)
|
||||
p.adminAuthHeaders(req)
|
||||
if got := req.Header.Get("Authorization"); got != "Bearer admin-api-key" {
|
||||
t.Errorf("Authorization = %q, want %q", got, "Bearer admin-api-key")
|
||||
}
|
||||
if got := req.Header.Get("X-Molecule-Admin-Token"); got != "tok-abc" {
|
||||
t.Errorf("X-Molecule-Admin-Token = %q, want tok-abc", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminAuthHeaders_FallsBackToSharedSecretWhenAdminKeyUnset —
|
||||
// self-hosted and dev deployments set PROVISION_SHARED_SECRET but not
|
||||
// CP_ADMIN_API_TOKEN. Fall back so single-secret setups keep working
|
||||
// (CP in those deployments either accepts both bearers or doesn't gate
|
||||
// /cp/admin/*).
|
||||
func TestAdminAuthHeaders_FallsBackToSharedSecretWhenAdminKeyUnset(t *testing.T) {
|
||||
p := &CPProvisioner{
|
||||
sharedSecret: "provision-secret",
|
||||
adminToken: "tok-abc",
|
||||
cpAdminAPIKey: "provision-secret", // NewCPProvisioner sets this when env is unset
|
||||
}
|
||||
req := httptest.NewRequest("GET", "http://x/", nil)
|
||||
p.adminAuthHeaders(req)
|
||||
if got := req.Header.Get("Authorization"); got != "Bearer provision-secret" {
|
||||
t.Errorf("Authorization = %q, want fallback %q", got, "Bearer provision-secret")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewCPProvisioner_ReadsCPAdminAPIToken — env-to-field wiring.
|
||||
// When CP_ADMIN_API_TOKEN is set, cpAdminAPIKey picks it up.
|
||||
func TestNewCPProvisioner_ReadsCPAdminAPIToken(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ORG_ID", "org-abc")
|
||||
t.Setenv("MOLECULE_CP_SHARED_SECRET", "shared")
|
||||
t.Setenv("CP_ADMIN_API_TOKEN", "admin-key")
|
||||
p, err := NewCPProvisioner()
|
||||
if err != nil {
|
||||
t.Fatalf("NewCPProvisioner: %v", err)
|
||||
}
|
||||
if p.cpAdminAPIKey != "admin-key" {
|
||||
t.Errorf("cpAdminAPIKey = %q, want %q", p.cpAdminAPIKey, "admin-key")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewCPProvisioner_CPAdminAPITokenFallsBackToSharedSecret —
|
||||
// operators that don't split the two secrets (dev / self-hosted) still
|
||||
// get a working admin bearer via the fallback.
|
||||
func TestNewCPProvisioner_CPAdminAPITokenFallsBackToSharedSecret(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ORG_ID", "org-abc")
|
||||
t.Setenv("MOLECULE_CP_SHARED_SECRET", "shared")
|
||||
t.Setenv("CP_ADMIN_API_TOKEN", "")
|
||||
p, err := NewCPProvisioner()
|
||||
if err != nil {
|
||||
t.Fatalf("NewCPProvisioner: %v", err)
|
||||
}
|
||||
if p.cpAdminAPIKey != "shared" {
|
||||
t.Errorf("cpAdminAPIKey fallback = %q, want %q", p.cpAdminAPIKey, "shared")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStart_HappyPath — Start posts to the stubbed CP, passes the
|
||||
// bearer, and parses the returned instance_id.
|
||||
func TestStart_HappyPath(t *testing.T) {
|
||||
@ -516,3 +585,46 @@ func TestClose_Noop(t *testing.T) {
|
||||
t.Errorf("Close should return nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetConsoleOutput_UsesAdminBearer — regression guard for the
|
||||
// split-bearer fix. /cp/admin/workspaces/:id/console must send
|
||||
// Authorization: Bearer <cpAdminAPIKey>, NOT <sharedSecret>.
|
||||
// Previously the tenant sent sharedSecret → CP 401 → tenant 502 on
|
||||
// the "View Logs" UI. Symptom log: "cp provisioner: console: unexpected 401"
|
||||
// on hongmingwang prod tenant, 2026-04-22.
|
||||
func TestGetConsoleOutput_UsesAdminBearer(t *testing.T) {
|
||||
var sawBearer, sawMethod, sawPath string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sawBearer = r.Header.Get("Authorization")
|
||||
sawMethod = r.Method
|
||||
sawPath = r.URL.Path
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, `{"output":"boot log"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := &CPProvisioner{
|
||||
baseURL: srv.URL,
|
||||
orgID: "org-1",
|
||||
sharedSecret: "provision-secret-do-not-use-here",
|
||||
adminToken: "tok-xyz",
|
||||
cpAdminAPIKey: "admin-api-key",
|
||||
httpClient: srv.Client(),
|
||||
}
|
||||
out, err := p.GetConsoleOutput(context.Background(), "ws-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetConsoleOutput: %v", err)
|
||||
}
|
||||
if out != "boot log" {
|
||||
t.Errorf("output = %q, want %q", out, "boot log")
|
||||
}
|
||||
if sawMethod != "GET" {
|
||||
t.Errorf("method = %q, want GET", sawMethod)
|
||||
}
|
||||
if sawPath != "/cp/admin/workspaces/ws-1/console" {
|
||||
t.Errorf("path = %q, want /cp/admin/workspaces/ws-1/console", sawPath)
|
||||
}
|
||||
if sawBearer != "Bearer admin-api-key" {
|
||||
t.Errorf("bearer = %q, want Bearer admin-api-key (NOT the provision secret)", sawBearer)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user