Merge remote-tracking branch 'origin/main' into docs/move-marketing-strategy-to-internal

This commit is contained in:
Hongming Wang 2026-04-22 18:46:31 -07:00
commit 0582651284
13 changed files with 762 additions and 141 deletions

View File

@ -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

View File

@ -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"));
});
});

View File

@ -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"

View File

@ -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;

View File

@ -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 falsetrue on first client render.
*/
export function isSaaSTenant(): boolean {
if (typeof window === "undefined") return false;
return getTenantSlug() !== "";
}

View File

@ -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
}
}

View 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, "'", `'\''`) + "'"
}

View File

@ -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)
}
})
}
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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

View File

@ -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)

View File

@ -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)
}
}