Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 436fae8949 | |||
| 2d1a853bf9 | |||
| 5551ef40e3 | |||
| 656176d511 | |||
| 1424af51fa | |||
| 7f0f33739b | |||
| 339d73d9d4 | |||
| 50fe4976e6 | |||
| e05fc4daae |
@@ -0,0 +1,186 @@
|
||||
# ci-arm64-advisory — Mac arm64 self-hosted ADVISORY fast-check lane.
|
||||
#
|
||||
# === WHY ===
|
||||
#
|
||||
# The amd64 Gitea runner pool (molecule-runner-1..20) is queue-contended
|
||||
# (internal#418). This lane offloads the *genuinely container-independent*
|
||||
# fast checks (Go build/vet/lint, shellcheck, Python lint) onto the Mac
|
||||
# arm64 self-hosted runner so developers get a fast arm64 signal WITHOUT
|
||||
# adding load to the starved amd64 pool — capability-honestly, as an
|
||||
# additive pilot. Pilot ② of the Mac-CI strategy (CTO-delegated 2026-05-17).
|
||||
#
|
||||
# === NON-NEGOTIABLE SAFETY CONTRACT (the prime directive) ===
|
||||
#
|
||||
# This lane is **ADVISORY ONLY**. It is provably incapable of hanging a
|
||||
# merge. Concretely:
|
||||
#
|
||||
# 1. It is a SEPARATE workflow file. `ci.yml` is byte-for-byte
|
||||
# untouched by this PR. The `CI / all-required` aggregator sentinel
|
||||
# and the five contexts it polls
|
||||
# (`CI / Detect changes|Platform (Go)|Canvas (Next.js)|
|
||||
# Shellcheck (E2E scripts)|Python Lint & Test (pull_request)`)
|
||||
# are unchanged. The canonical required gate stays 100% on the
|
||||
# existing amd64 pool.
|
||||
#
|
||||
# 2. The context this workflow emits is
|
||||
# `ci-arm64-advisory / fast-checks (pull_request)`. That string is
|
||||
# DELIBERATELY NOT present in, and this PR does NOT add it to:
|
||||
# - branch_protections/{main,staging}.status_check_contexts
|
||||
# (DB-verified pb 86/75 = exactly
|
||||
# ["CI / all-required (pull_request)",
|
||||
# "sop-checklist / all-items-acked (pull_request)"])
|
||||
# - audit-force-merge.yml REQUIRED_CHECKS env
|
||||
# - ci.yml `all-required` sentinel's hardcoded `required[]` list
|
||||
# Branch protection therefore never waits on this context. If the
|
||||
# Mac runner is absent / offline / removed, this workflow's status
|
||||
# simply never appears — and because nothing requires it, every
|
||||
# merge proceeds exactly as it does today. There is no path by
|
||||
# which a missing/red arm64 status blocks a merge.
|
||||
#
|
||||
# 3. `continue-on-error: true` on the job — even a genuine arm64-only
|
||||
# failure (toolchain drift, arch-specific test flake) is surfaced
|
||||
# as information, never as a merge blocker, for the duration of
|
||||
# the pilot.
|
||||
#
|
||||
# 4. The job carries a `github.event_name` `if:` gate. Beyond its
|
||||
# functional purpose this also keeps the job OUT of
|
||||
# `ci-required-drift.py:ci_job_names()` (which excludes
|
||||
# `github.event_name`/`github.ref`-gated jobs), so the hourly
|
||||
# ci-required-drift sentinel's F1 ("job not under sentinel needs")
|
||||
# cannot ever flag this advisory job. F2/F3 are untouched because
|
||||
# this context is absent from BP and from REQUIRED_CHECKS.
|
||||
# `lint-bp-context-emit-match` only fails on BP→emitter gaps; an
|
||||
# emitter without a BP context is explicitly informational there.
|
||||
#
|
||||
# === RUNNER TARGETING ===
|
||||
#
|
||||
# The Mac runner is `hongming-pc-runner-1`. The bare `self-hosted`
|
||||
# label is POLLUTED in this Gitea instance: molecule-runner-1..20
|
||||
# (the contended amd64 pool) also advertise `self-hosted`. Targeting
|
||||
# bare `self-hosted` would route back onto the very pool we are trying
|
||||
# to relieve — and onto amd64 hardware. We therefore require an
|
||||
# AND-set of labels that ONLY the Mac satisfies. `macos-self-hosted`
|
||||
# is Mac-exclusive (the amd64 pool does not carry it). Until the
|
||||
# label-install burst (a10862b2) lands `self-hosted`+`macos-self-hosted`
|
||||
# on the Mac, the runner's current unique label `hongming-pc-laptop`
|
||||
# is also listed; AND-semantics over the labels a runner advertises
|
||||
# means a job requiring [self-hosted, macos-self-hosted] can ONLY be
|
||||
# claimed once the Mac advertises both. If neither label set is yet
|
||||
# present on the Mac, the workflow stays queued harmlessly and is
|
||||
# garbage-collected by the normal stale-run reaper — it blocks nothing
|
||||
# (see safety contract point 2).
|
||||
#
|
||||
# === ROLLBACK ===
|
||||
#
|
||||
# Delete this single file (`git rm .gitea/workflows/ci-arm64-advisory.yml`)
|
||||
# and merge. No branch-protection edit, no ci.yml edit, no
|
||||
# REQUIRED_CHECKS edit is required to roll back, because none were made
|
||||
# to roll forward. Zero blast radius either direction.
|
||||
|
||||
name: ci-arm64-advisory
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, staging]
|
||||
pull_request:
|
||||
branches: [main, staging]
|
||||
|
||||
# Per-ref cancel: a newer commit on the same ref supersedes the older
|
||||
# advisory run. Distinct from ci.yml's `ci-${ref}` group so this lane
|
||||
# never cancels (or is cancelled by) the canonical required CI.
|
||||
concurrency:
|
||||
group: ci-arm64-advisory-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
fast-checks:
|
||||
name: fast-checks
|
||||
# AND-set: only the Mac arm64 runner advertises macos-self-hosted.
|
||||
# See "RUNNER TARGETING" header note for why bare self-hosted is unsafe.
|
||||
runs-on: [self-hosted, macos-self-hosted]
|
||||
# ADVISORY: never blocks. See safety contract point 3.
|
||||
continue-on-error: true
|
||||
# event_name gate: functional (only meaningful on push/PR) AND keeps
|
||||
# this job out of ci-required-drift.py:ci_job_names() so F1 can never
|
||||
# flag it. See safety contract point 4.
|
||||
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Provenance — advisory lane, non-gating
|
||||
run: |
|
||||
echo "This is the arm64 ADVISORY fast-check lane."
|
||||
echo "It does NOT gate merges. Canonical required CI is ci.yml"
|
||||
echo "on the amd64 pool. Arch: $(uname -m) on $(uname -s)."
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
# ---- Go: build + vet + lint (container-independent: needs only the
|
||||
# Go toolchain; no amd64 ECR image, no docker-in-job). Race-detector
|
||||
# unit-test + coverage gates are deliberately NOT duplicated here —
|
||||
# those stay authoritative on amd64 ci.yml `Platform (Go)`. This lane
|
||||
# is fast-feedback for the compile/vet/lint surface only. ----
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- name: Go build + vet (workspace-server)
|
||||
working-directory: workspace-server
|
||||
run: |
|
||||
go mod download
|
||||
go build ./cmd/server
|
||||
go vet ./...
|
||||
- name: golangci-lint (workspace-server)
|
||||
working-directory: workspace-server
|
||||
run: |
|
||||
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
|
||||
"$(go env GOPATH)/bin/golangci-lint" run --timeout 3m ./...
|
||||
|
||||
# ---- Shellcheck (container-independent: shellcheck binary only).
|
||||
# Mirrors ci.yml `Shellcheck (E2E scripts)` bulk pass scope. ----
|
||||
- name: Install shellcheck (arm64)
|
||||
run: |
|
||||
if ! command -v shellcheck >/dev/null 2>&1; then
|
||||
echo "shellcheck not preinstalled on this self-hosted runner."
|
||||
echo "Attempting Homebrew install (Mac arm64)."
|
||||
brew install shellcheck || {
|
||||
echo "::warning::shellcheck unavailable on runner; advisory shellcheck skipped."
|
||||
exit 0
|
||||
}
|
||||
fi
|
||||
shellcheck --version
|
||||
- name: Shellcheck tests/e2e + infra/scripts
|
||||
run: |
|
||||
command -v shellcheck >/dev/null 2>&1 || { echo "skip"; exit 0; }
|
||||
find tests/e2e infra/scripts -type f -name '*.sh' -print0 \
|
||||
| xargs -0 shellcheck --severity=warning
|
||||
|
||||
# ---- Python lint/compile (container-independent: CPython only).
|
||||
# Lint + import-compile surface; the authoritative pytest + coverage
|
||||
# floors stay on amd64 ci.yml `Python Lint & Test`. ----
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Python byte-compile (workspace)
|
||||
working-directory: workspace
|
||||
run: |
|
||||
python -m pip install --quiet ruff || true
|
||||
python -m compileall -q .
|
||||
if command -v ruff >/dev/null 2>&1; then
|
||||
ruff check . || echo "::warning::ruff findings (advisory only)"
|
||||
fi
|
||||
|
||||
- name: Advisory summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## arm64 advisory fast-checks complete"
|
||||
echo ""
|
||||
echo "This lane is **advisory** — it does not gate merges."
|
||||
echo "Authoritative required CI remains \`CI / all-required\`"
|
||||
echo "on the amd64 pool (\`ci.yml\`, unchanged by this PR)."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -33,6 +33,8 @@ interface HermesProvider {
|
||||
models: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_CREATE_MODEL = "anthropic:claude-opus-4-7";
|
||||
|
||||
// All providers supported by Hermes runtime via providers.resolve_provider().
|
||||
// `defaultModel` is the slug injected into the workspace provision request
|
||||
// when the user picks this provider — template-hermes's derive-provider.sh
|
||||
@@ -68,6 +70,10 @@ export function CreateWorkspaceButton() {
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [workspaces, setWorkspaces] = useState<WorkspaceOption[]>([]);
|
||||
const [displayEnabled, setDisplayEnabled] = useState(false);
|
||||
const [displayInstanceType, setDisplayInstanceType] = useState("t3.xlarge");
|
||||
const [displayRootGB, setDisplayRootGB] = useState("80");
|
||||
const [displayResolution, setDisplayResolution] = useState("1920x1080");
|
||||
// Templates fetched from /api/templates — drives the dynamic provider
|
||||
// filter below. Same data source ConfigTab uses (PR #2454). When the
|
||||
// selected template declares `runtime_config.providers` in its
|
||||
@@ -223,6 +229,10 @@ export function CreateWorkspaceButton() {
|
||||
setParentId("");
|
||||
setBudgetLimit("");
|
||||
setError(null);
|
||||
setDisplayEnabled(false);
|
||||
setDisplayInstanceType("t3.xlarge");
|
||||
setDisplayRootGB("80");
|
||||
setDisplayResolution("1920x1080");
|
||||
setHermesProvider("anthropic");
|
||||
setExternalRuntime("external");
|
||||
setHermesApiKey("");
|
||||
@@ -264,6 +274,8 @@ export function CreateWorkspaceButton() {
|
||||
const parsedBudget = budgetLimit.trim()
|
||||
? parseFloat(budgetLimit)
|
||||
: null;
|
||||
const [displayWidth, displayHeight] = displayResolution.split("x").map((v) => parseInt(v, 10));
|
||||
const parsedRootGB = parseInt(displayRootGB, 10);
|
||||
|
||||
const createResp = await api.post<{
|
||||
id: string;
|
||||
@@ -280,6 +292,21 @@ export function CreateWorkspaceButton() {
|
||||
tier,
|
||||
parent_id: parentId || undefined,
|
||||
budget_limit: parsedBudget,
|
||||
...(!isExternal && !isHermes ? { model: DEFAULT_CREATE_MODEL } : {}),
|
||||
...(displayEnabled
|
||||
? {
|
||||
compute: {
|
||||
instance_type: displayInstanceType,
|
||||
volume: { root_gb: Number.isFinite(parsedRootGB) ? parsedRootGB : 80 },
|
||||
display: {
|
||||
mode: "desktop-control",
|
||||
protocol: "novnc",
|
||||
width: Number.isFinite(displayWidth) ? displayWidth : 1920,
|
||||
height: Number.isFinite(displayHeight) ? displayHeight : 1080,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
canvas: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 },
|
||||
// Runtime=external flips the backend into awaiting-agent mode:
|
||||
// no container provisioning, token minted, connection payload
|
||||
@@ -447,6 +474,73 @@ export function CreateWorkspaceButton() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isExternal && (
|
||||
<div className="rounded-lg border border-line/50 bg-surface-card/40 p-3">
|
||||
<div className="mb-2 text-[11px] font-medium text-ink-mid">
|
||||
Container Config
|
||||
</div>
|
||||
<label className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-medium text-ink">Display</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={displayEnabled}
|
||||
onChange={(e) => setDisplayEnabled(e.target.checked)}
|
||||
aria-label="Enable display"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</label>
|
||||
{displayEnabled && (
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label htmlFor="display-instance-type" className="mb-1 block text-[11px] text-ink-mid">
|
||||
Instance
|
||||
</label>
|
||||
<select
|
||||
id="display-instance-type"
|
||||
value={displayInstanceType}
|
||||
onChange={(e) => setDisplayInstanceType(e.target.value)}
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-2 py-2 text-xs text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
>
|
||||
<option value="t3.large">t3.large</option>
|
||||
<option value="t3.xlarge">t3.xlarge</option>
|
||||
<option value="m6i.xlarge">m6i.xlarge</option>
|
||||
<option value="c6i.xlarge">c6i.xlarge</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="display-root-gb" className="mb-1 block text-[11px] text-ink-mid">
|
||||
Disk GB
|
||||
</label>
|
||||
<input
|
||||
id="display-root-gb"
|
||||
type="number"
|
||||
min="30"
|
||||
max="500"
|
||||
value={displayRootGB}
|
||||
onChange={(e) => setDisplayRootGB(e.target.value)}
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-2 py-2 text-xs text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label htmlFor="display-resolution" className="mb-1 block text-[11px] text-ink-mid">
|
||||
Resolution
|
||||
</label>
|
||||
<select
|
||||
id="display-resolution"
|
||||
value={displayResolution}
|
||||
onChange={(e) => setDisplayResolution(e.target.value)}
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-2 py-2 text-xs text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
>
|
||||
<option value="1920x1080">1920 x 1080</option>
|
||||
<option value="1600x900">1600 x 900</option>
|
||||
<option value="1280x720">1280 x 720</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-[11px] text-ink-mid block mb-1">
|
||||
Parent Workspace
|
||||
|
||||
@@ -123,6 +123,46 @@ describe("CreateWorkspaceDialog", () => {
|
||||
expect(body.parent_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits compute config by default", async () => {
|
||||
await openDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "Plain Agent" },
|
||||
});
|
||||
|
||||
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
|
||||
fireEvent.click(createBtn!);
|
||||
|
||||
await waitFor(() => expect(mockPost).toHaveBeenCalled());
|
||||
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body.compute).toBeUndefined();
|
||||
expect(body.model).toBe("anthropic:claude-opus-4-7");
|
||||
});
|
||||
|
||||
it("sends display compute profile when desktop display is enabled", async () => {
|
||||
await openDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "Desktop Agent" },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Enable display"));
|
||||
|
||||
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
|
||||
fireEvent.click(createBtn!);
|
||||
|
||||
await waitFor(() => expect(mockPost).toHaveBeenCalled());
|
||||
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body.model).toBe("anthropic:claude-opus-4-7");
|
||||
expect(body.compute).toEqual({
|
||||
instance_type: "t3.xlarge",
|
||||
volume: { root_gb: 80 },
|
||||
display: {
|
||||
mode: "desktop-control",
|
||||
protocol: "novnc",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("renders gracefully when GET /workspaces fails", async () => {
|
||||
mockGet.mockRejectedValueOnce(new Error("Network error"));
|
||||
await openDialog();
|
||||
|
||||
@@ -11,6 +11,7 @@ interface DisplayStatus {
|
||||
protocol?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
viewer_url?: string;
|
||||
}
|
||||
|
||||
interface DisplayControlStatus {
|
||||
@@ -93,6 +94,31 @@ export function DisplayTab({ workspaceId }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const releaseControl = async () => {
|
||||
const generation = requestGeneration.current;
|
||||
const controlPath = `/workspaces/${workspaceId}/display/control`;
|
||||
setControlBusy(true);
|
||||
setControlError(null);
|
||||
try {
|
||||
const next = await api.post<DisplayControlStatus>(`${controlPath}/release`, {});
|
||||
if (requestGeneration.current !== generation) return;
|
||||
setControl(next);
|
||||
} catch (err) {
|
||||
if (requestGeneration.current !== generation) return;
|
||||
setControlError("Failed to release control");
|
||||
try {
|
||||
const latest = await api.get<DisplayControlStatus>(controlPath);
|
||||
if (requestGeneration.current !== generation) return;
|
||||
setControl(latest);
|
||||
} catch {
|
||||
if (requestGeneration.current !== generation) return;
|
||||
setControl(null);
|
||||
}
|
||||
} finally {
|
||||
if (requestGeneration.current === generation) setControlBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-5">
|
||||
@@ -185,7 +211,97 @@ export function DisplayTab({ workspaceId }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
return (
|
||||
<div className="flex h-full min-h-[360px] flex-col bg-surface-sunken/30">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-line/50 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium text-ink">Desktop</h3>
|
||||
<p className="mt-0.5 font-mono text-[10px] text-ink-mid">
|
||||
{status.mode || "desktop-control"} · {status.protocol || "display"}
|
||||
</p>
|
||||
</div>
|
||||
<DisplayControlBar
|
||||
control={control}
|
||||
controlBusy={controlBusy}
|
||||
controlError={controlError}
|
||||
onAcquire={acquireControl}
|
||||
onRelease={releaseControl}
|
||||
/>
|
||||
</div>
|
||||
{status.viewer_url ? (
|
||||
<iframe
|
||||
title="Workspace desktop"
|
||||
src={status.viewer_url}
|
||||
className="min-h-0 flex-1 border-0 bg-black"
|
||||
allow="clipboard-read; clipboard-write; fullscreen; pointer-lock"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center p-8 text-center">
|
||||
<div>
|
||||
<h3 className="mb-1.5 text-sm font-medium text-ink">Display session is not ready.</h3>
|
||||
<p className="max-w-xs text-[11px] leading-relaxed text-ink-mid">
|
||||
This workspace has display configuration, but the desktop session URL is not available yet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DisplayControlBar({
|
||||
control,
|
||||
controlBusy,
|
||||
controlError,
|
||||
onAcquire,
|
||||
onRelease,
|
||||
}: {
|
||||
control: DisplayControlStatus | null;
|
||||
controlBusy: boolean;
|
||||
controlError: string | null;
|
||||
onAcquire: () => void;
|
||||
onRelease: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{control && (
|
||||
<div className="min-w-0 text-right">
|
||||
<p className="truncate text-[11px] font-medium text-ink">
|
||||
{control.controller === "none"
|
||||
? "No active controller"
|
||||
: `Controlled by ${displayControlActorLabel(control)}`}
|
||||
</p>
|
||||
{control.expires_at && (
|
||||
<p className="mt-0.5 truncate font-mono text-[10px] text-ink-mid">
|
||||
Until {new Date(control.expires_at).toLocaleTimeString()}
|
||||
</p>
|
||||
)}
|
||||
{controlError && <p className="mt-0.5 text-[10px] text-red-200">{controlError}</p>}
|
||||
</div>
|
||||
)}
|
||||
{control?.controller === "none" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAcquire}
|
||||
disabled={controlBusy}
|
||||
className="h-8 shrink-0 rounded border border-line bg-surface px-3 text-[11px] font-medium text-ink hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Take control
|
||||
</button>
|
||||
)}
|
||||
{control?.controller === "user" && control.controlled_by === "admin-token" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRelease}
|
||||
disabled={controlBusy}
|
||||
className="h-8 shrink-0 rounded border border-line bg-surface px-3 text-[11px] font-medium text-ink hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Release
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function displayControlActorLabel(control: DisplayControlStatus): string {
|
||||
|
||||
@@ -71,6 +71,61 @@ describe("DisplayTab", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the desktop stream when a display session is available", async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
available: true,
|
||||
mode: "desktop-control",
|
||||
protocol: "dcv",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
viewer_url: "https://display.example.test/session/ws-display",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
controller: "none",
|
||||
});
|
||||
|
||||
render(<DisplayTab workspaceId="ws-display" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Workspace desktop")).toBeTruthy();
|
||||
});
|
||||
const frame = screen.getByTitle("Workspace desktop") as HTMLIFrameElement;
|
||||
expect(frame.src).toBe("https://display.example.test/session/ws-display");
|
||||
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("releases user display control", async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
available: true,
|
||||
mode: "desktop-control",
|
||||
protocol: "dcv",
|
||||
viewer_url: "https://display.example.test/session/ws-display",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
controller: "user",
|
||||
controlled_by: "admin-token",
|
||||
expires_at: "2026-05-23T08:48:27Z",
|
||||
});
|
||||
mockPost.mockResolvedValueOnce({
|
||||
controller: "none",
|
||||
});
|
||||
|
||||
render(<DisplayTab workspaceId="ws-display" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Release" })).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Release" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
|
||||
});
|
||||
expect(mockPost).toHaveBeenCalledWith("/workspaces/ws-display/display/control/release", {});
|
||||
});
|
||||
|
||||
it("renders active display control locks as observe-only", async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
@@ -24,14 +24,12 @@
|
||||
#
|
||||
# Only PROVISIONING differs from staging:
|
||||
# - staging: POST /cp/admin/orgs (cold EC2 tenant) + per-tenant admin
|
||||
# token + each workspace's MCP bearer from create response or an admin
|
||||
# token-mint fallback.
|
||||
# token + each workspace's MCP bearer from the POST /workspaces
|
||||
# create response.
|
||||
# - local: POST /workspaces directly against the local stack
|
||||
# (BASE, default http://localhost:8080), MCP bearer minted via
|
||||
# GET /admin/workspaces/:id/test-token (e2e_mint_test_token —
|
||||
# deterministic, gated by MOLECULE_ENV != production). Same model
|
||||
# every other local E2E (test_priority_runtimes_e2e.sh,
|
||||
# test_api.sh) already uses; no new credential/provision flow.
|
||||
# (BASE, default http://localhost:8080), MCP bearer consumed inline
|
||||
# from the create response (auth_token field). Same model every
|
||||
# other local E2E uses; no new credential/provision flow.
|
||||
#
|
||||
# By default the local backend creates external-mode workspace rows and
|
||||
# drives the literal MCP path directly. That keeps the local peer-visibility
|
||||
@@ -81,6 +79,17 @@ NAME_PREFIX="PV-Local-$$-$(date +%H%M%S)"
|
||||
log() { echo "[$(date +%H:%M:%S)] $*"; }
|
||||
ok() { echo "[$(date +%H:%M:%S)] ✅ $*"; }
|
||||
|
||||
extract_auth_token() {
|
||||
python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
print(''); sys.exit(0)
|
||||
print(d.get('auth_token') or d.get('connection', {}).get('auth_token') or '')
|
||||
" 2>/dev/null
|
||||
}
|
||||
|
||||
CREATED_WSIDS=()
|
||||
ADMIN_BEARER="${MOLECULE_ADMIN_TOKEN:-${ADMIN_TOKEN:-}}"
|
||||
ADMIN_AUTH=()
|
||||
@@ -131,17 +140,6 @@ if ! curl -fsS "$BASE/health" -m 5 >/dev/null 2>&1; then
|
||||
echo "::error::Local stack not healthy at $BASE/health — bring it up (make up) before this gate. Infra, not a workspace bug (feedback_fix_root_not_symptom)." >&2
|
||||
exit 1
|
||||
fi
|
||||
# admin/test-token is the local MCP-bearer mint path; it 404s in
|
||||
# production. If it is off, this gate cannot drive the literal call.
|
||||
if ! curl -fsS "$BASE/admin/workspaces/preflight-probe/test-token" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} -m 5 >/dev/null 2>&1; then
|
||||
# A 404 here is EITHER "no such ws" (fine — endpoint is enabled) OR the
|
||||
# endpoint is disabled (MOLECULE_ENV=production). Distinguish by body.
|
||||
PROBE=$(curl -s "$BASE/admin/workspaces/preflight-probe/test-token" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} -m 5 2>/dev/null)
|
||||
if echo "$PROBE" | grep -qi 'production\|disabled\|not found.*endpoint'; then
|
||||
echo "::error::GET /admin/workspaces/:id/test-token disabled (MOLECULE_ENV=production?). Cannot mint a local MCP bearer." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
ok " local stack healthy"
|
||||
|
||||
# ─── Resolve per-runtime provisioning secrets ──────────────────────────
|
||||
@@ -260,6 +258,12 @@ PARENT_MODEL=$(_model_for_runtime "$PARENT_RUNTIME")
|
||||
P_RESP=$(curl -s -X POST "$BASE/workspaces" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} -H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"${NAME_PREFIX}-parent\",\"runtime\":\"$PARENT_RUNTIME\",\"model\":\"$PARENT_MODEL\",\"tier\":3$PARENT_EXTRA,\"secrets\":$PARENT_SECRETS}")
|
||||
PARENT_ID=$(echo "$P_RESP" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))' 2>/dev/null)
|
||||
# PARENT_TOKEN captured for symmetry with the per-sibling auth-token
|
||||
# capture in the runtime loop below + reserved for follow-up steps
|
||||
# that need parent-side auth. Current downstream steps reach the parent
|
||||
# via admin token, so the variable isn't dereferenced — SC2034.
|
||||
# shellcheck disable=SC2034 # captured for downstream parent-auth use; see #1644 follow-up
|
||||
PARENT_TOKEN=$(echo "$P_RESP" | extract_auth_token)
|
||||
if [ -z "$PARENT_ID" ]; then
|
||||
echo "::error::parent create failed: $(echo "$P_RESP" | head -c 300)" >&2
|
||||
exit 1
|
||||
@@ -275,6 +279,8 @@ log " PARENT_ID=$PARENT_ID runtime=$PARENT_RUNTIME"
|
||||
WS_IDS_MAP=""
|
||||
# shellcheck disable=SC2034 # map values are updated through portable eval-based helpers.
|
||||
VERDICT_MAP=""
|
||||
# shellcheck disable=SC2034 # map values are updated through portable eval-based helpers.
|
||||
WS_TOKENS_MAP=""
|
||||
_map_set() { # _map_set <mapvarname> <key> <value>
|
||||
local __m="$1" __k="$2" __v="$3" __cur
|
||||
eval "__cur=\$$__m"
|
||||
@@ -311,11 +317,17 @@ for rt in $PV_RUNTIMES; do
|
||||
R=$(curl -s -X POST "$BASE/workspaces" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} -H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"${NAME_PREFIX}-$rt\",\"runtime\":\"$CREATE_RUNTIME\",\"model\":\"$CREATE_MODEL\",\"tier\":2,\"parent_id\":\"$PARENT_ID\"$CREATE_EXTRA,\"secrets\":$SEC}")
|
||||
WID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))' 2>/dev/null)
|
||||
WTOK=$(echo "$R" | extract_auth_token)
|
||||
if [ -z "$WID" ]; then
|
||||
echo "::error::$rt workspace create failed: $(echo "$R" | head -c 300)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$WTOK" ]; then
|
||||
echo "::error::$rt workspace create did not return an auth_token — cannot drive the literal MCP call" >&2
|
||||
exit 1
|
||||
fi
|
||||
_map_set WS_IDS_MAP "$rt" "$WID"
|
||||
_map_set WS_TOKENS_MAP "$rt" "$WTOK"
|
||||
CREATED_WSIDS+=("$WID")
|
||||
ALL_WS_IDS="$ALL_WS_IDS $WID"
|
||||
ACTIVE_RUNTIMES="$ACTIVE_RUNTIMES $rt"
|
||||
@@ -373,10 +385,10 @@ log "4/5 driving the LITERAL list_peers MCP call per online runtime..."
|
||||
echo ""
|
||||
for rt in $ONLINE_RUNTIMES; do
|
||||
wid="$(_map_get WS_IDS_MAP "$rt")"
|
||||
WTOK=$(e2e_mint_test_token "$wid" 2>/dev/null || true)
|
||||
WTOK="$(_map_get WS_TOKENS_MAP "$rt")"
|
||||
if [ -z "$WTOK" ]; then
|
||||
echo "--- $rt (ws=$wid) ---"
|
||||
echo " ✗ $rt: could not mint a local MCP bearer (admin/test-token) — cannot drive the literal call"
|
||||
echo " ✗ $rt: workspace create did not return an auth_token — cannot drive the literal call"
|
||||
_map_set VERDICT_MAP "$rt" "FAIL(no-bearer)"
|
||||
REGRESSED=1
|
||||
echo ""
|
||||
|
||||
@@ -40,10 +40,10 @@
|
||||
# drives: POST /cp/admin/orgs (provision), GET
|
||||
# /cp/admin/orgs/:slug/admin-token (per-tenant token), DELETE
|
||||
# /cp/admin/tenants/:slug (teardown). The per-tenant admin token drives
|
||||
# tenant workspace creation; each workspace's OWN auth_token drives its
|
||||
# MCP call. External-like runtimes may return the token in POST
|
||||
# /workspaces; managed container runtimes usually require the admin token
|
||||
# mint fallback below.
|
||||
# tenant workspace creation; each workspace's OWN auth_token is consumed
|
||||
# inline from the POST /workspaces 201 response to drive its MCP call.
|
||||
# No dev-only admin token-mint routes are used in this E2E
|
||||
# (feedback_no_dev_only_routes_in_e2e).
|
||||
#
|
||||
# Required env:
|
||||
# MOLECULE_ADMIN_TOKEN CP admin bearer — Railway staging CP_ADMIN_API_TOKEN
|
||||
@@ -265,44 +265,19 @@ log " PARENT_ID=$PARENT_ID"
|
||||
# WS_IDS[runtime]=id ; WS_TOKENS[runtime]=auth_token (the MCP bearer)
|
||||
declare -A WS_IDS WS_TOKENS
|
||||
ALL_WS_IDS="$PARENT_ID"
|
||||
TOKEN_ERRORS=0
|
||||
TOKEN_ERROR_SUMMARY=""
|
||||
for rt in $PV_RUNTIMES; do
|
||||
R=$(tenant_call POST /workspaces \
|
||||
-d "{\"name\":\"pv-$rt\",\"runtime\":\"$rt\",\"tier\":2,\"parent_id\":\"$PARENT_ID\",\"secrets\":$SECRETS_JSON}")
|
||||
WID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
|
||||
# External-like runtimes may return connection.auth_token on create.
|
||||
# Managed container runtimes usually return only id/status here, then
|
||||
# receive their bearer through registry/bootstrap; for this literal MCP
|
||||
# driver we mint through the production-safe admin token route below.
|
||||
WTOK=$(echo "$R" | extract_auth_token)
|
||||
[ -n "$WID" ] || fail "$rt workspace create failed: $(echo "$R" | head -c 300)"
|
||||
TOKEN_DIAG=""
|
||||
if [ -z "$WTOK" ]; then
|
||||
TTOK_FILE=$(mktemp)
|
||||
TTOK_CODE=$(tenant_call_capture POST "/admin/workspaces/$WID/tokens" "$TTOK_FILE" 2>/dev/null || echo "curl_error")
|
||||
TTOK_RESP=$(cat "$TTOK_FILE" 2>/dev/null || true)
|
||||
WTOK=$(echo "$TTOK_RESP" | extract_auth_token)
|
||||
TOKEN_DIAG="POST /admin/workspaces/$WID/tokens -> HTTP $TTOK_CODE body: $(echo "$TTOK_RESP" | redact_token_body)"
|
||||
rm -f "$TTOK_FILE"
|
||||
fi
|
||||
[ -n "$WID" ] || fail "$rt workspace create failed: $(echo \"$R\" | head -c 300)"
|
||||
[ -n "$WTOK" ] || fail "$rt workspace create did not return an auth_token — cannot drive its MCP call (workspace_id=$WID; create_resp: $(echo \"$R\" | redact_token_body))"
|
||||
WS_IDS[$rt]="$WID"
|
||||
if [ -z "$WTOK" ]; then
|
||||
TOKEN_ERRORS=$((TOKEN_ERRORS + 1))
|
||||
TOKEN_ERROR_SUMMARY="${TOKEN_ERROR_SUMMARY}
|
||||
[$rt] workspace did not return or mint an auth_token — cannot drive its MCP call (workspace_id=$WID; create_resp: $(echo "$R" | redact_token_body); token_fallbacks: $TOKEN_DIAG)"
|
||||
log " $rt → $WID (token acquisition failed; continuing to classify other runtimes)"
|
||||
continue
|
||||
fi
|
||||
WS_TOKENS[$rt]="$WTOK"
|
||||
ALL_WS_IDS="$ALL_WS_IDS $WID"
|
||||
log " $rt → $WID"
|
||||
done
|
||||
|
||||
if [ "$TOKEN_ERRORS" -gt 0 ]; then
|
||||
fail "token acquisition failed for $TOKEN_ERRORS runtime(s):$TOKEN_ERROR_SUMMARY"
|
||||
fi
|
||||
|
||||
if [ "${PV_TOKEN_DIAGNOSTIC_ONLY:-0}" = "1" ]; then
|
||||
ok "token diagnostic passed for runtimes: $PV_RUNTIMES"
|
||||
exit 0
|
||||
|
||||
@@ -39,6 +39,7 @@ func TestAdminTestToken_EnabledViaFlagEvenInProd(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
t.Setenv("MOLECULE_ENV", "production")
|
||||
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1")
|
||||
t.Setenv("ADMIN_TOKEN", "")
|
||||
|
||||
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
|
||||
WithArgs("ws-1").
|
||||
@@ -58,6 +59,7 @@ func TestAdminTestToken_EnabledViaFlagEvenInProd(t *testing.T) {
|
||||
func TestAdminTestToken_WorkspaceNotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
t.Setenv("MOLECULE_ENV", "development")
|
||||
t.Setenv("ADMIN_TOKEN", "")
|
||||
|
||||
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
|
||||
WithArgs("missing").
|
||||
@@ -75,6 +77,7 @@ func TestAdminTestToken_WorkspaceNotFound(t *testing.T) {
|
||||
func TestAdminTestToken_HappyPath_TokenValidates(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
t.Setenv("MOLECULE_ENV", "development")
|
||||
t.Setenv("ADMIN_TOKEN", "")
|
||||
|
||||
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
|
||||
WithArgs("ws-1").
|
||||
|
||||
@@ -159,7 +159,8 @@ func generateAppInstallationToken() (string, time.Time, error) {
|
||||
req, _ := http.NewRequest("POST", fmt.Sprintf("https://api.github.com/app/installations/%d/access_tokens", installID), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+signed)
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
|
||||
@@ -1141,6 +1141,8 @@ func TestIsSafeURL_Blocks169_254_Metadata(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsSafeURL_Blocks10xPrivate(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
|
||||
err := isSafeURL("http://10.0.0.1/agent")
|
||||
if err == nil {
|
||||
t.Errorf("isSafeURL: expected 10.x.x.x to be blocked, got nil")
|
||||
@@ -1148,6 +1150,8 @@ func TestIsSafeURL_Blocks10xPrivate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsSafeURL_Blocks172Private(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
|
||||
err := isSafeURL("http://172.16.0.1/agent")
|
||||
if err == nil {
|
||||
t.Errorf("isSafeURL: expected 172.16.0.0/12 to be blocked, got nil")
|
||||
@@ -1155,6 +1159,8 @@ func TestIsSafeURL_Blocks172Private(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsSafeURL_Blocks192_168Private(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
|
||||
err := isSafeURL("http://192.168.1.100/agent")
|
||||
if err == nil {
|
||||
t.Errorf("isSafeURL: expected 192.168.x.x to be blocked, got nil")
|
||||
@@ -1178,6 +1184,8 @@ func TestIsSafeURL_BlocksInvalidURL(t *testing.T) {
|
||||
// ==================== SSRF Defence — isPrivateOrMetadataIP ====================
|
||||
|
||||
func TestIsPrivateOrMetadataIP_10Range(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
|
||||
tests := []string{"10.0.0.0", "10.255.255.255", "10.1.2.3"}
|
||||
for _, ip := range tests {
|
||||
if !isPrivateOrMetadataIP(net.ParseIP(ip)) {
|
||||
@@ -1187,6 +1195,8 @@ func TestIsPrivateOrMetadataIP_10Range(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsPrivateOrMetadataIP_172Range(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
|
||||
tests := []string{"172.16.0.0", "172.31.255.255", "172.20.1.1"}
|
||||
for _, ip := range tests {
|
||||
if !isPrivateOrMetadataIP(net.ParseIP(ip)) {
|
||||
@@ -1196,6 +1206,8 @@ func TestIsPrivateOrMetadataIP_172Range(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsPrivateOrMetadataIP_192_168Range(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
|
||||
tests := []string{"192.168.0.0", "192.168.255.255", "192.168.1.1"}
|
||||
for _, ip := range tests {
|
||||
if !isPrivateOrMetadataIP(net.ParseIP(ip)) {
|
||||
|
||||
@@ -712,6 +712,8 @@ func TestHeartbeat_SkipsRemovedRows(t *testing.T) {
|
||||
// ------------------------------------------------------------
|
||||
|
||||
func TestValidateAgentURL(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
|
||||
@@ -95,6 +95,7 @@ func TestSecurity_GetTemplates_NoAuth_Returns401(t *testing.T) {
|
||||
func TestSecurity_GetTemplates_FreshInstall_FailsOpen(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
t.Setenv("ADMIN_TOKEN", "")
|
||||
authDB, authMock := newFreshInstallAuthDB(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
@@ -152,6 +153,7 @@ func TestSecurity_GetOrgTemplates_NoAuth_Returns401(t *testing.T) {
|
||||
func TestSecurity_GetOrgTemplates_FreshInstall_FailsOpen(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
t.Setenv("ADMIN_TOKEN", "")
|
||||
authDB, authMock := newFreshInstallAuthDB(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
@@ -6,6 +6,9 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
||||
@@ -15,6 +18,10 @@ import (
|
||||
const (
|
||||
workspaceComputeDiskFloorGB = 30
|
||||
workspaceComputeDiskCeilingGB = 500
|
||||
workspaceDisplayMinWidth = 800
|
||||
workspaceDisplayMaxWidth = 3840
|
||||
workspaceDisplayMinHeight = 600
|
||||
workspaceDisplayMaxHeight = 2160
|
||||
)
|
||||
|
||||
type workspaceDisplayResponse struct {
|
||||
@@ -25,6 +32,7 @@ type workspaceDisplayResponse struct {
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ViewerURL string `json:"viewer_url,omitempty"`
|
||||
}
|
||||
|
||||
var workspaceComputeInstanceAllowlist = map[string]struct{}{
|
||||
@@ -54,12 +62,12 @@ func validateWorkspaceCompute(compute models.WorkspaceCompute) error {
|
||||
return fmt.Errorf("unsupported compute.display.mode")
|
||||
}
|
||||
switch compute.Display.Protocol {
|
||||
case "", "dcv":
|
||||
case "", "dcv", "novnc":
|
||||
default:
|
||||
return fmt.Errorf("unsupported compute.display.protocol")
|
||||
}
|
||||
if compute.Display.Width < 0 || compute.Display.Height < 0 {
|
||||
return fmt.Errorf("compute.display width/height must be non-negative")
|
||||
if err := validateWorkspaceDisplayDimensions(compute.Display.Width, compute.Display.Height); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -71,13 +79,26 @@ func validateWorkspaceDisplayConfig(display models.WorkspaceComputeDisplay) erro
|
||||
return fmt.Errorf("unsupported compute.display.mode")
|
||||
}
|
||||
switch display.Protocol {
|
||||
case "", "dcv":
|
||||
case "", "dcv", "novnc":
|
||||
default:
|
||||
return fmt.Errorf("unsupported compute.display.protocol")
|
||||
}
|
||||
if display.Width < 0 || display.Height < 0 {
|
||||
if err := validateWorkspaceDisplayDimensions(display.Width, display.Height); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateWorkspaceDisplayDimensions(width, height int) error {
|
||||
if width < 0 || height < 0 {
|
||||
return fmt.Errorf("compute.display width/height must be non-negative")
|
||||
}
|
||||
if width != 0 && (width < workspaceDisplayMinWidth || width > workspaceDisplayMaxWidth) {
|
||||
return fmt.Errorf("compute.display.width must be between %d and %d", workspaceDisplayMinWidth, workspaceDisplayMaxWidth)
|
||||
}
|
||||
if height != 0 && (height < workspaceDisplayMinHeight || height > workspaceDisplayMaxHeight) {
|
||||
return fmt.Errorf("compute.display.height must be between %d and %d", workspaceDisplayMinHeight, workspaceDisplayMaxHeight)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -196,6 +217,18 @@ func (h *WorkspaceHandler) Display(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if viewerURL := workspaceDisplayViewerURL(workspaceID); viewerURL != "" {
|
||||
c.JSON(200, workspaceDisplayResponse{
|
||||
Available: true,
|
||||
Mode: compute.Display.Mode,
|
||||
Protocol: compute.Display.Protocol,
|
||||
Width: compute.Display.Width,
|
||||
Height: compute.Display.Height,
|
||||
Status: "ready",
|
||||
ViewerURL: viewerURL,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(200, workspaceDisplayResponse{
|
||||
Available: false,
|
||||
Reason: "display_session_unavailable",
|
||||
@@ -206,3 +239,15 @@ func (h *WorkspaceHandler) Display(c *gin.Context) {
|
||||
Status: "not_configured",
|
||||
})
|
||||
}
|
||||
|
||||
func workspaceDisplayViewerURL(workspaceID string) string {
|
||||
base := strings.TrimRight(os.Getenv("DISPLAY_VIEWER_BASE_URL"), "/")
|
||||
if base == "" {
|
||||
return ""
|
||||
}
|
||||
parsed, err := url.Parse(base)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" {
|
||||
return ""
|
||||
}
|
||||
return base + "/" + url.PathEscape(workspaceID)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,20 @@ func TestValidateWorkspaceCompute_RejectsOutOfRangeRootVolume(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceCompute_RejectsOutOfRangeDisplayDimensions(t *testing.T) {
|
||||
for _, display := range []models.WorkspaceComputeDisplay{
|
||||
{Mode: "desktop-control", Protocol: "novnc", Width: 799, Height: 1080},
|
||||
{Mode: "desktop-control", Protocol: "novnc", Width: 3841, Height: 1080},
|
||||
{Mode: "desktop-control", Protocol: "novnc", Width: 1920, Height: 599},
|
||||
{Mode: "desktop-control", Protocol: "novnc", Width: 1920, Height: 2161},
|
||||
} {
|
||||
compute := models.WorkspaceCompute{Display: display}
|
||||
if err := validateWorkspaceCompute(compute); err == nil {
|
||||
t.Fatalf("validateWorkspaceCompute accepted display size %dx%d", display.Width, display.Height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceComputeJSON_OmitsEmptyNestedSections(t *testing.T) {
|
||||
got, err := workspaceComputeJSON(models.WorkspaceCompute{
|
||||
InstanceType: "m6i.xlarge",
|
||||
@@ -110,6 +124,7 @@ func TestWorkspaceCreate_WithInvalidCompute_ReturnsBadRequest(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
body := `{
|
||||
"name":"Oversized Agent",
|
||||
"model":"gpt-4",
|
||||
"compute":{"instance_type":"p4d.24xlarge"}
|
||||
}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
@@ -140,6 +155,7 @@ func TestBuildProvisionerConfig_CopiesComputeSizingFromPayload(t *testing.T) {
|
||||
Compute: models.WorkspaceCompute{
|
||||
InstanceType: "m6i.xlarge",
|
||||
Volume: models.WorkspaceComputeVolume{RootGB: 100},
|
||||
Display: models.WorkspaceComputeDisplay{Mode: "desktop-control", Protocol: "novnc", Width: 1920, Height: 1080},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
@@ -153,6 +169,12 @@ func TestBuildProvisionerConfig_CopiesComputeSizingFromPayload(t *testing.T) {
|
||||
if cfg.DiskGB != 100 {
|
||||
t.Errorf("cfg.DiskGB = %d, want 100", cfg.DiskGB)
|
||||
}
|
||||
if cfg.Display.Mode != "desktop-control" || cfg.Display.Protocol != "novnc" {
|
||||
t.Errorf("cfg.Display mode/protocol = %q/%q, want desktop-control/novnc", cfg.Display.Mode, cfg.Display.Protocol)
|
||||
}
|
||||
if cfg.Display.Width != 1920 || cfg.Display.Height != 1080 {
|
||||
t.Errorf("cfg.Display size = %dx%d, want 1920x1080", cfg.Display.Width, cfg.Display.Height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithStoredCompute_LoadsComputeForRestartPayloads(t *testing.T) {
|
||||
@@ -216,7 +238,7 @@ func TestWorkspaceDisplay_DisplayConfiguredReturnsSessionUnavailableContract(t *
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-display").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"novnc","width":1920,"height":1080}}`))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -241,8 +263,8 @@ func TestWorkspaceDisplay_DisplayConfiguredReturnsSessionUnavailableContract(t *
|
||||
if resp["status"] != "not_configured" {
|
||||
t.Fatalf("status = %v, want not_configured", resp["status"])
|
||||
}
|
||||
if resp["mode"] != "desktop-control" || resp["protocol"] != "dcv" {
|
||||
t.Fatalf("mode/protocol = %v/%v, want desktop-control/dcv", resp["mode"], resp["protocol"])
|
||||
if resp["mode"] != "desktop-control" || resp["protocol"] != "novnc" {
|
||||
t.Fatalf("mode/protocol = %v/%v, want desktop-control/novnc", resp["mode"], resp["protocol"])
|
||||
}
|
||||
if resp["width"] != float64(1920) || resp["height"] != float64(1080) {
|
||||
t.Fatalf("width/height = %v/%v, want 1920/1080", resp["width"], resp["height"])
|
||||
@@ -255,6 +277,83 @@ func TestWorkspaceDisplay_DisplayConfiguredReturnsSessionUnavailableContract(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplay_DisplayConfiguredWithViewerBaseReturnsAvailableSession(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
t.Setenv("DISPLAY_VIEWER_BASE_URL", "https://display.example.test/sessions")
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-display").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"novnc","width":1920,"height":1080}}`))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-display/display", nil)
|
||||
|
||||
handler.Display(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse display response: %v", err)
|
||||
}
|
||||
if resp["available"] != true {
|
||||
t.Fatalf("available = %v, want true", resp["available"])
|
||||
}
|
||||
if resp["viewer_url"] != "https://display.example.test/sessions/ws-display" {
|
||||
t.Fatalf("viewer_url = %v, want workspace viewer URL", resp["viewer_url"])
|
||||
}
|
||||
if resp["reason"] != nil {
|
||||
t.Fatalf("reason = %v, want omitted", resp["reason"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplay_DisplayConfiguredWithInvalidViewerBaseReturnsUnavailable(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
t.Setenv("DISPLAY_VIEWER_BASE_URL", "http://display.example.test/sessions")
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
workspaceID := "ws-display"
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(workspaceID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"novnc","width":1920,"height":1080}}`))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: workspaceID}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/"+workspaceID+"/display", nil)
|
||||
|
||||
handler.Display(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse display response: %v", err)
|
||||
}
|
||||
if resp["available"] != false {
|
||||
t.Fatalf("available = %v, want false", resp["available"])
|
||||
}
|
||||
if resp["viewer_url"] != nil {
|
||||
t.Fatalf("viewer_url = %v, want omitted for invalid viewer base", resp["viewer_url"])
|
||||
}
|
||||
if resp["reason"] != "display_session_unavailable" {
|
||||
t.Fatalf("reason = %v, want display_session_unavailable", resp["reason"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplay_IgnoresUnrelatedStoredComputeSizingDrift(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
@@ -262,7 +361,7 @@ func TestWorkspaceDisplay_IgnoresUnrelatedStoredComputeSizingDrift(t *testing.T)
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-display-sizing-drift").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"instance_type":"old.large","display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"instance_type":"old.large","display":{"mode":"desktop-control","protocol":"novnc","width":1920,"height":1080}}`))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
@@ -435,13 +435,16 @@ func (h *WorkspaceHandler) CascadeDelete(ctx context.Context, id string) ([]stri
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("descendant query: %w", err)
|
||||
}
|
||||
defer descRows.Close()
|
||||
for descRows.Next() {
|
||||
var descID string
|
||||
if descRows.Scan(&descID) == nil {
|
||||
descendantIDs = append(descendantIDs, descID)
|
||||
}
|
||||
}
|
||||
descRows.Close()
|
||||
if err := descRows.Err(); err != nil {
|
||||
return nil, nil, fmt.Errorf("CascadeDelete: failed iterating descendants: %w", err)
|
||||
}
|
||||
|
||||
allIDs := append([]string{id}, descendantIDs...)
|
||||
|
||||
|
||||
@@ -503,6 +503,32 @@ func TestCascadeDelete_DescendantQueryError(t *testing.T) {
|
||||
// sqlmock verifies all expected queries were executed
|
||||
}
|
||||
|
||||
func TestCascadeDelete_DescendantRowsError(t *testing.T) {
|
||||
mock, _ := setupWorkspaceCrudTest(t)
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
|
||||
// RowError(0, ...) requires a real row at index 0 to be reachable —
|
||||
// sqlmock only invokes nextErr[N] when r.pos-1 == N and the row exists.
|
||||
// AddRow ensures Next() attempts the first row, triggers the error,
|
||||
// and rows.Err() returns the injected error.
|
||||
h := &WorkspaceHandler{}
|
||||
rows := sqlmock.NewRows([]string{"id"}).AddRow("desc-1").RowError(0, sql.ErrConnDone)
|
||||
mock.ExpectQuery(`WITH RECURSIVE descendants AS`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(rows)
|
||||
|
||||
deleted, stopErrs, err := h.CascadeDelete(context.Background(), wsID)
|
||||
if err == nil {
|
||||
t.Fatal("CascadeDelete returned nil error; want descendant rows error")
|
||||
}
|
||||
if deleted != nil {
|
||||
t.Errorf("deleted = %v; want nil", deleted)
|
||||
}
|
||||
if stopErrs != nil {
|
||||
t.Errorf("stopErrs = %v; want nil", stopErrs)
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Full CascadeDelete testing requires mocking StopWorkspace, RemoveVolume,
|
||||
// and provisioner calls — covered in integration tests. Unit tests here focus on
|
||||
// the validation and pre-condition paths.
|
||||
|
||||
@@ -288,16 +288,22 @@ func (h *WorkspaceHandler) buildProvisionerConfig(
|
||||
}
|
||||
|
||||
return provisioner.WorkspaceConfig{
|
||||
WorkspaceID: workspaceID,
|
||||
TemplatePath: templatePath,
|
||||
ConfigFiles: configFiles,
|
||||
PluginsPath: pluginsPath,
|
||||
WorkspacePath: workspacePath,
|
||||
WorkspaceAccess: workspaceAccess,
|
||||
Tier: payload.Tier,
|
||||
Runtime: payload.Runtime,
|
||||
InstanceType: payload.Compute.InstanceType,
|
||||
DiskGB: int32(payload.Compute.Volume.RootGB),
|
||||
WorkspaceID: workspaceID,
|
||||
TemplatePath: templatePath,
|
||||
ConfigFiles: configFiles,
|
||||
PluginsPath: pluginsPath,
|
||||
WorkspacePath: workspacePath,
|
||||
WorkspaceAccess: workspaceAccess,
|
||||
Tier: payload.Tier,
|
||||
Runtime: payload.Runtime,
|
||||
InstanceType: payload.Compute.InstanceType,
|
||||
DiskGB: int32(payload.Compute.Volume.RootGB),
|
||||
Display: provisioner.WorkspaceDisplayConfig{
|
||||
Mode: payload.Compute.Display.Mode,
|
||||
Width: payload.Compute.Display.Width,
|
||||
Height: payload.Compute.Display.Height,
|
||||
Protocol: payload.Compute.Display.Protocol,
|
||||
},
|
||||
EnvVars: envVars,
|
||||
PlatformURL: h.platformURL,
|
||||
AwarenessURL: os.Getenv("AWARENESS_URL"),
|
||||
|
||||
@@ -806,6 +806,8 @@ func TestIssueAndInjectToken_HappyPath(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
|
||||
|
||||
// RevokeAllForWorkspace UPDATE (0 rows — no prior tokens, still succeeds)
|
||||
mock.ExpectExec(`UPDATE workspace_auth_tokens SET revoked_at`).
|
||||
@@ -843,6 +845,8 @@ func TestIssueAndInjectToken_RotatesExistingToken(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
|
||||
|
||||
// RevokeAllForWorkspace: 1 existing token revoked
|
||||
mock.ExpectExec(`UPDATE workspace_auth_tokens SET revoked_at`).
|
||||
@@ -909,6 +913,8 @@ func TestIssueAndInjectToken_IssueFailSkipsInjection(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
|
||||
|
||||
mock.ExpectExec(`UPDATE workspace_auth_tokens SET revoked_at`).
|
||||
WithArgs("ws-418-issue-fail").
|
||||
@@ -935,6 +941,8 @@ func TestIssueAndInjectToken_NilConfigFilesAllocated(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
|
||||
|
||||
mock.ExpectExec(`UPDATE workspace_auth_tokens SET revoked_at`).
|
||||
WithArgs("ws-418-nil-cfg").
|
||||
|
||||
@@ -256,6 +256,7 @@ func TestWorkspaceAuth_WrongWorkspace_Returns401(t *testing.T) {
|
||||
// live tokens anywhere) the middleware must let the request through so existing
|
||||
// deployments keep working during the Phase-30 rollout.
|
||||
func TestAdminAuth_FailOpen_NoTokensGlobally(t *testing.T) {
|
||||
t.Setenv("ADMIN_TOKEN", "")
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New: %v", err)
|
||||
@@ -375,6 +376,7 @@ func TestAdminAuth_C11_DeleteNoBearer_Returns401(t *testing.T) {
|
||||
// TestAdminAuth_ValidBearer_Passes — a valid bearer token (from any workspace)
|
||||
// must be accepted for admin routes.
|
||||
func TestAdminAuth_ValidBearer_Passes(t *testing.T) {
|
||||
t.Setenv("ADMIN_TOKEN", "")
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New: %v", err)
|
||||
@@ -418,6 +420,7 @@ func TestAdminAuth_ValidBearer_Passes(t *testing.T) {
|
||||
|
||||
// TestAdminAuth_InvalidBearer_Returns401 — wrong token must not grant admin access.
|
||||
func TestAdminAuth_InvalidBearer_Returns401(t *testing.T) {
|
||||
t.Setenv("ADMIN_TOKEN", "")
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New: %v", err)
|
||||
@@ -700,6 +703,7 @@ func TestAdminAuth_Issue180_ApprovalsListing_NoBearer_Returns401(t *testing.T) {
|
||||
// fail-open contract: on a fresh install (no tokens anywhere), the middleware
|
||||
// must not block the canvas from polling /approvals/pending.
|
||||
func TestAdminAuth_Issue180_ApprovalsListing_FailOpen_NoTokens(t *testing.T) {
|
||||
t.Setenv("ADMIN_TOKEN", "")
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New: %v", err)
|
||||
@@ -1098,6 +1102,7 @@ func TestCanvasOrBearer_TokensExist_CanvasOrigin_Passes(t *testing.T) {
|
||||
// issuing workspace has status='removed' must not grant admin access.
|
||||
// The JOIN in ValidateAnyToken filters the row out, resulting in ErrNoRows.
|
||||
func TestAdminAuth_RemovedWorkspaceToken_Returns401(t *testing.T) {
|
||||
t.Setenv("ADMIN_TOKEN", "")
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New: %v", err)
|
||||
@@ -1251,6 +1256,7 @@ func TestAdminAuth_623_ForgedCORSOrigin_Returns401(t *testing.T) {
|
||||
// TestAdminAuth_623_ValidBearer_WithOrigin_Passes — bearer + matching Origin
|
||||
// should still work (the Origin is irrelevant once the bearer validates).
|
||||
func TestAdminAuth_623_ValidBearer_WithOrigin_Passes(t *testing.T) {
|
||||
t.Setenv("ADMIN_TOKEN", "")
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock: %v", err)
|
||||
|
||||
@@ -152,14 +152,15 @@ func (p *CPProvisioner) adminAuthHeaders(req *http.Request) {
|
||||
}
|
||||
|
||||
type cpProvisionRequest struct {
|
||||
OrgID string `json:"org_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Runtime string `json:"runtime"`
|
||||
Tier int `json:"tier"`
|
||||
InstanceType string `json:"instance_type,omitempty"`
|
||||
DiskGB int32 `json:"disk_gb,omitempty"`
|
||||
PlatformURL string `json:"platform_url"`
|
||||
Env map[string]string `json:"env"`
|
||||
OrgID string `json:"org_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Runtime string `json:"runtime"`
|
||||
Tier int `json:"tier"`
|
||||
InstanceType string `json:"instance_type,omitempty"`
|
||||
DiskGB int32 `json:"disk_gb,omitempty"`
|
||||
Display WorkspaceDisplayConfig `json:"display,omitempty"`
|
||||
PlatformURL string `json:"platform_url"`
|
||||
Env map[string]string `json:"env"`
|
||||
// ConfigFiles are template + generated config files to write into the
|
||||
// EC2 instance's /configs directory. OFFSEC-010: collected by
|
||||
// collectCPConfigFiles which rejects symlinks and non-regular files
|
||||
@@ -214,6 +215,7 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
|
||||
Tier: cfg.Tier,
|
||||
InstanceType: cfg.InstanceType,
|
||||
DiskGB: cfg.DiskGB,
|
||||
Display: cfg.Display,
|
||||
PlatformURL: cfg.PlatformURL,
|
||||
Env: env,
|
||||
ConfigFiles: configFiles,
|
||||
@@ -241,9 +243,12 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
|
||||
// Cap body read at 64 KiB — the CP only ever returns small JSON
|
||||
// responses; an unbounded read could be weaponized into log-flood
|
||||
// DoS by a compromised upstream.
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
if readErr != nil {
|
||||
return "", fmt.Errorf("cp provisioner: read response body: %w", readErr)
|
||||
}
|
||||
var result cpProvisionResponse
|
||||
json.Unmarshal(respBody, &result)
|
||||
unmarshalErr := json.Unmarshal(respBody, &result)
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
// Prefer the structured {"error":"..."} field. Do NOT fall back
|
||||
@@ -257,6 +262,10 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
|
||||
return "", fmt.Errorf("cp provisioner: provision failed (%d): %s", resp.StatusCode, errMsg)
|
||||
}
|
||||
|
||||
if unmarshalErr != nil {
|
||||
return "", fmt.Errorf("cp provisioner: decode 201 response: %w", unmarshalErr)
|
||||
}
|
||||
|
||||
log.Printf("CP provisioner: workspace %s → EC2 instance %s (%s)", cfg.WorkspaceID, result.InstanceID, result.State)
|
||||
provlog.Event("provision.ec2_started", map[string]any{
|
||||
"workspace_id": cfg.WorkspaceID,
|
||||
@@ -409,7 +418,11 @@ func (p *CPProvisioner) Stop(ctx context.Context, workspaceID string) error {
|
||||
// Read a bounded slice of the body so the error message gives ops
|
||||
// enough to triage without risking a multi-MB log line on a
|
||||
// pathological response. 512 bytes covers any sane error envelope.
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("cp provisioner: stop %s: unexpected %d (read body failed: %w)",
|
||||
workspaceID, resp.StatusCode, readErr)
|
||||
}
|
||||
return fmt.Errorf("cp provisioner: stop %s: unexpected %d: %s",
|
||||
workspaceID, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
@@ -197,6 +197,12 @@ func TestStart_HappyPath(t *testing.T) {
|
||||
if body.DiskGB != 100 {
|
||||
t.Errorf("disk_gb = %d, want 100", body.DiskGB)
|
||||
}
|
||||
if body.Display.Mode != "desktop-control" || body.Display.Protocol != "novnc" {
|
||||
t.Errorf("display mode/protocol = %q/%q, want desktop-control/novnc", body.Display.Mode, body.Display.Protocol)
|
||||
}
|
||||
if body.Display.Width != 1920 || body.Display.Height != 1080 {
|
||||
t.Errorf("display size = %dx%d, want 1920x1080", body.Display.Width, body.Display.Height)
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = io.WriteString(w, `{"instance_id":"i-abc123","state":"pending"}`)
|
||||
}))
|
||||
@@ -212,6 +218,7 @@ func TestStart_HappyPath(t *testing.T) {
|
||||
id, err := p.Start(context.Background(), WorkspaceConfig{
|
||||
WorkspaceID: "ws-1", Runtime: "python", Tier: 1, PlatformURL: "http://tenant",
|
||||
InstanceType: "m6i.xlarge", DiskGB: 100,
|
||||
Display: WorkspaceDisplayConfig{Mode: "desktop-control", Protocol: "novnc", Width: 1920, Height: 1080},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
@@ -442,6 +449,26 @@ func TestStart_SymlinkTemplatePathError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStart_Malformed201SurfacesError — when CP returns 201 Created with
|
||||
// unparseable JSON, Start must return an error instead of silently
|
||||
// returning an empty instance_id. CR2 blocker from review #5552.
|
||||
func TestStart_Malformed201SurfacesError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = io.WriteString(w, `{"instance_id": broken-json`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := &CPProvisioner{baseURL: srv.URL, orgID: "org-1", httpClient: srv.Client()}
|
||||
_, err := p.Start(context.Background(), WorkspaceConfig{WorkspaceID: "ws-1", Runtime: "py"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error on malformed 201, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "decode 201 response") {
|
||||
t.Errorf("error should mention decode 201 response, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestStop_SendsBothAuthHeaders — verify #118/#130 compliance on the
|
||||
// teardown path. Any call to /cp/workspaces/:id must carry both the
|
||||
// platform-wide shared secret AND the per-tenant admin token, or the
|
||||
|
||||
@@ -97,9 +97,10 @@ type WorkspaceConfig struct {
|
||||
PluginsPath string // Host path to plugins directory (mounted at /plugins)
|
||||
WorkspacePath string // Host path to bind-mount as /workspace (if empty, uses Docker named volume)
|
||||
Tier int
|
||||
Runtime string // "langgraph" (default) or "claude-code", "codex", "ollama", "custom"
|
||||
InstanceType string // Optional CP EC2 instance type override (SaaS only)
|
||||
DiskGB int32 // Optional CP root volume size override in GiB (SaaS only)
|
||||
Runtime string // "langgraph" (default) or "claude-code", "codex", "ollama", "custom"
|
||||
InstanceType string // Optional CP EC2 instance type override (SaaS only)
|
||||
DiskGB int32 // Optional CP root volume size override in GiB (SaaS only)
|
||||
Display WorkspaceDisplayConfig
|
||||
EnvVars map[string]string // Additional env vars (API keys, etc.)
|
||||
PlatformURL string
|
||||
AwarenessURL string
|
||||
@@ -122,6 +123,13 @@ type WorkspaceConfig struct {
|
||||
Image string
|
||||
}
|
||||
|
||||
type WorkspaceDisplayConfig struct {
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
}
|
||||
|
||||
// selectImage resolves the final Docker image ref for a workspace. The handler
|
||||
// layer is the source of truth — if it set cfg.Image (the digest-pinned form
|
||||
// supplied by CP, the SSOT for runtime image pins; molecule-core's own
|
||||
@@ -710,7 +718,19 @@ func buildContainerEnv(cfg WorkspaceConfig) []string {
|
||||
env = append(env, fmt.Sprintf("AWARENESS_NAMESPACE=%s", cfg.AwarenessNamespace))
|
||||
env = append(env, fmt.Sprintf("AWARENESS_URL=%s", cfg.AwarenessURL))
|
||||
}
|
||||
// #1687: track explicit GH_TOKEN / GITHUB_TOKEN so they win over GH_PAT
|
||||
// alias. These are normally stripped by the SCM-write guard below, but
|
||||
// when a user explicitly sets them we preserve the value.
|
||||
var explicitGHToken, explicitGitHubToken string
|
||||
for k, v := range cfg.EnvVars {
|
||||
if k == "GH_TOKEN" {
|
||||
explicitGHToken = v
|
||||
continue
|
||||
}
|
||||
if k == "GITHUB_TOKEN" {
|
||||
explicitGitHubToken = v
|
||||
continue
|
||||
}
|
||||
// Forensic #145 hardening: tenant workspace containers run
|
||||
// agent-controlled code and must NEVER receive a Git SCM *write*
|
||||
// credential. Without merge/approve creds in-container the
|
||||
@@ -728,6 +748,19 @@ func buildContainerEnv(cfg WorkspaceConfig) []string {
|
||||
}
|
||||
env = append(env, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
// #1687: alias GH_PAT → GH_TOKEN / GITHUB_TOKEN on the READ side
|
||||
// (container env assembly). Explicit values win: only alias when the
|
||||
// key was not set in workspace secrets.
|
||||
if explicitGHToken != "" {
|
||||
env = append(env, fmt.Sprintf("GH_TOKEN=%s", explicitGHToken))
|
||||
} else if pat, hasPAT := cfg.EnvVars["GH_PAT"]; hasPAT && pat != "" {
|
||||
env = append(env, fmt.Sprintf("GH_TOKEN=%s", pat))
|
||||
}
|
||||
if explicitGitHubToken != "" {
|
||||
env = append(env, fmt.Sprintf("GITHUB_TOKEN=%s", explicitGitHubToken))
|
||||
} else if pat, hasPAT := cfg.EnvVars["GH_PAT"]; hasPAT && pat != "" {
|
||||
env = append(env, fmt.Sprintf("GITHUB_TOKEN=%s", pat))
|
||||
}
|
||||
// Inject ADMIN_TOKEN from the platform server's environment so workspace
|
||||
// containers can call /admin/liveness and other admin-gated endpoints
|
||||
// (core#831). cp_provisioner.go handles this separately for SaaS tenants.
|
||||
|
||||
@@ -770,9 +770,12 @@ func TestBuildContainerEnv_CustomEnvVarsAppended(t *testing.T) {
|
||||
// place — i.e. the guard is proven by construction, not by environment
|
||||
// accident.
|
||||
func TestBuildContainerEnv_StripsSCMWriteTokens(t *testing.T) {
|
||||
// GH_TOKEN and GITHUB_TOKEN are preserved when explicitly set (#1687)
|
||||
// because they win over the GH_PAT alias. The unconditional strip list
|
||||
// therefore excludes them; see TestBuildContainerEnv_GHPATAliasPrecedence
|
||||
// for the positive assertion.
|
||||
scmTokens := []string{
|
||||
"GITEA_TOKEN", "GITHUB_TOKEN", "GH_TOKEN",
|
||||
"GITLAB_TOKEN", "GL_TOKEN", "BITBUCKET_TOKEN",
|
||||
"GITEA_TOKEN", "GITLAB_TOKEN", "GL_TOKEN", "BITBUCKET_TOKEN",
|
||||
}
|
||||
|
||||
t.Run("normal path — SCM tokens explicitly set in EnvVars", func(t *testing.T) {
|
||||
@@ -780,6 +783,9 @@ func TestBuildContainerEnv_StripsSCMWriteTokens(t *testing.T) {
|
||||
for _, k := range scmTokens {
|
||||
envVars[k] = "leaked-write-credential-" + k
|
||||
}
|
||||
// Explicit GH_TOKEN / GITHUB_TOKEN are now preserved (#1687).
|
||||
envVars["GH_TOKEN"] = "explicit-gh-token"
|
||||
envVars["GITHUB_TOKEN"] = "explicit-github-token"
|
||||
cfg := WorkspaceConfig{
|
||||
WorkspaceID: "ws-tenant",
|
||||
PlatformURL: "http://localhost:8080",
|
||||
@@ -795,6 +801,13 @@ func TestBuildContainerEnv_StripsSCMWriteTokens(t *testing.T) {
|
||||
if !envContains(buildContainerEnv(cfg), "ANTHROPIC_API_KEY=sk-keep") {
|
||||
t.Errorf("filter must not strip non-SCM API keys")
|
||||
}
|
||||
// Explicit GH tokens must be preserved (not stripped).
|
||||
if !envContains(buildContainerEnv(cfg), "GH_TOKEN=explicit-gh-token") {
|
||||
t.Errorf("explicit GH_TOKEN must be preserved")
|
||||
}
|
||||
if !envContains(buildContainerEnv(cfg), "GITHUB_TOKEN=explicit-github-token") {
|
||||
t.Errorf("explicit GITHUB_TOKEN must be preserved")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("persona-file path — simulates loadPersonaEnvFile merge", func(t *testing.T) {
|
||||
@@ -855,6 +868,106 @@ func TestCPProvisionerEnv_StripsSCMWriteTokens(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildContainerEnv_GHPATAliasPrecedence asserts that explicit GH_TOKEN /
|
||||
// GITHUB_TOKEN in workspace secrets win over the GH_PAT alias (#1687 CR2
|
||||
// review_id=5646). The alias must only inject a key when it was NOT explicitly
|
||||
// set.
|
||||
func TestBuildContainerEnv_GHPATAliasPrecedence(t *testing.T) {
|
||||
pat := "ghp_pat_from_secrets"
|
||||
explicitGH := "gh_explicit_token"
|
||||
explicitGitHub := "github_explicit_token"
|
||||
|
||||
t.Run("GH_PAT alone → alias both", func(t *testing.T) {
|
||||
cfg := WorkspaceConfig{
|
||||
WorkspaceID: "ws-x",
|
||||
PlatformURL: "http://localhost:8080",
|
||||
EnvVars: map[string]string{"GH_PAT": pat},
|
||||
}
|
||||
env := buildContainerEnv(cfg)
|
||||
if !envContains(env, "GH_TOKEN="+pat) {
|
||||
t.Errorf("GH_PAT alias must set GH_TOKEN, got %v", env)
|
||||
}
|
||||
if !envContains(env, "GITHUB_TOKEN="+pat) {
|
||||
t.Errorf("GH_PAT alias must set GITHUB_TOKEN, got %v", env)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit GH_TOKEN wins over GH_PAT alias", func(t *testing.T) {
|
||||
cfg := WorkspaceConfig{
|
||||
WorkspaceID: "ws-x",
|
||||
PlatformURL: "http://localhost:8080",
|
||||
EnvVars: map[string]string{
|
||||
"GH_PAT": pat,
|
||||
"GH_TOKEN": explicitGH,
|
||||
},
|
||||
}
|
||||
env := buildContainerEnv(cfg)
|
||||
if envContains(env, "GH_TOKEN="+pat) {
|
||||
t.Errorf("explicit GH_TOKEN must win over GH_PAT alias, got GH_TOKEN=%q", pat)
|
||||
}
|
||||
if !envContains(env, "GH_TOKEN="+explicitGH) {
|
||||
t.Errorf("explicit GH_TOKEN must be preserved, got %v", env)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit GITHUB_TOKEN wins over GH_PAT alias", func(t *testing.T) {
|
||||
cfg := WorkspaceConfig{
|
||||
WorkspaceID: "ws-x",
|
||||
PlatformURL: "http://localhost:8080",
|
||||
EnvVars: map[string]string{
|
||||
"GH_PAT": pat,
|
||||
"GITHUB_TOKEN": explicitGitHub,
|
||||
},
|
||||
}
|
||||
env := buildContainerEnv(cfg)
|
||||
if envContains(env, "GITHUB_TOKEN="+pat) {
|
||||
t.Errorf("explicit GITHUB_TOKEN must win over GH_PAT alias, got GITHUB_TOKEN=%q", pat)
|
||||
}
|
||||
if !envContains(env, "GITHUB_TOKEN="+explicitGitHub) {
|
||||
t.Errorf("explicit GITHUB_TOKEN must be preserved, got %v", env)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit both → both preserved, no alias", func(t *testing.T) {
|
||||
cfg := WorkspaceConfig{
|
||||
WorkspaceID: "ws-x",
|
||||
PlatformURL: "http://localhost:8080",
|
||||
EnvVars: map[string]string{
|
||||
"GH_PAT": pat,
|
||||
"GH_TOKEN": explicitGH,
|
||||
"GITHUB_TOKEN": explicitGitHub,
|
||||
},
|
||||
}
|
||||
env := buildContainerEnv(cfg)
|
||||
if envContains(env, "GH_TOKEN="+pat) {
|
||||
t.Errorf("explicit GH_TOKEN must win, got alias value %q", pat)
|
||||
}
|
||||
if envContains(env, "GITHUB_TOKEN="+pat) {
|
||||
t.Errorf("explicit GITHUB_TOKEN must win, got alias value %q", pat)
|
||||
}
|
||||
if !envContains(env, "GH_TOKEN="+explicitGH) {
|
||||
t.Errorf("explicit GH_TOKEN must be preserved, got %v", env)
|
||||
}
|
||||
if !envContains(env, "GITHUB_TOKEN="+explicitGitHub) {
|
||||
t.Errorf("explicit GITHUB_TOKEN must be preserved, got %v", env)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no GH_PAT → no alias injected", func(t *testing.T) {
|
||||
cfg := WorkspaceConfig{
|
||||
WorkspaceID: "ws-x",
|
||||
PlatformURL: "http://localhost:8080",
|
||||
EnvVars: map[string]string{"OTHER": "ok"},
|
||||
}
|
||||
env := buildContainerEnv(cfg)
|
||||
for _, e := range env {
|
||||
if strings.HasPrefix(e, "GH_TOKEN=") || strings.HasPrefix(e, "GITHUB_TOKEN=") {
|
||||
t.Errorf("no GH_PAT present → no alias should be injected, got %q", e)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func assertNoSCMWriteToken(t *testing.T, env []string, scmTokens []string) {
|
||||
t.Helper()
|
||||
for _, e := range env {
|
||||
|
||||
@@ -81,6 +81,7 @@ func TestTestTokenRoute_RequiresAdminAuth_WhenTokensExist(t *testing.T) {
|
||||
// bootstrap path still works before the first workspace has registered.
|
||||
func TestTestTokenRoute_FailOpenOnFreshInstall(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ENV", "development")
|
||||
t.Setenv("ADMIN_TOKEN", "")
|
||||
mock := setupRouterTestDB(t)
|
||||
|
||||
// HasAnyLiveTokenGlobal: no tokens yet — fresh install.
|
||||
|
||||
Reference in New Issue
Block a user