Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e75740a44 |
@@ -1,186 +0,0 @@
|
||||
# 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"
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
interface DisplayStatus {
|
||||
@@ -13,86 +13,31 @@ interface DisplayStatus {
|
||||
height?: number;
|
||||
}
|
||||
|
||||
interface DisplayControlStatus {
|
||||
controller: "none" | "user" | "agent";
|
||||
controlled_by?: string;
|
||||
expires_at?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export function DisplayTab({ workspaceId }: Props) {
|
||||
const [status, setStatus] = useState<DisplayStatus | null>(null);
|
||||
const [control, setControl] = useState<DisplayControlStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [controlError, setControlError] = useState<string | null>(null);
|
||||
const [controlBusy, setControlBusy] = useState(false);
|
||||
const requestGeneration = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const generation = requestGeneration.current + 1;
|
||||
requestGeneration.current = generation;
|
||||
let cancelled = false;
|
||||
setStatus(null);
|
||||
setControl(null);
|
||||
setError(null);
|
||||
setControlError(null);
|
||||
setControlBusy(false);
|
||||
async function load() {
|
||||
try {
|
||||
const displayStatus = await api.get<DisplayStatus>(`/workspaces/${workspaceId}/display`);
|
||||
if (cancelled || requestGeneration.current !== generation) return;
|
||||
setStatus(displayStatus);
|
||||
if (displayStatus.reason === "display_not_enabled") return;
|
||||
try {
|
||||
const displayControl = await api.get<DisplayControlStatus>(`/workspaces/${workspaceId}/display/control`);
|
||||
if (!cancelled && requestGeneration.current === generation) setControl(displayControl);
|
||||
} catch (err) {
|
||||
if (!cancelled && requestGeneration.current === generation) {
|
||||
setControl(null);
|
||||
setControlError("Display control unavailable");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled && requestGeneration.current === generation) setError("The display status could not be loaded.");
|
||||
}
|
||||
}
|
||||
load();
|
||||
api
|
||||
.get<DisplayStatus>(`/workspaces/${workspaceId}/display`)
|
||||
.then((data) => {
|
||||
if (!cancelled) setStatus(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : "Display status unavailable");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [workspaceId]);
|
||||
|
||||
const acquireControl = async () => {
|
||||
const generation = requestGeneration.current;
|
||||
const controlPath = `/workspaces/${workspaceId}/display/control`;
|
||||
setControlBusy(true);
|
||||
setControlError(null);
|
||||
try {
|
||||
const next = await api.post<DisplayControlStatus>(`${controlPath}/acquire`, {
|
||||
controller: "user",
|
||||
ttl_seconds: 300,
|
||||
});
|
||||
if (requestGeneration.current !== generation) return;
|
||||
setControl(next);
|
||||
} catch (err) {
|
||||
if (requestGeneration.current !== generation) return;
|
||||
setControlError("Failed to take 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">
|
||||
@@ -136,50 +81,12 @@ export function DisplayTab({ workspaceId }: Props) {
|
||||
: "This workspace has display configuration, but the desktop session infrastructure is not configured yet."}
|
||||
</p>
|
||||
{!isNotEnabled && (
|
||||
<>
|
||||
<dl className="mt-5 grid grid-cols-2 gap-x-4 gap-y-2 text-left text-[11px]">
|
||||
<dt className="text-ink-mid">Mode</dt>
|
||||
<dd className="font-mono text-ink">{status.mode || "unknown"}</dd>
|
||||
<dt className="text-ink-mid">Status</dt>
|
||||
<dd className="font-mono text-ink">{status.status || "unknown"}</dd>
|
||||
</dl>
|
||||
<div className="mt-5 w-full max-w-xs border-t border-line/50 pt-4">
|
||||
{control ? (
|
||||
<div className="flex items-center justify-between gap-3 text-left">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-medium text-ink">
|
||||
{control.controller === "none"
|
||||
? "No active controller"
|
||||
: `Controlled by ${displayControlActorLabel(control)}`}
|
||||
</p>
|
||||
{control.expires_at && (
|
||||
<p className="mt-1 truncate font-mono text-[10px] text-ink-mid">
|
||||
Until {new Date(control.expires_at).toLocaleTimeString()}
|
||||
</p>
|
||||
)}
|
||||
{controlError && <p className="mt-1 text-[10px] leading-snug text-red-200">{controlError}</p>}
|
||||
</div>
|
||||
{control.controller === "none" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={acquireControl}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-left">
|
||||
{!controlError && (
|
||||
<div className="h-8 rounded border border-line/40 bg-surface-sunken/30 motion-safe:animate-pulse" />
|
||||
)}
|
||||
{controlError && <p className="mt-2 text-[10px] leading-snug text-red-200">{controlError}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<dl className="mt-5 grid grid-cols-2 gap-x-4 gap-y-2 text-left text-[11px]">
|
||||
<dt className="text-ink-mid">Mode</dt>
|
||||
<dd className="font-mono text-ink">{status.mode || "unknown"}</dd>
|
||||
<dt className="text-ink-mid">Status</dt>
|
||||
<dd className="font-mono text-ink">{status.status || "unknown"}</dd>
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -187,10 +94,3 @@ export function DisplayTab({ workspaceId }: Props) {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function displayControlActorLabel(control: DisplayControlStatus): string {
|
||||
if (control.controller === "agent") return "Agent";
|
||||
if (control.controlled_by === "admin-token") return "Admin";
|
||||
if (control.controlled_by?.startsWith("org-token:")) return "Automation";
|
||||
return "User";
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
const { mockGet, mockPost } = vi.hoisted(() => ({ mockGet: vi.fn(), mockPost: vi.fn() }));
|
||||
const { mockGet } = vi.hoisted(() => ({ mockGet: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: mockGet,
|
||||
post: mockPost,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -15,9 +14,7 @@ import { DisplayTab } from "../DisplayTab";
|
||||
|
||||
describe("DisplayTab", () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
});
|
||||
|
||||
it("renders unavailable state for non-display workspaces", async () => {
|
||||
@@ -32,219 +29,5 @@ describe("DisplayTab", () => {
|
||||
expect(screen.getByText("Display is not enabled for this workspace.")).toBeTruthy();
|
||||
});
|
||||
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-no-display/display");
|
||||
expect(mockGet).not.toHaveBeenCalledWith("/workspaces/ws-no-display/display/control");
|
||||
});
|
||||
|
||||
it("renders control acquisition for display-configured workspaces", async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
available: false,
|
||||
reason: "display_session_unavailable",
|
||||
mode: "desktop-control",
|
||||
status: "not_configured",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
controller: "none",
|
||||
});
|
||||
mockPost.mockResolvedValueOnce({
|
||||
controller: "user",
|
||||
controlled_by: "admin-token",
|
||||
expires_at: "2026-05-23T08:48:27Z",
|
||||
});
|
||||
|
||||
render(<DisplayTab workspaceId="ws-display" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
|
||||
});
|
||||
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-display/display");
|
||||
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-display/display/control");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Take control" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Controlled by Admin")).toBeTruthy();
|
||||
});
|
||||
expect(mockPost).toHaveBeenCalledWith("/workspaces/ws-display/display/control/acquire", {
|
||||
controller: "user",
|
||||
ttl_seconds: 300,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders active display control locks as observe-only", async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
available: false,
|
||||
reason: "display_session_unavailable",
|
||||
mode: "desktop-control",
|
||||
status: "not_configured",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
controller: "agent",
|
||||
controlled_by: "sidecar",
|
||||
expires_at: "2026-05-23T08:48:27Z",
|
||||
});
|
||||
|
||||
render(<DisplayTab workspaceId="ws-display" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Controlled by Agent")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: "Release" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Take control" })).toBeNull();
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("labels org-token display control locks as automation", async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
available: false,
|
||||
reason: "display_session_unavailable",
|
||||
mode: "desktop-control",
|
||||
status: "not_configured",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
controller: "user",
|
||||
controlled_by: "org-token:abc123",
|
||||
expires_at: "2026-05-23T08:48:27Z",
|
||||
});
|
||||
|
||||
render(<DisplayTab workspaceId="ws-display" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Controlled by Automation")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByText("org-token:abc123")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Take control" })).toBeNull();
|
||||
});
|
||||
|
||||
it("refreshes display control state after failed acquisition", async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
available: false,
|
||||
reason: "display_session_unavailable",
|
||||
mode: "desktop-control",
|
||||
status: "not_configured",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
controller: "none",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
controller: "agent",
|
||||
controlled_by: "sidecar",
|
||||
expires_at: "2026-05-23T08:48:27Z",
|
||||
});
|
||||
mockPost.mockRejectedValueOnce(new Error("API POST /workspaces/ws-display/display/control/acquire: 409 conflict"));
|
||||
|
||||
render(<DisplayTab workspaceId="ws-display" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Take control" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Controlled by Agent")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByText("Failed to take control")).toBeTruthy();
|
||||
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-display/display/control");
|
||||
expect(mockGet).toHaveBeenCalledTimes(3);
|
||||
expect(mockPost).toHaveBeenCalledWith("/workspaces/ws-display/display/control/acquire", {
|
||||
controller: "user",
|
||||
ttl_seconds: 300,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps display status visible without takeover actions when control status fails", async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
available: false,
|
||||
reason: "display_session_unavailable",
|
||||
mode: "desktop-control",
|
||||
status: "not_configured",
|
||||
})
|
||||
.mockRejectedValueOnce(new Error("API GET /workspaces/ws-display/display/control: 401 unauthorized"));
|
||||
|
||||
render(<DisplayTab workspaceId="ws-display" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Display session is not ready.")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: "Take control" })).toBeNull();
|
||||
expect(screen.getByText("Display control unavailable")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not render raw display status errors", async () => {
|
||||
mockGet.mockRejectedValueOnce(new Error("API GET /workspaces/ws-display/display: 500 secret backend details"));
|
||||
|
||||
render(<DisplayTab workspaceId="ws-display" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Display status unavailable")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByText(/secret backend details/)).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores stale acquire responses after workspace changes", async () => {
|
||||
const acquire = deferred<{ controller: "user"; controlled_by: string; expires_at: string }>();
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
available: false,
|
||||
reason: "display_session_unavailable",
|
||||
mode: "desktop-control",
|
||||
status: "not_configured",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
controller: "none",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
available: false,
|
||||
reason: "display_session_unavailable",
|
||||
mode: "desktop-control",
|
||||
status: "not_configured",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
controller: "none",
|
||||
});
|
||||
mockPost.mockReturnValueOnce(acquire.promise);
|
||||
|
||||
const { rerender } = render(<DisplayTab workspaceId="ws-a" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Take control" }));
|
||||
|
||||
rerender(<DisplayTab workspaceId="ws-b" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-b/display/control");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
|
||||
});
|
||||
|
||||
acquire.resolve({
|
||||
controller: "user",
|
||||
controlled_by: "admin-token",
|
||||
expires_at: "2026-05-23T08:48:27Z",
|
||||
});
|
||||
await acquire.promise;
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Controlled by Admin")).toBeNull();
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
@@ -50,16 +50,13 @@ docker rm $(docker ps -aq --filter "name=ws-") 2>/dev/null || true
|
||||
echo ""
|
||||
echo "--- Create Workspaces ---"
|
||||
|
||||
# model is required at the Create boundary (CTO 2026-05-22 SSOT —
|
||||
# feedback_workspace_model_required_no_platform_default_dynamic_credential_intake).
|
||||
# Pass the same value the deleted DefaultModel("claude-code") returned.
|
||||
ROOT=$(curl -s -X POST $PLATFORM/workspaces -H "Content-Type: application/json" \
|
||||
-d '{"name":"Root Agent","role":"Company coordinator","runtime":"claude-code","model":"sonnet","tier":3}' \
|
||||
-d '{"name":"Root Agent","role":"Company coordinator","runtime":"claude-code","tier":3}' \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
check_contains "Create root workspace" "-" "$ROOT"
|
||||
|
||||
CHILD=$(curl -s -X POST $PLATFORM/workspaces -H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"Child Agent\",\"role\":\"Sub-team member\",\"runtime\":\"claude-code\",\"model\":\"sonnet\",\"tier\":2,\"parent_id\":\"$ROOT\"}" \
|
||||
-d "{\"name\":\"Child Agent\",\"role\":\"Sub-team member\",\"runtime\":\"claude-code\",\"tier\":2,\"parent_id\":\"$ROOT\"}" \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
check_contains "Create child workspace" "-" "$CHILD"
|
||||
|
||||
|
||||
@@ -92,12 +92,8 @@ for _wid in $PRIOR; do
|
||||
curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" > /dev/null || true
|
||||
done
|
||||
|
||||
# model is required at the Create boundary (CTO 2026-05-22 SSOT — see
|
||||
# feedback_workspace_model_required_no_platform_default_dynamic_credential_intake).
|
||||
# Body had no runtime → defaults to langgraph; pass the langgraph-compatible
|
||||
# default that the deleted DefaultModel("") would have returned.
|
||||
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
||||
-d '{"name":"Notify E2E","tier":1,"model":"anthropic:claude-opus-4-7"}')
|
||||
-d '{"name":"Notify E2E","tier":1}')
|
||||
WSID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
|
||||
[ -n "$WSID" ] || { echo "Failed to create workspace: $R"; exit 1; }
|
||||
echo "Created workspace $WSID"
|
||||
|
||||
@@ -24,12 +24,14 @@
|
||||
#
|
||||
# Only PROVISIONING differs from staging:
|
||||
# - staging: POST /cp/admin/orgs (cold EC2 tenant) + per-tenant admin
|
||||
# token + each workspace's MCP bearer from the POST /workspaces
|
||||
# create response.
|
||||
# token + each workspace's MCP bearer from create response or an admin
|
||||
# token-mint fallback.
|
||||
# - local: POST /workspaces directly against the local stack
|
||||
# (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.
|
||||
# (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.
|
||||
#
|
||||
# By default the local backend creates external-mode workspace rows and
|
||||
# drives the literal MCP path directly. That keeps the local peer-visibility
|
||||
@@ -79,17 +81,6 @@ 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=()
|
||||
@@ -140,6 +131,17 @@ 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 ──────────────────────────
|
||||
@@ -239,31 +241,9 @@ else
|
||||
fi
|
||||
log "1/5 provisioning parent ($PARENT_RUNTIME, mode=$PV_LOCAL_PROVISION_MODE) + one sibling per runtime under test..."
|
||||
|
||||
# Map runtime → model per the CTO 2026-05-22 SSOT directive (model is
|
||||
# required, no platform default). External runtimes are exempt by the
|
||||
# Create-handler gate — for them the URL is the contract — but we still
|
||||
# pass model="external:custom" defensively in case a downstream consumer
|
||||
# of the create body asserts presence.
|
||||
_model_for_runtime() {
|
||||
case "$1" in
|
||||
claude-code) echo "sonnet" ;;
|
||||
codex) echo "gpt-5.5" ;;
|
||||
kimi) echo "kimi-coding/kimi-k2-coding-6" ;;
|
||||
minimax) echo "minimax/MiniMax-M2.7" ;;
|
||||
external) echo "external:custom" ;;
|
||||
*) echo "anthropic:claude-opus-4-7" ;;
|
||||
esac
|
||||
}
|
||||
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}")
|
||||
-d "{\"name\":\"${NAME_PREFIX}-parent\",\"runtime\":\"$PARENT_RUNTIME\",\"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
|
||||
@@ -279,8 +259,6 @@ 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"
|
||||
@@ -313,21 +291,14 @@ for rt in $PV_RUNTIMES; do
|
||||
CREATE_RUNTIME="$rt"
|
||||
CREATE_EXTRA=""
|
||||
fi
|
||||
CREATE_MODEL=$(_model_for_runtime "$CREATE_RUNTIME")
|
||||
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}")
|
||||
-d "{\"name\":\"${NAME_PREFIX}-$rt\",\"runtime\":\"$CREATE_RUNTIME\",\"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"
|
||||
@@ -385,10 +356,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="$(_map_get WS_TOKENS_MAP "$rt")"
|
||||
WTOK=$(e2e_mint_test_token "$wid" 2>/dev/null || true)
|
||||
if [ -z "$WTOK" ]; then
|
||||
echo "--- $rt (ws=$wid) ---"
|
||||
echo " ✗ $rt: workspace create did not return an auth_token — cannot drive the literal call"
|
||||
echo " ✗ $rt: could not mint a local MCP bearer (admin/test-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 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).
|
||||
# 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.
|
||||
#
|
||||
# Required env:
|
||||
# MOLECULE_ADMIN_TOKEN CP admin bearer — Railway staging CP_ADMIN_API_TOKEN
|
||||
@@ -265,19 +265,44 @@ 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)"
|
||||
[ -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))"
|
||||
[ -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
|
||||
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
|
||||
|
||||
@@ -188,9 +188,8 @@ import json, os
|
||||
print(json.dumps({'CLAUDE_CODE_OAUTH_TOKEN': os.environ['CLAUDE_CODE_OAUTH_TOKEN']}))
|
||||
")
|
||||
local resp wsid
|
||||
# model required (CTO 2026-05-22 SSOT) — pass the deleted DefaultModel("claude-code") value.
|
||||
resp=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"Priority E2E (claude-code)\",\"runtime\":\"claude-code\",\"model\":\"sonnet\",\"tier\":1,\"secrets\":$secrets}")
|
||||
-d "{\"name\":\"Priority E2E (claude-code)\",\"runtime\":\"claude-code\",\"tier\":1,\"secrets\":$secrets}")
|
||||
wsid=$(echo "$resp" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))') || true
|
||||
if [ -z "$wsid" ]; then
|
||||
fail "create claude-code workspace" "$resp"
|
||||
@@ -381,9 +380,8 @@ import json, os
|
||||
print(json.dumps({'GEMINI_API_KEY': os.environ['E2E_GEMINI_API_KEY']}))
|
||||
")
|
||||
local resp wsid
|
||||
# model required (CTO 2026-05-22 SSOT) — gemini-cli routes via the gemini provider.
|
||||
resp=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"Priority E2E (gemini-cli)\",\"runtime\":\"gemini-cli\",\"model\":\"gemini-2.0-flash\",\"tier\":1,\"secrets\":$secrets}")
|
||||
-d "{\"name\":\"Priority E2E (gemini-cli)\",\"runtime\":\"gemini-cli\",\"tier\":1,\"secrets\":$secrets}")
|
||||
wsid=$(echo "$resp" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))') || true
|
||||
if [ -z "$wsid" ]; then fail "create gemini-cli workspace" "$resp"; return 0; fi
|
||||
CREATED_WSIDS+=("$wsid")
|
||||
|
||||
@@ -393,9 +393,8 @@ func main() {
|
||||
// See molecule-core#7.
|
||||
bindHost := resolveBindHost()
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%s", bindHost, port),
|
||||
Handler: r,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
Addr: fmt.Sprintf("%s:%s", bindHost, port),
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
// Start server in goroutine
|
||||
|
||||
@@ -116,11 +116,8 @@ func (d *DiscordAdapter) SendMessage(ctx context.Context, config map[string]inte
|
||||
// would propagate that token into logs and error responses (#659).
|
||||
return fmt.Errorf("discord: HTTP request failed")
|
||||
}
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
_ = resp.Body.Close()
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("discord: read response body: %w", readErr)
|
||||
}
|
||||
|
||||
// Discord returns 204 No Content on success.
|
||||
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||
|
||||
@@ -119,10 +119,7 @@ func (l *LarkAdapter) SendMessage(ctx context.Context, config map[string]interfa
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("lark: read response body: %w", readErr)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("lark: webhook returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
@@ -156,9 +156,6 @@ func (m *Manager) PausePollersForToken(workspaceID, botToken string) func() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("Channels: pause-pollers rows.Err: %v", err)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if len(pausedIDs) == 0 {
|
||||
@@ -207,16 +204,8 @@ func (m *Manager) Reload(ctx context.Context) {
|
||||
log.Printf("Channels: reload scan error: %v", err)
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal(configJSON, &ch.Config); err != nil {
|
||||
log.Printf("Channels: reload config unmarshal error for %s: %v", truncID(ch.ID), err)
|
||||
continue
|
||||
}
|
||||
if len(allowedJSON) > 0 {
|
||||
if err := json.Unmarshal(allowedJSON, &ch.AllowedUsers); err != nil {
|
||||
log.Printf("Channels: reload allowed_users unmarshal error for %s: %v", truncID(ch.ID), err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
_ = json.Unmarshal(configJSON, &ch.Config)
|
||||
_ = json.Unmarshal(allowedJSON, &ch.AllowedUsers)
|
||||
// #319: decrypt at the boundary between DB (ciphertext) and the
|
||||
// in-memory config adapters consume. A decrypt failure logs and
|
||||
// skips the channel — downstream getUpdates would fail anyway
|
||||
@@ -227,9 +216,6 @@ func (m *Manager) Reload(ctx context.Context) {
|
||||
}
|
||||
desired[ch.ID] = ch
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("Channels: reload rows.Err: %v", err)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -487,9 +473,6 @@ func (m *Manager) BroadcastToWorkspaceChannels(ctx context.Context, workspaceID,
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("Channels: broadcast rows.Err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// FetchWorkspaceChannelContext returns recent Slack channel messages formatted
|
||||
@@ -572,14 +555,8 @@ func (m *Manager) loadChannel(ctx context.Context, channelID string) (ChannelRow
|
||||
if err != nil {
|
||||
return ch, fmt.Errorf("channel %s not found: %w", channelID, err)
|
||||
}
|
||||
if err := json.Unmarshal(configJSON, &ch.Config); err != nil {
|
||||
return ch, fmt.Errorf("channel %s config unmarshal: %w", channelID, err)
|
||||
}
|
||||
if len(allowedJSON) > 0 {
|
||||
if err := json.Unmarshal(allowedJSON, &ch.AllowedUsers); err != nil {
|
||||
return ch, fmt.Errorf("channel %s allowed_users unmarshal: %w", channelID, err)
|
||||
}
|
||||
}
|
||||
json.Unmarshal(configJSON, &ch.Config)
|
||||
json.Unmarshal(allowedJSON, &ch.AllowedUsers)
|
||||
// #319: decrypt bot_token / webhook_secret — SendOutbound and adapter
|
||||
// methods downstream read them as plaintext strings.
|
||||
if err := DecryptSensitiveFields(ch.Config); err != nil {
|
||||
|
||||
@@ -171,11 +171,8 @@ func (s *SlackAdapter) sendBotMessage(ctx context.Context, config map[string]int
|
||||
if err != nil {
|
||||
return fmt.Errorf("slack: send: %w", err)
|
||||
}
|
||||
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
_ = resp.Body.Close()
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("slack: read response body: %w", readErr)
|
||||
}
|
||||
var result struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error"`
|
||||
@@ -211,13 +208,9 @@ func (s *SlackAdapter) sendWebhookMessage(ctx context.Context, config map[string
|
||||
if err != nil {
|
||||
return fmt.Errorf("slack: send: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("slack: webhook returned %d (read body failed: %v)", resp.StatusCode, readErr)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("slack: webhook returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil
|
||||
@@ -531,11 +524,8 @@ func FetchChannelHistory(ctx context.Context, botToken, channelID string, limit
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 65536))
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 65536))
|
||||
_ = resp.Body.Close()
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("slack: read history response: %w", readErr)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
OK bool `json:"ok"`
|
||||
|
||||
@@ -39,7 +39,6 @@ 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").
|
||||
@@ -59,7 +58,6 @@ 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").
|
||||
@@ -77,7 +75,6 @@ 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").
|
||||
|
||||
@@ -104,9 +104,6 @@ func (h *ChannelHandler) List(c *gin.Context) {
|
||||
}
|
||||
result = append(result, entry)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("Channels: list rows.Err: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
@@ -517,9 +514,6 @@ func (h *ChannelHandler) Webhook(c *gin.Context) {
|
||||
candidates = append(candidates, row)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("Channels: telegram webhook rows.Err: %v", err)
|
||||
}
|
||||
|
||||
if targetSlug != "" {
|
||||
// [slug] routing — match against config username (lowercased)
|
||||
|
||||
@@ -393,9 +393,6 @@ func queryPeerMaps(query string, args ...interface{}) ([]map[string]interface{},
|
||||
|
||||
result = append(result, peer)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("queryPeerMaps rows.Err: %v", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -49,9 +49,6 @@ func (h *EventsHandler) List(c *gin.Context) {
|
||||
"created_at": createdAt,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("Events list rows error: %v", err)
|
||||
}
|
||||
c.JSON(http.StatusOK, events)
|
||||
}
|
||||
|
||||
@@ -90,8 +87,5 @@ func (h *EventsHandler) ListByWorkspace(c *gin.Context) {
|
||||
"created_at": createdAt,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("WorkspaceEvents list rows error: %v", err)
|
||||
}
|
||||
c.JSON(http.StatusOK, events)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestWorkspaceCreate_WithParentID(t *testing.T) {
|
||||
// Default tier is 3 (Privileged) — see workspace.go create-handler comment.
|
||||
// delivery_mode defaults to "push" when payload omits it (#2339).
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Child Agent", nil, 3, "langgraph", &parentID, nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Child Agent", nil, 3, "langgraph", sqlmock.AnyArg(), &parentID, nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
@@ -44,7 +44,7 @@ func TestWorkspaceCreate_WithParentID(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
body := `{"name":"Child Agent","model":"anthropic:claude-opus-4-7","parent_id":"parent-ws-123"}`
|
||||
body := `{"name":"Child Agent","parent_id":"parent-ws-123"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -69,7 +69,7 @@ func TestWorkspaceCreate_ExplicitClaudeCodeRuntime(t *testing.T) {
|
||||
mock.ExpectBegin()
|
||||
// delivery_mode defaults to "push" when payload omits it (#2339).
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "CC Agent", nil, 2, "claude-code", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "CC Agent", nil, 2, "claude-code", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
@@ -80,7 +80,7 @@ func TestWorkspaceCreate_ExplicitClaudeCodeRuntime(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
body := `{"name":"CC Agent","tier":2,"runtime":"claude-code","model":"sonnet","canvas":{"x":10,"y":20}}`
|
||||
body := `{"name":"CC Agent","tier":2,"runtime":"claude-code","canvas":{"x":10,"y":20}}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -291,7 +291,7 @@ func TestWorkspaceCreate_MaxConcurrentTasksOverride(t *testing.T) {
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Leader Agent", nil, 3, "claude-code", (*string)(nil), nil, "none", (*int64)(nil), 3, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Leader Agent", nil, 3, "claude-code", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), 3, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
@@ -301,7 +301,7 @@ func TestWorkspaceCreate_MaxConcurrentTasksOverride(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
body := `{"name":"Leader Agent","runtime":"claude-code","model":"sonnet","max_concurrent_tasks":3}`
|
||||
body := `{"name":"Leader Agent","runtime":"claude-code","max_concurrent_tasks":3}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
|
||||
@@ -777,103 +777,6 @@ func TestCreate_FieldValidation_Returns400(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreate_ModelRequired_Returns422 pins the CTO 2026-05-22 SSOT
|
||||
// directive (feedback_workspace_model_required_no_platform_default_dynamic_credential_intake):
|
||||
// model is required user input; the platform must not supply a default,
|
||||
// the runtime must not fall back. Empirical trigger: Code Reviewer
|
||||
// 5ba15d7e was created with `{"name":..., "runtime":"codex", ...}` (no
|
||||
// model). The legacy DefaultModel fallback returned "anthropic:claude-opus-4-7"
|
||||
// and codex adapter wedged forever — `picks provider='anthropic' but it
|
||||
// is not in the providers registry`. The gate at the Create boundary
|
||||
// turns that silent stuck-workspace failure into an immediate 422 the
|
||||
// caller can react to.
|
||||
//
|
||||
// Three shapes covered:
|
||||
// 1. bare name (no template, no runtime, no model) — formerly defaulted
|
||||
// to langgraph + anthropic; now 422 because model is unspecified.
|
||||
// 2. explicit runtime, no model — the Code Reviewer repro shape.
|
||||
// 3. explicit runtime+template path, but template (when missing on
|
||||
// disk or unreadable) would leave model empty — exercised here by
|
||||
// pointing at a non-existent template under /tmp/configs.
|
||||
func TestCreate_ModelRequired_Returns422(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", "/tmp/configs")
|
||||
|
||||
cases := []struct{ label, body string }{
|
||||
{"bare_name_no_runtime_no_model", `{"name":"x"}`},
|
||||
{"explicit_codex_no_model", `{"name":"Code Reviewer","role":"code reviewer","runtime":"codex","tier":4,"max_concurrent_tasks":1}`},
|
||||
{"explicit_hermes_no_model", `{"name":"researcher","runtime":"hermes"}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.label, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(tc.body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
handler.Create(c)
|
||||
if w.Code != http.StatusUnprocessableEntity {
|
||||
t.Errorf("Create(%s): want 422 MODEL_REQUIRED, got %d: %s", tc.label, w.Code, w.Body.String())
|
||||
return
|
||||
}
|
||||
if !bytes.Contains(w.Body.Bytes(), []byte(`"code":"MODEL_REQUIRED"`)) {
|
||||
t.Errorf("Create(%s): want body containing code=MODEL_REQUIRED, got %s", tc.label, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreate_ExternalRuntime_NoModel_OK pins the external-runtime
|
||||
// exemption from the MODEL_REQUIRED gate. External workspaces
|
||||
// intentionally do not spawn a Docker container or run an adapter;
|
||||
// they delegate to a registered URL (workspace_provision.go:497-498:
|
||||
// "external is a first-class runtime that intentionally does NOT
|
||||
// spawn a Docker container"). The model field has no meaning for
|
||||
// them — the URL is the contract, and the gate would 422 every
|
||||
// legitimate "register my agent at https://..." flow.
|
||||
//
|
||||
// Both spellings count as external:
|
||||
// 1. payload.External == true (the canonical flag, e.g. with any runtime)
|
||||
// 2. payload.Runtime == "external" (legacy shape some E2E scripts still use)
|
||||
//
|
||||
// The isExternalLikeRuntime() helper catches both "external" and any
|
||||
// future external-like runtime alias.
|
||||
func TestCreate_ExternalRuntime_NoModel_OK(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
// External=true with explicit runtime — the test_api.sh / Echo Agent shape.
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`UPDATE workspaces SET status =`).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
body := `{"name":"Echo Agent","tier":1,"runtime":"external","external":true}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("external workspace without model: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_FieldValidation_Returns400(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
@@ -364,11 +364,11 @@ func TestWorkspaceCreate(t *testing.T) {
|
||||
// Expect transaction begin for atomic workspace+secrets creation
|
||||
mock.ExpectBegin()
|
||||
|
||||
// Expect workspace INSERT (uuid is dynamic, use AnyArg for id, runtime).
|
||||
// Expect workspace INSERT (uuid is dynamic, use AnyArg for id, runtime, awareness_namespace).
|
||||
// Default tier is 3 (Privileged) — see workspace.go create-handler comment.
|
||||
// delivery_mode defaults to "push" when payload omits it (#2339).
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Test Agent", nil, 3, "langgraph", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Test Agent", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// Expect transaction commit (no secrets in this payload)
|
||||
@@ -386,13 +386,7 @@ func TestWorkspaceCreate(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
// Note: model is now required at the Create boundary (CTO 2026-05-22
|
||||
// SSOT directive — see feedback_workspace_model_required_no_platform_default_dynamic_credential_intake
|
||||
// and TestCreate_ModelRequired_Returns422). This test happens to take
|
||||
// the bare-defaults path (no template, no runtime → langgraph), so
|
||||
// the body must declare an explicit model. Using a langgraph-compatible
|
||||
// id; the test doesn't exercise model semantics beyond presence.
|
||||
body := `{"name":"Test Agent","model":"anthropic:claude-opus-4-7","canvas":{"x":100,"y":200}}`
|
||||
body := `{"name":"Test Agent","canvas":{"x":100,"y":200}}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -412,17 +406,24 @@ func TestWorkspaceCreate(t *testing.T) {
|
||||
if resp["id"] == nil || resp["id"] == "" {
|
||||
t.Error("expected non-empty id in response")
|
||||
}
|
||||
if resp["awareness_namespace"] != "workspace:"+resp["id"].(string) {
|
||||
t.Errorf("expected awareness namespace derived from workspace id, got %v", resp["awareness_namespace"])
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProvisionerConfig_WorkspacePathFromPayload(t *testing.T) {
|
||||
func TestBuildProvisionerConfig_IncludesAwarenessSettings(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
// runtime_image_pins reader removed by RFC internal#617 / task #335
|
||||
// — CP is the SSOT for runtime image pins. No DB lookup here anymore.
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", "/tmp/configs")
|
||||
|
||||
t.Setenv("AWARENESS_URL", "http://awareness:37800")
|
||||
t.Setenv("WORKSPACE_DIR", "/tmp/workspace")
|
||||
|
||||
cfg := handler.buildProvisionerConfig(
|
||||
@@ -433,10 +434,17 @@ func TestBuildProvisionerConfig_WorkspacePathFromPayload(t *testing.T) {
|
||||
models.CreateWorkspacePayload{Tier: 2, Runtime: "claude-code", WorkspaceDir: "/tmp/workspace", WorkspaceAccess: "read_write"},
|
||||
map[string]string{"OPENAI_API_KEY": "sk-test"},
|
||||
"/tmp/plugins",
|
||||
"workspace:ws-123",
|
||||
)
|
||||
|
||||
if cfg.AwarenessURL != "http://awareness:37800" {
|
||||
t.Fatalf("expected awareness URL to be injected, got %q", cfg.AwarenessURL)
|
||||
}
|
||||
if cfg.AwarenessNamespace != "workspace:ws-123" {
|
||||
t.Fatalf("expected awareness namespace to be injected, got %q", cfg.AwarenessNamespace)
|
||||
}
|
||||
if cfg.WorkspacePath != "/tmp/workspace" {
|
||||
t.Fatalf("expected workspace path from payload, got %q", cfg.WorkspacePath)
|
||||
t.Fatalf("expected workspace path from env, got %q", cfg.WorkspacePath)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1141,8 +1141,6 @@ 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")
|
||||
@@ -1150,8 +1148,6 @@ 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")
|
||||
@@ -1159,8 +1155,6 @@ 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")
|
||||
@@ -1184,8 +1178,6 @@ 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)) {
|
||||
@@ -1195,8 +1187,6 @@ 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)) {
|
||||
@@ -1206,8 +1196,6 @@ 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)) {
|
||||
|
||||
@@ -95,18 +95,14 @@ func (h *MCPHandler) toolListPeers(ctx context.Context, workspaceID string) (str
|
||||
cols+` FROM workspaces w WHERE w.parent_id = $1 AND w.id != $2 AND w.status != 'removed'`,
|
||||
parentID.String, workspaceID)
|
||||
if err == nil {
|
||||
if scanErr := scanPeers(rows); scanErr != nil {
|
||||
log.Printf("MCP toolListPeers: sibling scan error: %v", scanErr)
|
||||
}
|
||||
_ = scanPeers(rows)
|
||||
}
|
||||
} else {
|
||||
rows, err := h.database.QueryContext(ctx,
|
||||
cols+` FROM workspaces w WHERE w.parent_id IS NULL AND w.id != $1 AND w.status != 'removed'`,
|
||||
workspaceID)
|
||||
if err == nil {
|
||||
if scanErr := scanPeers(rows); scanErr != nil {
|
||||
log.Printf("MCP toolListPeers: sibling scan error: %v", scanErr)
|
||||
}
|
||||
_ = scanPeers(rows)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,9 +112,7 @@ func (h *MCPHandler) toolListPeers(ctx context.Context, workspaceID string) (str
|
||||
cols+` FROM workspaces w WHERE w.parent_id = $1 AND w.status != 'removed'`,
|
||||
workspaceID)
|
||||
if err == nil {
|
||||
if scanErr := scanPeers(rows); scanErr != nil {
|
||||
log.Printf("MCP toolListPeers: children scan error: %v", scanErr)
|
||||
}
|
||||
_ = scanPeers(rows)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,9 +122,7 @@ func (h *MCPHandler) toolListPeers(ctx context.Context, workspaceID string) (str
|
||||
cols+` FROM workspaces w WHERE w.id = $1 AND w.status != 'removed'`,
|
||||
parentID.String)
|
||||
if err == nil {
|
||||
if scanErr := scanPeers(rows); scanErr != nil {
|
||||
log.Printf("MCP toolListPeers: parent scan error: %v", scanErr)
|
||||
}
|
||||
_ = scanPeers(rows)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -799,12 +799,13 @@ func (h *OrgHandler) Import(c *gin.Context) {
|
||||
if len(tmpl.GlobalMemories) > 0 && len(results) > 0 {
|
||||
rootID, _ := results[0]["id"].(string)
|
||||
if rootID != "" {
|
||||
rootNS := workspaceAwarenessNamespace(rootID)
|
||||
// Force scope to GLOBAL regardless of what the YAML says.
|
||||
globalSeeds := make([]models.MemorySeed, len(tmpl.GlobalMemories))
|
||||
for i, gm := range tmpl.GlobalMemories {
|
||||
globalSeeds[i] = models.MemorySeed{Content: gm.Content, Scope: "GLOBAL"}
|
||||
}
|
||||
seedInitialMemories(context.Background(), rootID, globalSeeds)
|
||||
seedInitialMemories(context.Background(), rootID, globalSeeds, rootNS)
|
||||
log.Printf("Org import: seeded %d global memories on root workspace %s", len(globalSeeds), rootID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,15 +69,10 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
|
||||
model = defaults.Model
|
||||
}
|
||||
if model == "" {
|
||||
// SSOT (CTO 2026-05-22, feedback_workspace_model_required_no_platform_default_dynamic_credential_intake):
|
||||
// model is REQUIRED. The org-import template MUST declare a
|
||||
// model — either per-workspace (`ws.Model`) or via the org
|
||||
// defaults block (`defaults.Model`). If neither is present
|
||||
// the template is malformed and the import must fail-closed
|
||||
// rather than silently provisioning a workspace with a
|
||||
// runtime-incompatible default (the prior `anthropic:claude-opus-4-7`
|
||||
// fallback wedged every codex workspace at adapter init).
|
||||
return fmt.Errorf("org import: workspace %q has no model and the org defaults block does not provide one (runtime=%s) — model is a required field per the workspace-creation contract; either set `model:` on the workspace or under `defaults:`", ws.Name, runtime)
|
||||
// SSOT: per-runtime defaults live in models/runtime_defaults.go
|
||||
// (see RFC #2873). Consolidated from a duplicate of the same
|
||||
// branch in workspace_provision.go.
|
||||
model = models.DefaultModel(runtime)
|
||||
}
|
||||
tier := ws.Tier
|
||||
if tier == 0 {
|
||||
@@ -102,6 +97,7 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
|
||||
}
|
||||
|
||||
id := uuid.New().String()
|
||||
awarenessNS := workspaceAwarenessNamespace(id)
|
||||
|
||||
var role interface{}
|
||||
if ws.Role != "" {
|
||||
@@ -167,13 +163,13 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
|
||||
// EXACTLY for Postgres to consider the index applicable.
|
||||
var insertedID string
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO workspaces (id, name, role, tier, runtime, status, parent_id, workspace_dir, workspace_access, max_concurrent_tasks)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
INSERT INTO workspaces (id, name, role, tier, runtime, awareness_namespace, status, parent_id, workspace_dir, workspace_access, max_concurrent_tasks)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
ON CONFLICT (COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), name)
|
||||
WHERE status != 'removed'
|
||||
DO NOTHING
|
||||
RETURNING id
|
||||
`, id, ws.Name, role, tier, runtime, "provisioning", parentID, workspaceDir, workspaceAccess, maxConcurrent).Scan(&insertedID)
|
||||
`, id, ws.Name, role, tier, runtime, awarenessNS, "provisioning", parentID, workspaceDir, workspaceAccess, maxConcurrent).Scan(&insertedID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// Skip path — a non-removed row already exists for
|
||||
// (parent_id, name). Re-select its id; idempotency-friendly
|
||||
@@ -258,7 +254,7 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
|
||||
if len(wsMemories) == 0 {
|
||||
wsMemories = defaults.InitialMemories
|
||||
}
|
||||
seedInitialMemories(ctx, id, wsMemories)
|
||||
seedInitialMemories(ctx, id, wsMemories, awarenessNS)
|
||||
|
||||
// Handle external workspaces
|
||||
if ws.External {
|
||||
|
||||
@@ -712,8 +712,6 @@ 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
|
||||
|
||||
@@ -133,30 +133,24 @@ func loadRestartContextData(ctx context.Context, workspaceID string) restartCont
|
||||
// message bus.
|
||||
keySet := map[string]struct{}{}
|
||||
if rows, err := db.DB.QueryContext(ctx, `SELECT key FROM global_secrets`); err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var k string
|
||||
if rows.Scan(&k) == nil {
|
||||
keySet[k] = struct{}{}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("loadRestartContextData: global_secrets rows.Err: %v", err)
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
if rows, err := db.DB.QueryContext(ctx,
|
||||
`SELECT key FROM workspace_secrets WHERE workspace_id = $1`, workspaceID,
|
||||
); err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var k string
|
||||
if rows.Scan(&k) == nil {
|
||||
keySet[k] = struct{}{}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("loadRestartContextData: workspace_secrets rows.Err: %v", err)
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
for k := range keySet {
|
||||
d.EnvKeys = append(d.EnvKeys, k)
|
||||
|
||||
@@ -417,9 +417,6 @@ func (h *ScheduleHandler) History(c *gin.Context) {
|
||||
e.Request = json.RawMessage(reqStr)
|
||||
entries = append(entries, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ScheduleHistory: rows error: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, entries)
|
||||
}
|
||||
|
||||
@@ -95,7 +95,6 @@ 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()
|
||||
@@ -153,7 +152,6 @@ 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()
|
||||
|
||||
@@ -51,10 +51,6 @@ func (h *TracesHandler) List(c *gin.Context) {
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read response body"})
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
c.Data(resp.StatusCode, "application/json", body)
|
||||
}
|
||||
|
||||
@@ -216,6 +216,7 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
}
|
||||
|
||||
id := uuid.New().String()
|
||||
awarenessNamespace := workspaceAwarenessNamespace(id)
|
||||
if h.IsSaaS() {
|
||||
// SaaS hard gate: every hosted workspace gets its own sibling
|
||||
// EC2 instance, so T4 is the only meaningful runtime boundary.
|
||||
@@ -320,51 +321,6 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
payload.Runtime = "langgraph"
|
||||
}
|
||||
|
||||
// SSOT (CTO 2026-05-22, feedback_workspace_model_required_no_platform_default_dynamic_credential_intake):
|
||||
// model is REQUIRED user input for SPAWNED-runtime workspaces. The
|
||||
// platform must not provide a default; the runtime must not fall back.
|
||||
// The decision belongs to the user (or to the agent acting on the
|
||||
// user's behalf), never to the platform.
|
||||
//
|
||||
// Empirical trigger: Code Reviewer 5ba15d7e was created with
|
||||
// `{"name":"Code Reviewer","role":"...","runtime":"codex",...}` (no
|
||||
// model). The legacy `DefaultModel(runtime)` fallback in
|
||||
// provisionWorkspace returned `"anthropic:claude-opus-4-7"`. Codex
|
||||
// adapter only supports openai-* providers — it wedged forever with
|
||||
// `codex adapter: workspace config picks provider='anthropic' but
|
||||
// it is not in the providers registry`. PATCH /workspaces/:id
|
||||
// explicitly disallows updating model (the comment literally reads
|
||||
// `model not patchable`), so the only recovery path was SQL UPDATE
|
||||
// or delete+recreate.
|
||||
//
|
||||
// External workspaces are EXEMPT — they intentionally do not spawn
|
||||
// a Docker container or run an adapter; they delegate to a registered
|
||||
// URL (see provision.go: "external is a first-class runtime that
|
||||
// intentionally does NOT spawn a Docker container"). The MODEL_REQUIRED
|
||||
// gate is meaningful for spawned-runtime workspaces where the model
|
||||
// id drives provider selection at adapter init. For external workspaces
|
||||
// the contract is the URL, not the model — requiring it would be
|
||||
// ceremony with no payoff, and would 422 every legitimate "register
|
||||
// my agent at https://..." flow. The SSOT directive concerns
|
||||
// platform-side defaults; an external workspace genuinely has no
|
||||
// "model decision" for the user to make.
|
||||
//
|
||||
// Fail-closed at the Create boundary so the caller learns the
|
||||
// contract immediately — same shape as the controlplane#188
|
||||
// runtime-unresolved gate above. Caller fixes the request, no
|
||||
// EC2 launched, no stuck workspace, no operator paging.
|
||||
isExternal := payload.External || isExternalLikeRuntime(payload.Runtime)
|
||||
if payload.Model == "" && !isExternal {
|
||||
log.Printf("Create: FAIL-CLOSED — model is required (runtime=%q template=%q); refusing the silent DefaultModel fallback per CTO 2026-05-22 SSOT directive", payload.Runtime, payload.Template)
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{
|
||||
"error": "model is required and has no platform-side default — pass an explicit \"model\" in the request body, or use a \"template\" whose config.yaml declares one. See feedback_workspace_model_required_no_platform_default_dynamic_credential_intake for the contract.",
|
||||
"runtime": payload.Runtime,
|
||||
"template": payload.Template,
|
||||
"code": "MODEL_REQUIRED",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Convert empty role to NULL
|
||||
@@ -447,10 +403,10 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
// returns the actually-persisted name (which we MUST thread back into
|
||||
// payload + broadcast so the canvas displays what the DB has).
|
||||
const insertWorkspaceSQL = `
|
||||
INSERT INTO workspaces (id, name, role, tier, runtime, status, parent_id, workspace_dir, workspace_access, budget_limit, max_concurrent_tasks, delivery_mode)
|
||||
VALUES ($1, $2, $3, $4, $5, 'provisioning', $6, $7, $8, $9, $10, $11)
|
||||
INSERT INTO workspaces (id, name, role, tier, runtime, awareness_namespace, status, parent_id, workspace_dir, workspace_access, budget_limit, max_concurrent_tasks, delivery_mode)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'provisioning', $7, $8, $9, $10, $11, $12)
|
||||
`
|
||||
insertArgs := []any{id, payload.Name, role, payload.Tier, payload.Runtime, payload.ParentID, workspaceDir, workspaceAccess, payload.BudgetLimit, maxConcurrent, deliveryMode}
|
||||
insertArgs := []any{id, payload.Name, role, payload.Tier, payload.Runtime, awarenessNamespace, payload.ParentID, workspaceDir, workspaceAccess, payload.BudgetLimit, maxConcurrent, deliveryMode}
|
||||
persistedName, currentTx, err := insertWorkspaceWithNameRetry(
|
||||
ctx,
|
||||
tx,
|
||||
@@ -571,7 +527,7 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
|
||||
// Seed initial memories from the create payload (issue #1050).
|
||||
// Non-fatal: failures are logged but don't block workspace creation.
|
||||
seedInitialMemories(ctx, id, payload.InitialMemories)
|
||||
seedInitialMemories(ctx, id, payload.InitialMemories, awarenessNamespace)
|
||||
|
||||
// Broadcast provisioning event. Include `runtime` so the canvas can
|
||||
// populate the Runtime pill on the side panel immediately — without it
|
||||
@@ -706,9 +662,10 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"id": id,
|
||||
"status": "provisioning",
|
||||
"workspace_access": workspaceAccess,
|
||||
"id": id,
|
||||
"status": "provisioning",
|
||||
"awareness_namespace": awarenessNamespace,
|
||||
"workspace_access": workspaceAccess,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -152,6 +152,7 @@ func TestWorkspaceBudget_Create_WithLimit(t *testing.T) {
|
||||
nil, // role
|
||||
3, // tier (default, workspace.go create-handler)
|
||||
"langgraph", // runtime
|
||||
sqlmock.AnyArg(), // awareness_namespace
|
||||
(*string)(nil), // parent_id
|
||||
nil, // workspace_dir
|
||||
"none", // workspace_access
|
||||
@@ -169,7 +170,7 @@ func TestWorkspaceBudget_Create_WithLimit(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
body := `{"name":"Budgeted Agent","model":"anthropic:claude-opus-4-7","budget_limit":1000}`
|
||||
body := `{"name":"Budgeted Agent","budget_limit":1000}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
handler.Create(c)
|
||||
|
||||
@@ -110,7 +110,6 @@ 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))
|
||||
@@ -145,6 +144,7 @@ func TestBuildProvisionerConfig_CopiesComputeSizingFromPayload(t *testing.T) {
|
||||
},
|
||||
nil,
|
||||
t.TempDir(),
|
||||
"workspace:ws-compute",
|
||||
)
|
||||
|
||||
if cfg.InstanceType != "m6i.xlarge" {
|
||||
|
||||
@@ -103,13 +103,13 @@ func cleanupTestRows(t *testing.T, conn *sql.DB, namePrefix string) {
|
||||
// TestIntegration_WorkspaceCreate_NameRetry_AutoSuffixesOnCollision
|
||||
// exercises the helper end-to-end against a real Postgres:
|
||||
//
|
||||
// 1. INSERT a row with name "<prefix>-Repro" — succeeds.
|
||||
// 2. Run insertWorkspaceWithNameRetry with the same name —
|
||||
// partial-unique violation fires, helper retries with
|
||||
// " (2)", that succeeds.
|
||||
// 3. SELECT the row by id, confirm name = "<prefix>-Repro (2)".
|
||||
// 4. Run helper AGAIN — second collision, helper retries with
|
||||
// " (3)".
|
||||
// 1. INSERT a row with name "<prefix>-Repro" — succeeds.
|
||||
// 2. Run insertWorkspaceWithNameRetry with the same name —
|
||||
// partial-unique violation fires, helper retries with
|
||||
// " (2)", that succeeds.
|
||||
// 3. SELECT the row by id, confirm name = "<prefix>-Repro (2)".
|
||||
// 4. Run helper AGAIN — second collision, helper retries with
|
||||
// " (3)".
|
||||
//
|
||||
// This is the live-test that proves the partial-index behaviour
|
||||
// matches the migration's intent — sqlmock cannot reach this depth.
|
||||
@@ -130,9 +130,9 @@ func TestIntegration_WorkspaceCreate_NameRetry_AutoSuffixesOnCollision(t *testin
|
||||
// targets + the NOT NULL columns required by the schema).
|
||||
firstID := uuid.New().String()
|
||||
if _, err := conn.ExecContext(ctx, `
|
||||
INSERT INTO workspaces (id, name, tier, runtime, status)
|
||||
VALUES ($1, $2, 2, 'claude-code', 'provisioning')
|
||||
`, firstID, baseName); err != nil {
|
||||
INSERT INTO workspaces (id, name, tier, runtime, awareness_namespace, status)
|
||||
VALUES ($1, $2, 2, 'claude-code', $3, 'provisioning')
|
||||
`, firstID, baseName, "workspace:"+firstID); err != nil {
|
||||
t.Fatalf("seed first row: %v", err)
|
||||
}
|
||||
|
||||
@@ -145,10 +145,10 @@ func TestIntegration_WorkspaceCreate_NameRetry_AutoSuffixesOnCollision(t *testin
|
||||
}
|
||||
secondID := uuid.New().String()
|
||||
query := `
|
||||
INSERT INTO workspaces (id, name, tier, runtime, status)
|
||||
VALUES ($1, $2, 2, 'claude-code', 'provisioning')
|
||||
INSERT INTO workspaces (id, name, tier, runtime, awareness_namespace, status)
|
||||
VALUES ($1, $2, 2, 'claude-code', $3, 'provisioning')
|
||||
`
|
||||
args := []any{secondID, baseName}
|
||||
args := []any{secondID, baseName, "workspace:" + secondID}
|
||||
persistedName, finalTx, err := insertWorkspaceWithNameRetry(
|
||||
ctx, tx, beginTx, baseName, 1, query, args,
|
||||
)
|
||||
@@ -179,7 +179,7 @@ func TestIntegration_WorkspaceCreate_NameRetry_AutoSuffixesOnCollision(t *testin
|
||||
t.Fatalf("begin tx3: %v", err)
|
||||
}
|
||||
thirdID := uuid.New().String()
|
||||
args3 := []any{thirdID, baseName}
|
||||
args3 := []any{thirdID, baseName, "workspace:" + thirdID}
|
||||
persistedName3, finalTx3, err := insertWorkspaceWithNameRetry(
|
||||
ctx, tx3, beginTx, baseName, 1, query, args3,
|
||||
)
|
||||
@@ -216,9 +216,9 @@ func TestIntegration_WorkspaceCreate_NameRetry_TombstonedRowDoesNotCollide(t *te
|
||||
// Seed a row, then tombstone it.
|
||||
firstID := uuid.New().String()
|
||||
if _, err := conn.ExecContext(ctx, `
|
||||
INSERT INTO workspaces (id, name, tier, runtime, status)
|
||||
VALUES ($1, $2, 2, 'claude-code', 'removed')
|
||||
`, firstID, baseName); err != nil {
|
||||
INSERT INTO workspaces (id, name, tier, runtime, awareness_namespace, status)
|
||||
VALUES ($1, $2, 2, 'claude-code', $3, 'removed')
|
||||
`, firstID, baseName, "workspace:"+firstID); err != nil {
|
||||
t.Fatalf("seed tombstoned row: %v", err)
|
||||
}
|
||||
|
||||
@@ -231,10 +231,10 @@ func TestIntegration_WorkspaceCreate_NameRetry_TombstonedRowDoesNotCollide(t *te
|
||||
}
|
||||
secondID := uuid.New().String()
|
||||
query := `
|
||||
INSERT INTO workspaces (id, name, tier, runtime, status)
|
||||
VALUES ($1, $2, 2, 'claude-code', 'provisioning')
|
||||
INSERT INTO workspaces (id, name, tier, runtime, awareness_namespace, status)
|
||||
VALUES ($1, $2, 2, 'claude-code', $3, 'provisioning')
|
||||
`
|
||||
args := []any{secondID, baseName}
|
||||
args := []any{secondID, baseName, "workspace:" + secondID}
|
||||
persistedName, finalTx, err := insertWorkspaceWithNameRetry(
|
||||
ctx, tx, beginTx, baseName, 1, query, args,
|
||||
)
|
||||
|
||||
@@ -435,16 +435,13 @@ 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)
|
||||
}
|
||||
}
|
||||
if err := descRows.Err(); err != nil {
|
||||
return nil, nil, fmt.Errorf("CascadeDelete: failed iterating descendants: %w", err)
|
||||
}
|
||||
descRows.Close()
|
||||
|
||||
allIDs := append([]string{id}, descendantIDs...)
|
||||
|
||||
|
||||
@@ -503,32 +503,6 @@ 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.
|
||||
|
||||
@@ -1,360 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
displayControlDefaultTTLSeconds = 300
|
||||
displayControlMinTTLSeconds = 30
|
||||
displayControlMaxTTLSeconds = 3600
|
||||
)
|
||||
|
||||
type workspaceDisplayControlResponse struct {
|
||||
Controller string `json:"controller"`
|
||||
ControlledBy string `json:"controlled_by,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
type workspaceDisplayControlNoneResponse struct {
|
||||
Controller string `json:"controller"`
|
||||
}
|
||||
|
||||
type acquireDisplayControlRequest struct {
|
||||
Controller string `json:"controller"`
|
||||
TTLSeconds int `json:"ttl_seconds"`
|
||||
}
|
||||
|
||||
type releaseDisplayControlRequest struct {
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
|
||||
// DisplayControl handles GET /workspaces/:id/display/control.
|
||||
func (h *WorkspaceHandler) DisplayControl(c *gin.Context) {
|
||||
lock, found, err := h.loadActiveDisplayControl(c, c.Param("id"))
|
||||
if err != nil {
|
||||
log.Printf("DisplayControl: load lock for %s failed: %v", c.Param("id"), err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load display control"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusOK, workspaceDisplayControlNoneResponse{Controller: "none"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, lock)
|
||||
}
|
||||
|
||||
// AcquireDisplayControl handles POST /workspaces/:id/display/control/acquire.
|
||||
func (h *WorkspaceHandler) AcquireDisplayControl(c *gin.Context) {
|
||||
var req acquireDisplayControlRequest
|
||||
if c.Request.Body != nil && c.Request.ContentLength != 0 {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid display control request"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Controller == "" {
|
||||
req.Controller = "user"
|
||||
}
|
||||
if req.Controller != "user" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "browser callers may only acquire user display control"})
|
||||
return
|
||||
}
|
||||
if req.TTLSeconds == 0 {
|
||||
req.TTLSeconds = displayControlDefaultTTLSeconds
|
||||
}
|
||||
if req.TTLSeconds < displayControlMinTTLSeconds || req.TTLSeconds > displayControlMaxTTLSeconds {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ttl_seconds must be between 30 and 3600"})
|
||||
return
|
||||
}
|
||||
if ok := h.displayControlEnabled(c, c.Param("id")); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
controlledBy, ok := displayControlActor(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "display control requires admin-token or org-token auth"})
|
||||
return
|
||||
}
|
||||
workspaceID := c.Param("id")
|
||||
startedAt := time.Now()
|
||||
emitDisplayControlEvent(c.Request.Context(), "display.control.acquire.started", workspaceID, map[string]any{
|
||||
"controller": req.Controller,
|
||||
"controlled_by": controlledBy,
|
||||
"ttl_seconds": req.TTLSeconds,
|
||||
})
|
||||
var lock workspaceDisplayControlResponse
|
||||
err := db.DB.QueryRowContext(c.Request.Context(), `
|
||||
INSERT INTO workspace_display_control_locks
|
||||
(workspace_id, controller, controlled_by, expires_at)
|
||||
VALUES
|
||||
($1, $2, $3, now() + ($4 * interval '1 second'))
|
||||
ON CONFLICT (workspace_id) DO UPDATE
|
||||
SET controller = EXCLUDED.controller,
|
||||
controlled_by = EXCLUDED.controlled_by,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
updated_at = now()
|
||||
WHERE workspace_display_control_locks.expires_at <= now()
|
||||
OR workspace_display_control_locks.controlled_by = EXCLUDED.controlled_by
|
||||
RETURNING controller, controlled_by, expires_at`,
|
||||
workspaceID, req.Controller, controlledBy, req.TTLSeconds,
|
||||
).Scan(&lock.Controller, &lock.ControlledBy, &lock.ExpiresAt)
|
||||
if err == nil {
|
||||
emitDisplayControlEvent(c.Request.Context(), "display.control.acquire.completed", workspaceID, map[string]any{
|
||||
"controller": lock.Controller,
|
||||
"controlled_by": lock.ControlledBy,
|
||||
"ttl_seconds": req.TTLSeconds,
|
||||
"duration_ms": time.Since(startedAt).Milliseconds(),
|
||||
})
|
||||
c.JSON(http.StatusOK, lock)
|
||||
return
|
||||
}
|
||||
if err == sql.ErrNoRows {
|
||||
current, found, loadErr := h.loadActiveDisplayControl(c, workspaceID)
|
||||
if loadErr != nil {
|
||||
log.Printf("AcquireDisplayControl: load active lock for %s failed: %v", workspaceID, loadErr)
|
||||
emitDisplayControlEvent(c.Request.Context(), "display.control.acquire.failed", workspaceID, map[string]any{
|
||||
"controlled_by": controlledBy,
|
||||
"duration_ms": time.Since(startedAt).Milliseconds(),
|
||||
"error": loadErr.Error(),
|
||||
})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load display control"})
|
||||
return
|
||||
}
|
||||
emitDisplayControlEvent(c.Request.Context(), "display.control.acquire.failed", workspaceID, map[string]any{
|
||||
"controlled_by": controlledBy,
|
||||
"duration_ms": time.Since(startedAt).Milliseconds(),
|
||||
"error": "display control already held",
|
||||
})
|
||||
if !found {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "display control already held",
|
||||
"current": workspaceDisplayControlNoneResponse{Controller: "none"},
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "display control already held",
|
||||
"current": current,
|
||||
})
|
||||
return
|
||||
}
|
||||
log.Printf("AcquireDisplayControl: acquire lock for %s failed: %v", workspaceID, err)
|
||||
emitDisplayControlEvent(c.Request.Context(), "display.control.acquire.failed", workspaceID, map[string]any{
|
||||
"controlled_by": controlledBy,
|
||||
"duration_ms": time.Since(startedAt).Milliseconds(),
|
||||
"error": err.Error(),
|
||||
})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to acquire display control"})
|
||||
}
|
||||
|
||||
// ReleaseDisplayControl handles POST /workspaces/:id/display/control/release.
|
||||
func (h *WorkspaceHandler) ReleaseDisplayControl(c *gin.Context) {
|
||||
var req releaseDisplayControlRequest
|
||||
if c.Request.Body != nil && c.Request.ContentLength != 0 {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid display control release request"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Force {
|
||||
if !displayControlIsAdminToken(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "force release requires admin-token auth"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
controlledBy, ok := displayControlActor(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "display control requires admin-token or org-token auth"})
|
||||
return
|
||||
}
|
||||
workspaceID := c.Param("id")
|
||||
startedAt := time.Now()
|
||||
emitDisplayControlEvent(c.Request.Context(), "display.control.release.started", workspaceID, map[string]any{
|
||||
"controlled_by": controlledBy,
|
||||
"force": req.Force,
|
||||
})
|
||||
query := `DELETE FROM workspace_display_control_locks WHERE workspace_id = $1 AND controlled_by = $2`
|
||||
args := []interface{}{workspaceID, controlledBy}
|
||||
if req.Force {
|
||||
query = `DELETE FROM workspace_display_control_locks WHERE workspace_id = $1`
|
||||
args = []interface{}{workspaceID}
|
||||
}
|
||||
result, err := db.DB.ExecContext(c.Request.Context(), query, args...)
|
||||
if err != nil {
|
||||
log.Printf("ReleaseDisplayControl: release lock for %s failed: %v", workspaceID, err)
|
||||
emitDisplayControlEvent(c.Request.Context(), "display.control.release.failed", workspaceID, map[string]any{
|
||||
"controlled_by": controlledBy,
|
||||
"duration_ms": time.Since(startedAt).Milliseconds(),
|
||||
"error": err.Error(),
|
||||
"force": req.Force,
|
||||
})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to release display control"})
|
||||
return
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
log.Printf("ReleaseDisplayControl: rows affected for %s failed: %v", workspaceID, err)
|
||||
emitDisplayControlEvent(c.Request.Context(), "display.control.release.failed", workspaceID, map[string]any{
|
||||
"controlled_by": controlledBy,
|
||||
"duration_ms": time.Since(startedAt).Milliseconds(),
|
||||
"error": err.Error(),
|
||||
"force": req.Force,
|
||||
})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to release display control"})
|
||||
return
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
current, found, loadErr := h.loadActiveDisplayControl(c, workspaceID)
|
||||
if loadErr != nil {
|
||||
log.Printf("ReleaseDisplayControl: load active lock for %s failed: %v", workspaceID, loadErr)
|
||||
emitDisplayControlEvent(c.Request.Context(), "display.control.release.failed", workspaceID, map[string]any{
|
||||
"controlled_by": controlledBy,
|
||||
"duration_ms": time.Since(startedAt).Milliseconds(),
|
||||
"error": loadErr.Error(),
|
||||
"force": req.Force,
|
||||
})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load display control"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
emitDisplayControlEvent(c.Request.Context(), "display.control.release.completed", workspaceID, map[string]any{
|
||||
"controlled_by": controlledBy,
|
||||
"duration_ms": time.Since(startedAt).Milliseconds(),
|
||||
"force": req.Force,
|
||||
"rows_affected": rowsAffected,
|
||||
})
|
||||
c.JSON(http.StatusOK, workspaceDisplayControlNoneResponse{Controller: "none"})
|
||||
return
|
||||
}
|
||||
emitDisplayControlEvent(c.Request.Context(), "display.control.release.failed", workspaceID, map[string]any{
|
||||
"controlled_by": controlledBy,
|
||||
"duration_ms": time.Since(startedAt).Milliseconds(),
|
||||
"error": "display control held by another caller",
|
||||
"force": req.Force,
|
||||
})
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "display control held by another caller",
|
||||
"current": current,
|
||||
})
|
||||
return
|
||||
}
|
||||
emitDisplayControlEvent(c.Request.Context(), "display.control.release.completed", workspaceID, map[string]any{
|
||||
"controlled_by": controlledBy,
|
||||
"duration_ms": time.Since(startedAt).Milliseconds(),
|
||||
"force": req.Force,
|
||||
"rows_affected": rowsAffected,
|
||||
})
|
||||
c.JSON(http.StatusOK, workspaceDisplayControlNoneResponse{Controller: "none"})
|
||||
}
|
||||
|
||||
func (h *WorkspaceHandler) loadActiveDisplayControl(c *gin.Context, workspaceID string) (workspaceDisplayControlResponse, bool, error) {
|
||||
var lock workspaceDisplayControlResponse
|
||||
err := db.DB.QueryRowContext(c.Request.Context(),
|
||||
`SELECT controller, controlled_by, expires_at FROM workspace_display_control_locks WHERE workspace_id = $1 AND expires_at > now()`,
|
||||
workspaceID,
|
||||
).Scan(&lock.Controller, &lock.ControlledBy, &lock.ExpiresAt)
|
||||
if err == nil {
|
||||
return lock, true, nil
|
||||
}
|
||||
if err == sql.ErrNoRows {
|
||||
return workspaceDisplayControlResponse{}, false, nil
|
||||
}
|
||||
return workspaceDisplayControlResponse{}, false, err
|
||||
}
|
||||
|
||||
func (h *WorkspaceHandler) displayControlEnabled(c *gin.Context, workspaceID string) bool {
|
||||
var raw string
|
||||
err := db.DB.QueryRowContext(c.Request.Context(),
|
||||
`SELECT COALESCE(compute, '{}'::jsonb) FROM workspaces WHERE id = $1`,
|
||||
workspaceID,
|
||||
).Scan(&raw)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||
return false
|
||||
}
|
||||
log.Printf("displayControlEnabled: load compute for %s failed: %v", workspaceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load display config"})
|
||||
return false
|
||||
}
|
||||
compute, err := parseWorkspaceDisplayCompute(workspaceID, raw)
|
||||
if err != nil {
|
||||
log.Printf("displayControlEnabled: invalid display config for %s: %v", workspaceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid display config"})
|
||||
return false
|
||||
}
|
||||
if compute.Display.Mode == "" || compute.Display.Mode == "none" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "display not enabled"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseWorkspaceDisplayCompute(workspaceID, raw string) (models.WorkspaceCompute, error) {
|
||||
var compute models.WorkspaceCompute
|
||||
if raw == "" || raw == "{}" {
|
||||
return compute, nil
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &compute); err != nil {
|
||||
return models.WorkspaceCompute{}, fmt.Errorf("invalid compute JSON for %s: %w", workspaceID, err)
|
||||
}
|
||||
if err := validateWorkspaceDisplayConfig(compute.Display); err != nil {
|
||||
return models.WorkspaceCompute{}, err
|
||||
}
|
||||
return compute, nil
|
||||
}
|
||||
|
||||
func displayControlActor(c *gin.Context) (string, bool) {
|
||||
if v, ok := c.Get("org_token_prefix"); ok {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return actorOrgTokenPrefix + s, true
|
||||
}
|
||||
}
|
||||
if displayControlIsAdminToken(c) {
|
||||
return actorAdminToken, true
|
||||
}
|
||||
// Browser session auth is intentionally observe-only until AdminAuth
|
||||
// exposes a stable per-user or per-session identity in gin.Context.
|
||||
return "", false
|
||||
}
|
||||
|
||||
func displayControlIsAdminToken(c *gin.Context) bool {
|
||||
adminSecret := os.Getenv("ADMIN_TOKEN")
|
||||
if adminSecret == "" {
|
||||
return false
|
||||
}
|
||||
tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization"))
|
||||
return subtle.ConstantTimeCompare([]byte(tok), []byte(adminSecret)) == 1
|
||||
}
|
||||
|
||||
func emitDisplayControlEvent(ctx context.Context, eventType string, workspaceID string, payload map[string]any) {
|
||||
if payload == nil {
|
||||
payload = map[string]any{}
|
||||
}
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
log.Printf("emitDisplayControlEvent: marshal %s payload failed: %v", eventType, err)
|
||||
return
|
||||
}
|
||||
if _, err := db.DB.ExecContext(ctx, `
|
||||
INSERT INTO structure_events (event_type, workspace_id, payload, created_at)
|
||||
VALUES ($1, $2, $3::jsonb, now())
|
||||
`, eventType, workspaceID, string(payloadJSON)); err != nil {
|
||||
log.Printf("emitDisplayControlEvent: insert %s failed: %v", eventType, err)
|
||||
}
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func attachDisplayControlAdminToken(t *testing.T, c *gin.Context) {
|
||||
t.Helper()
|
||||
t.Setenv("ADMIN_TOKEN", "test-admin-secret")
|
||||
c.Request.Header.Set("Authorization", "Bearer test-admin-secret")
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplayControl_NoActiveLockReturnsNone(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT controller, controlled_by, expires_at FROM workspace_display_control_locks WHERE workspace_id = \$1 AND expires_at > now\(\)`).
|
||||
WithArgs("ws-display").
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
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/control", nil)
|
||||
|
||||
handler.DisplayControl(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp["controller"] != "none" {
|
||||
t.Fatalf("controller = %v, want none", resp["controller"])
|
||||
}
|
||||
if _, ok := resp["expires_at"]; ok {
|
||||
t.Fatalf("none response included expires_at: %#v", resp)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplayControlAcquire_ClaimsUnlockedDisplay(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
expiresAt := time.Date(2026, 5, 23, 18, 30, 0, 0, time.UTC)
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-display").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
|
||||
mock.ExpectQuery(`INSERT INTO workspace_display_control_locks`).
|
||||
WithArgs("ws-display", "user", "admin-token", 300).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"controller", "controlled_by", "expires_at"}).
|
||||
AddRow("user", "admin-token", expiresAt))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/acquire", bytes.NewBufferString(`{"controller":"user","ttl_seconds":300}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
attachDisplayControlAdminToken(t, c)
|
||||
|
||||
handler.AcquireDisplayControl(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp["controller"] != "user" || resp["controlled_by"] != "admin-token" {
|
||||
t.Fatalf("lock response = %#v, want user/admin-token", resp)
|
||||
}
|
||||
if resp["expires_at"] == "" {
|
||||
t.Fatalf("expires_at missing in response: %#v", resp)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplayControlAcquire_ActiveLockReturnsConflict(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
expiresAt := time.Date(2026, 5, 23, 18, 30, 0, 0, time.UTC)
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-display").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
|
||||
mock.ExpectQuery(`INSERT INTO workspace_display_control_locks`).
|
||||
WithArgs("ws-display", "user", "admin-token", 300).
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
mock.ExpectQuery(`SELECT controller, controlled_by, expires_at FROM workspace_display_control_locks WHERE workspace_id = \$1 AND expires_at > now\(\)`).
|
||||
WithArgs("ws-display").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"controller", "controlled_by", "expires_at"}).
|
||||
AddRow("agent", "sidecar", expiresAt))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/acquire", bytes.NewBufferString(`{"controller":"user","ttl_seconds":300}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
attachDisplayControlAdminToken(t, c)
|
||||
|
||||
handler.AcquireDisplayControl(c)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("expected status 409, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp["error"] != "display control already held" {
|
||||
t.Fatalf("error = %v, want display control already held", resp["error"])
|
||||
}
|
||||
current, ok := resp["current"].(map[string]interface{})
|
||||
if !ok || current["controller"] != "agent" || current["controlled_by"] != "sidecar" {
|
||||
t.Fatalf("current lock = %#v, want agent/sidecar", resp["current"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplayControlAcquire_RejectsDisplayDisabledWorkspace(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-no-display").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{}`))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-no-display"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-no-display/display/control/acquire", bytes.NewBufferString(`{"controller":"user","ttl_seconds":300}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
attachDisplayControlAdminToken(t, c)
|
||||
|
||||
handler.AcquireDisplayControl(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp["error"] != "display not enabled" {
|
||||
t.Fatalf("error = %v, want display not enabled", resp["error"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplayControlAcquire_RejectsCoarseSessionActor(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
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":"dcv","width":1920,"height":1080}}`))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/acquire", bytes.NewBufferString(`{"controller":"user","ttl_seconds":300}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request.Header.Set("Cookie", "molecule_session=present")
|
||||
|
||||
handler.AcquireDisplayControl(c)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected status 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp["error"] != "display control requires admin-token or org-token auth" {
|
||||
t.Fatalf("error = %v, want display control requires admin-token or org-token auth", resp["error"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplayControlRelease_RemovesCallerLock(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectExec(`DELETE FROM workspace_display_control_locks WHERE workspace_id = \$1 AND controlled_by = \$2`).
|
||||
WithArgs("ws-display", "admin-token").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/release", nil)
|
||||
attachDisplayControlAdminToken(t, c)
|
||||
|
||||
handler.ReleaseDisplayControl(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp["controller"] != "none" {
|
||||
t.Fatalf("controller = %v, want none", resp["controller"])
|
||||
}
|
||||
if _, ok := resp["expires_at"]; ok {
|
||||
t.Fatalf("none response included expires_at: %#v", resp)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplayControlRelease_ConflictWhenCallerDoesNotOwnLock(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
expiresAt := time.Date(2026, 5, 23, 18, 30, 0, 0, time.UTC)
|
||||
|
||||
mock.ExpectExec(`DELETE FROM workspace_display_control_locks WHERE workspace_id = \$1 AND controlled_by = \$2`).
|
||||
WithArgs("ws-display", "admin-token").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectQuery(`SELECT controller, controlled_by, expires_at FROM workspace_display_control_locks WHERE workspace_id = \$1 AND expires_at > now\(\)`).
|
||||
WithArgs("ws-display").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"controller", "controlled_by", "expires_at"}).
|
||||
AddRow("user", "org-token:abcd1234", expiresAt))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/release", nil)
|
||||
attachDisplayControlAdminToken(t, c)
|
||||
|
||||
handler.ReleaseDisplayControl(c)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("expected status 409, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp["error"] != "display control held by another caller" {
|
||||
t.Fatalf("error = %v, want display control held by another caller", resp["error"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplayControlRelease_RejectsOrgTokenForceRelease(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
|
||||
c.Set("org_token_prefix", "abcd1234")
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/release", bytes.NewBufferString(`{"force":true}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.ReleaseDisplayControl(c)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected status 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp["error"] != "force release requires admin-token auth" {
|
||||
t.Fatalf("error = %v, want force release requires admin-token auth", resp["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplayControlAcquire_RejectsAgentImpersonation(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/acquire", bytes.NewBufferString(`{"controller":"agent","ttl_seconds":300}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
attachDisplayControlAdminToken(t, c)
|
||||
|
||||
handler.AcquireDisplayControl(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp["error"] != "browser callers may only acquire user display control" {
|
||||
t.Fatalf("error = %v, want browser callers may only acquire user display control", resp["error"])
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,7 @@ func (h *WorkspaceHandler) provisionWorkspaceOpts(workspaceID, templatePath stri
|
||||
workspaceID, filepath.Base(runtimeTemplate))
|
||||
templatePath = runtimeTemplate
|
||||
// Rebuild cfg with the recovered template path so Start() sees it.
|
||||
cfg = h.buildProvisionerConfig(ctx, workspaceID, templatePath, configFiles, payload, prepared.EnvVars, prepared.PluginsPath)
|
||||
cfg = h.buildProvisionerConfig(ctx, workspaceID, templatePath, configFiles, payload, prepared.EnvVars, prepared.PluginsPath, prepared.AwarenessNamespace)
|
||||
cfg.ResetClaudeSession = resetClaudeSession
|
||||
recovered = true
|
||||
break
|
||||
@@ -194,11 +194,10 @@ func (h *WorkspaceHandler) provisionWorkspaceOpts(workspaceID, templatePath stri
|
||||
// a ~64k context window worth of text — but small enough to prevent abuse.
|
||||
const maxMemoryContentLength = 100_000 // ~100 KiB of text
|
||||
|
||||
func seedInitialMemories(ctx context.Context, workspaceID string, memories []models.MemorySeed) {
|
||||
func seedInitialMemories(ctx context.Context, workspaceID string, memories []models.MemorySeed, awarenessNamespace string) {
|
||||
if len(memories) == 0 {
|
||||
return
|
||||
}
|
||||
namespace := workspaceMemoryNamespace(workspaceID)
|
||||
for _, mem := range memories {
|
||||
scope := strings.ToUpper(mem.Scope)
|
||||
if scope == "" {
|
||||
@@ -224,27 +223,33 @@ func seedInitialMemories(ctx context.Context, workspaceID string, memories []mod
|
||||
if _, err := db.DB.ExecContext(ctx, `
|
||||
INSERT INTO agent_memories (workspace_id, content, scope, namespace)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`, workspaceID, redactedContent, scope, namespace); err != nil {
|
||||
`, workspaceID, redactedContent, scope, awarenessNamespace); err != nil {
|
||||
log.Printf("seedInitialMemories: failed to insert memory for %s (scope=%s): %v", workspaceID, scope, err)
|
||||
}
|
||||
}
|
||||
log.Printf("seedInitialMemories: seeded %d memories for workspace %s", len(memories), workspaceID)
|
||||
}
|
||||
|
||||
// workspaceMemoryNamespace returns the canonical v2 memory namespace
|
||||
// string for a workspace. Matches the form produced by
|
||||
// internal/memory/namespace/resolver.go for self-reads (issue #1735).
|
||||
func workspaceMemoryNamespace(workspaceID string) string {
|
||||
func workspaceAwarenessNamespace(workspaceID string) string {
|
||||
return fmt.Sprintf("workspace:%s", workspaceID)
|
||||
}
|
||||
|
||||
func (h *WorkspaceHandler) loadAwarenessNamespace(ctx context.Context, workspaceID string) string {
|
||||
var awarenessNamespace string
|
||||
err := db.DB.QueryRowContext(ctx, `SELECT COALESCE(awareness_namespace, '') FROM workspaces WHERE id = $1`, workspaceID).Scan(&awarenessNamespace)
|
||||
if err != nil || awarenessNamespace == "" {
|
||||
return workspaceAwarenessNamespace(workspaceID)
|
||||
}
|
||||
return awarenessNamespace
|
||||
}
|
||||
|
||||
func (h *WorkspaceHandler) buildProvisionerConfig(
|
||||
ctx context.Context,
|
||||
workspaceID, templatePath string,
|
||||
configFiles map[string][]byte,
|
||||
payload models.CreateWorkspacePayload,
|
||||
envVars map[string]string,
|
||||
pluginsPath string,
|
||||
pluginsPath, awarenessNamespace string,
|
||||
) provisioner.WorkspaceConfig {
|
||||
// Per-workspace workspace_dir takes priority over global WORKSPACE_DIR env var.
|
||||
// If neither is set, the provisioner creates an isolated Docker volume.
|
||||
@@ -283,18 +288,20 @@ 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),
|
||||
EnvVars: envVars,
|
||||
PlatformURL: h.platformURL,
|
||||
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),
|
||||
EnvVars: envVars,
|
||||
PlatformURL: h.platformURL,
|
||||
AwarenessURL: os.Getenv("AWARENESS_URL"),
|
||||
AwarenessNamespace: awarenessNamespace,
|
||||
// Image left empty — molecule-core's runtime_image_pins table (mig
|
||||
// 047, dead reader removed by RFC internal#617 / task #335) was an
|
||||
// aspirational SSOT that never received a writer. CP's
|
||||
@@ -543,22 +550,13 @@ func (h *WorkspaceHandler) ensureDefaultConfig(workspaceID string, payload model
|
||||
// via a crafted runtime string (#241).
|
||||
runtime := sanitizeRuntime(payload.Runtime)
|
||||
|
||||
// Generate a minimal config.yaml.
|
||||
//
|
||||
// SSOT (CTO 2026-05-22): model is REQUIRED user input. The platform
|
||||
// must not provide a default; the runtime must not fall back. The
|
||||
// Create handler is responsible for rejecting empty model BEFORE
|
||||
// reaching provisionWorkspace; this is a defence-in-depth assertion.
|
||||
// If we hit here with an empty model the YAML below would still
|
||||
// render a `model: ""` line — which renders all downstream provider
|
||||
// derivation undefined. Log loudly and let the workspace boot into
|
||||
// not_configured rather than masking the contract violation with a
|
||||
// silently-broken default (the prior `anthropic:claude-opus-4-7`
|
||||
// fallback was the canonical example — every codex workspace
|
||||
// created without an explicit model wedged).
|
||||
// Generate a minimal config.yaml
|
||||
model := payload.Model
|
||||
if model == "" {
|
||||
log.Printf("ensureDefaultConfig: workspace %s reached provisioning with empty model — Create handler should have rejected this; rendering empty model: \"\" in config.yaml (workspace will boot not_configured)", workspaceID)
|
||||
// SSOT: per-runtime defaults live in models/runtime_defaults.go
|
||||
// (see RFC #2873). Was previously duplicated here AND in
|
||||
// org_import.go; consolidating prevents silent drift.
|
||||
model = models.DefaultModel(runtime)
|
||||
}
|
||||
if runtime == "claude-code" {
|
||||
model = normalizeClaudeCodeModel(model)
|
||||
|
||||
@@ -85,9 +85,10 @@ func readOrLazyHealInboundSecret(ctx context.Context, workspaceID, opLabel strin
|
||||
// prepareProvisionContext when the caller proceeds; nil + non-empty
|
||||
// abort message when the caller must mark the workspace failed.
|
||||
type preparedProvisionContext struct {
|
||||
EnvVars map[string]string
|
||||
PluginsPath string
|
||||
Config provisioner.WorkspaceConfig
|
||||
EnvVars map[string]string
|
||||
PluginsPath string
|
||||
AwarenessNamespace string
|
||||
Config provisioner.WorkspaceConfig
|
||||
}
|
||||
|
||||
// provisionAbort describes why prepareProvisionContext refused to
|
||||
@@ -169,6 +170,7 @@ func (h *WorkspaceHandler) prepareProvisionContext(
|
||||
}
|
||||
|
||||
pluginsPath, _ := filepath.Abs(filepath.Join(h.configsDir, "..", "plugins"))
|
||||
awarenessNamespace := h.loadAwarenessNamespace(ctx, workspaceID)
|
||||
|
||||
// Per-agent git identity (#1957) — must run after secret loads so
|
||||
// a workspace_secret named GIT_AUTHOR_NAME can override.
|
||||
@@ -229,13 +231,14 @@ func (h *WorkspaceHandler) prepareProvisionContext(
|
||||
}
|
||||
}
|
||||
|
||||
cfg := h.buildProvisionerConfig(ctx, workspaceID, templatePath, configFiles, payload, envVars, pluginsPath)
|
||||
cfg := h.buildProvisionerConfig(ctx, workspaceID, templatePath, configFiles, payload, envVars, pluginsPath, awarenessNamespace)
|
||||
cfg.ResetClaudeSession = resetClaudeSession
|
||||
|
||||
return &preparedProvisionContext{
|
||||
EnvVars: envVars,
|
||||
PluginsPath: pluginsPath,
|
||||
Config: cfg,
|
||||
EnvVars: envVars,
|
||||
PluginsPath: pluginsPath,
|
||||
AwarenessNamespace: awarenessNamespace,
|
||||
Config: cfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -756,55 +756,47 @@ func TestWorkspaceCreate_FirstDeploy_PersistsModelAndProvider(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkspaceCreate_FirstDeploy_NoModel_Returns422 inverts the prior
|
||||
// premise (CTO 2026-05-22 SSOT directive — see
|
||||
// feedback_workspace_model_required_no_platform_default_dynamic_credential_intake
|
||||
// and TestCreate_ModelRequired_Returns422 in handlers_extended_test.go).
|
||||
//
|
||||
// Pre-2026-05-22 the canvas was allowed to omit `model` and the workspace
|
||||
// would 201 with no workspace_secrets rows for MODEL/LLM_PROVIDER (the
|
||||
// thinking being that templates inherit the runtime default later). That
|
||||
// "soft fallback" was the load-bearing bug magnet — `DefaultModel(runtime)`
|
||||
// would later return `anthropic:claude-opus-4-7`, and codex workspaces
|
||||
// wedged forever at adapter init.
|
||||
//
|
||||
// New contract: empty model is a 422 MODEL_REQUIRED, with NO DB writes
|
||||
// at all. The gate fires at the Create boundary before INSERT INTO
|
||||
// workspaces. The follow-on workspace_secrets gate (which the original
|
||||
// test pinned) is therefore unreachable on the empty-model path — there
|
||||
// is no row to mint secrets for.
|
||||
func TestWorkspaceCreate_FirstDeploy_NoModel_Returns422(t *testing.T) {
|
||||
// TestWorkspaceCreate_FirstDeploy_NoModel_NoSecretWritten asserts that
|
||||
// when payload.Model is empty, NEITHER MODEL nor LLM_PROVIDER is
|
||||
// written. Important: the canvas can omit `model` (template inherits
|
||||
// the runtime default later); we must not poison workspace_secrets with
|
||||
// empty rows in that case.
|
||||
func TestWorkspaceCreate_FirstDeploy_NoModel_NoSecretWritten(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
// NO mock.ExpectBegin / INSERT INTO workspaces — the Create gate
|
||||
// MUST fire before any DB write. If the gate fires late, sqlmock
|
||||
// will surface "call to ExecQuery 'INSERT INTO workspaces' was not
|
||||
// expected" — which is exactly the failure mode we want to flag.
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
// NO INSERT INTO workspace_secrets here — the gate is payload.Model != "".
|
||||
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`UPDATE workspaces SET status =`).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// Body: hermes runtime WITHOUT external:true (the external-runtime
|
||||
// exemption — see TestCreate_ExternalRuntime_NoModel_OK — does NOT
|
||||
// apply here; hermes spawns a real adapter and model selection
|
||||
// matters at adapter init). This is exactly the shape the old
|
||||
// "no-model-no-secret-write" test pinned, minus the external flag.
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
body := `{"name":"No Model Agent","runtime":"hermes"}`
|
||||
body := `{"name":"No Model Agent","runtime":"hermes","external":true}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("expected 422 MODEL_REQUIRED for empty model, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !bytes.Contains(w.Body.Bytes(), []byte(`"code":"MODEL_REQUIRED"`)) {
|
||||
t.Errorf("expected code=MODEL_REQUIRED in body, got %s", w.Body.String())
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected status 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock saw an unexpected DB write — the MODEL_REQUIRED gate fired too late: %v", err)
|
||||
t.Errorf("sqlmock expectations not met — empty payload.Model should NOT trigger workspace_secrets writes: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,9 +17,9 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ==================== workspaceMemoryNamespace ====================
|
||||
// ==================== workspaceAwarenessNamespace ====================
|
||||
|
||||
func TestWorkspaceMemoryNamespace(t *testing.T) {
|
||||
func TestWorkspaceAwarenessNamespace(t *testing.T) {
|
||||
tests := []struct {
|
||||
workspaceID string
|
||||
expected string
|
||||
@@ -31,9 +31,9 @@ func TestWorkspaceMemoryNamespace(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.workspaceID, func(t *testing.T) {
|
||||
result := workspaceMemoryNamespace(tt.workspaceID)
|
||||
result := workspaceAwarenessNamespace(tt.workspaceID)
|
||||
if result != tt.expected {
|
||||
t.Errorf("workspaceMemoryNamespace(%q) = %q, want %q", tt.workspaceID, result, tt.expected)
|
||||
t.Errorf("workspaceAwarenessNamespace(%q) = %q, want %q", tt.workspaceID, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -193,17 +193,10 @@ func TestEnsureDefaultConfig_Hermes(t *testing.T) {
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
// Post-CTO-SSOT-directive (2026-05-22): model is required user input;
|
||||
// ensureDefaultConfig no longer fills in a runtime default. The Create
|
||||
// handler gates on empty model and 422s before reaching here, so this
|
||||
// test now passes the model explicitly to exercise the YAML rendering
|
||||
// path — same model value the prior implicit DefaultModel("hermes")
|
||||
// returned.
|
||||
payload := models.CreateWorkspacePayload{
|
||||
Name: "Test Agent",
|
||||
Tier: 1,
|
||||
Runtime: "hermes",
|
||||
Model: "anthropic:claude-opus-4-7",
|
||||
}
|
||||
|
||||
files := handler.ensureDefaultConfig("ws-test-123", payload)
|
||||
@@ -226,7 +219,7 @@ func TestEnsureDefaultConfig_Hermes(t *testing.T) {
|
||||
t.Errorf("config.yaml missing tier, got:\n%s", content)
|
||||
}
|
||||
if !contains(content, `model: "anthropic:claude-opus-4-7"`) {
|
||||
t.Errorf("config.yaml should render the supplied model, got:\n%s", content)
|
||||
t.Errorf("config.yaml should use default non-claude model, got:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,14 +227,10 @@ func TestEnsureDefaultConfig_ClaudeCode(t *testing.T) {
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
// Post-CTO-SSOT-directive (2026-05-22): model is supplied explicitly
|
||||
// instead of relying on the deleted DefaultModel("claude-code") =
|
||||
// "sonnet" fallback. The Create handler 422s on empty model upstream.
|
||||
payload := models.CreateWorkspacePayload{
|
||||
Name: "Code Agent",
|
||||
Tier: 2,
|
||||
Runtime: "claude-code",
|
||||
Model: "sonnet",
|
||||
}
|
||||
|
||||
files := handler.ensureDefaultConfig("ws-code-123", payload)
|
||||
@@ -418,16 +407,9 @@ func TestEnsureDefaultConfig_EmptyRuntimeDefaultsToClaudeCode(t *testing.T) {
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
// Post-CTO-SSOT-directive (2026-05-22): ensureDefaultConfig is no
|
||||
// longer the source of the model default — it just renders whatever
|
||||
// the Create handler decided. The "empty runtime → claude-code"
|
||||
// fallback inside sanitizeRuntime() is still in effect; this test
|
||||
// continues to pin that behaviour by supplying the explicit
|
||||
// claude-code model that the Create handler would have required.
|
||||
payload := models.CreateWorkspacePayload{
|
||||
Name: "Default Agent",
|
||||
Tier: 1,
|
||||
Model: "sonnet",
|
||||
Name: "Default Agent",
|
||||
Tier: 1,
|
||||
}
|
||||
|
||||
files := handler.ensureDefaultConfig("ws-empty-rt", payload)
|
||||
@@ -436,7 +418,7 @@ func TestEnsureDefaultConfig_EmptyRuntimeDefaultsToClaudeCode(t *testing.T) {
|
||||
t.Errorf("empty runtime should default to claude-code, got:\n%s", configYAML)
|
||||
}
|
||||
if !contains(configYAML, `model: "sonnet"`) {
|
||||
t.Errorf("claude-code workspace should render the supplied model (quoted), got:\n%s", configYAML)
|
||||
t.Errorf("claude-code default model should be sonnet (quoted), got:\n%s", configYAML)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -645,7 +627,7 @@ func TestSeedInitialMemories_TruncatesOversizedContent(t *testing.T) {
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
}
|
||||
|
||||
seedInitialMemories(context.Background(), workspaceID, memories)
|
||||
seedInitialMemories(context.Background(), workspaceID, memories, "test-ns")
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet DB expectations: %v", err)
|
||||
@@ -674,7 +656,7 @@ func TestSeedInitialMemories_RedactsSecrets(t *testing.T) {
|
||||
WithArgs(workspaceID, wantRedacted, "LOCAL", sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
seedInitialMemories(context.Background(), workspaceID, memories)
|
||||
seedInitialMemories(context.Background(), workspaceID, memories, "test-ns")
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet DB expectations: %v", err)
|
||||
@@ -691,7 +673,7 @@ func TestSeedInitialMemories_InvalidScopeSkipped(t *testing.T) {
|
||||
{Content: "this should be skipped", Scope: "NOT_A_REAL_SCOPE"},
|
||||
}
|
||||
|
||||
seedInitialMemories(context.Background(), "ws-bad-scope", memories)
|
||||
seedInitialMemories(context.Background(), "ws-bad-scope", memories, "test-ns")
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unexpected DB calls for invalid scope: %v", err)
|
||||
@@ -704,7 +686,7 @@ func TestSeedInitialMemories_EmptyMemoriesNil(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectationsWereMet()
|
||||
|
||||
seedInitialMemories(context.Background(), "ws-nil", nil)
|
||||
seedInitialMemories(context.Background(), "ws-nil", nil, "test-ns")
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unexpected DB calls for nil slice: %v", err)
|
||||
@@ -733,6 +715,7 @@ func TestBuildProvisionerConfig_BasicFields(t *testing.T) {
|
||||
models.CreateWorkspacePayload{Tier: 1, Runtime: "langgraph"},
|
||||
map[string]string{"API_KEY": "secret"},
|
||||
pluginsPath,
|
||||
"workspace:ws-basic",
|
||||
)
|
||||
|
||||
if cfg.WorkspaceID != "ws-basic" {
|
||||
@@ -747,6 +730,9 @@ func TestBuildProvisionerConfig_BasicFields(t *testing.T) {
|
||||
if cfg.PlatformURL != "http://localhost:8080" {
|
||||
t.Errorf("expected PlatformURL 'http://localhost:8080', got %q", cfg.PlatformURL)
|
||||
}
|
||||
if cfg.AwarenessNamespace != "workspace:ws-basic" {
|
||||
t.Errorf("expected AwarenessNamespace 'workspace:ws-basic', got %q", cfg.AwarenessNamespace)
|
||||
}
|
||||
if cfg.PluginsPath != pluginsPath {
|
||||
t.Errorf("expected PluginsPath %q, got %q", pluginsPath, cfg.PluginsPath)
|
||||
}
|
||||
@@ -771,6 +757,7 @@ func TestBuildProvisionerConfig_WorkspacePathFromEnv(t *testing.T) {
|
||||
|
||||
workspaceDir := t.TempDir()
|
||||
t.Setenv("WORKSPACE_DIR", workspaceDir)
|
||||
t.Setenv("AWARENESS_URL", "http://awareness:37800")
|
||||
|
||||
pluginsPath := t.TempDir()
|
||||
cfg := handler.buildProvisionerConfig(
|
||||
@@ -781,11 +768,15 @@ func TestBuildProvisionerConfig_WorkspacePathFromEnv(t *testing.T) {
|
||||
models.CreateWorkspacePayload{Tier: 2, Runtime: "claude-code"},
|
||||
nil,
|
||||
pluginsPath,
|
||||
"workspace:ws-env",
|
||||
)
|
||||
|
||||
if cfg.WorkspacePath != workspaceDir {
|
||||
t.Errorf("expected WorkspacePath from env, got %q", cfg.WorkspacePath)
|
||||
}
|
||||
if cfg.AwarenessURL != "http://awareness:37800" {
|
||||
t.Errorf("expected AwarenessURL from env, got %q", cfg.AwarenessURL)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== issueAndInjectToken (issue #418) ====================
|
||||
@@ -797,8 +788,6 @@ 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`).
|
||||
@@ -836,8 +825,6 @@ 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`).
|
||||
@@ -904,8 +891,6 @@ 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").
|
||||
@@ -932,8 +917,6 @@ 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").
|
||||
@@ -998,7 +981,7 @@ func TestSeedInitialMemories_Truncation(t *testing.T) {
|
||||
WithArgs(sqlmock.AnyArg(), expectTruncated, "LOCAL", sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
seedInitialMemories(context.Background(), "ws-1066-test", memories)
|
||||
seedInitialMemories(context.Background(), "ws-1066-test", memories, "test-ns")
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("DB expectations not met: %v\n"+
|
||||
@@ -1018,7 +1001,7 @@ func TestSeedInitialMemories_ContentUnderLimit(t *testing.T) {
|
||||
WithArgs(sqlmock.AnyArg(), "short content", "TEAM", sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
seedInitialMemories(context.Background(), "ws-1066-under", memories)
|
||||
seedInitialMemories(context.Background(), "ws-1066-under", memories, "test-ns")
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("DB expectations not met: %v", err)
|
||||
@@ -1043,7 +1026,7 @@ func TestSeedInitialMemories_ExactlyAtLimit(t *testing.T) {
|
||||
WithArgs(sqlmock.AnyArg(), atLimitContent, "LOCAL", sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
seedInitialMemories(context.Background(), "ws-boundary", memories)
|
||||
seedInitialMemories(context.Background(), "ws-boundary", memories, "test-ns")
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("DB expectations not met: %v", err)
|
||||
@@ -1059,7 +1042,7 @@ func TestSeedInitialMemories_EmptyContent(t *testing.T) {
|
||||
}
|
||||
|
||||
// seedInitialMemories skips empty content at line 234 — no DB call expected.
|
||||
seedInitialMemories(context.Background(), "ws-empty", memories)
|
||||
seedInitialMemories(context.Background(), "ws-empty", memories, "test-ns")
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("DB expectations not met: %v", err)
|
||||
@@ -1083,7 +1066,7 @@ func TestSeedInitialMemories_OversizedWithSecrets(t *testing.T) {
|
||||
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), "GLOBAL", sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
seedInitialMemories(context.Background(), "ws-secrets", memories)
|
||||
seedInitialMemories(context.Background(), "ws-secrets", memories, "test-ns")
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("DB expectations not met: %v", err)
|
||||
|
||||
@@ -858,9 +858,6 @@ func (h *WorkspaceHandler) Pause(c *gin.Context) {
|
||||
toPause = append(toPause, struct{ id, name string }{cid, cname})
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("Pause: descendant query rows.Err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop containers and mark all as paused. StopWorkspaceAuto routes
|
||||
@@ -942,9 +939,6 @@ func (h *WorkspaceHandler) Resume(c *gin.Context) {
|
||||
toResume = append(toResume, ws)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("Resume: descendant query rows.Err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-provision all
|
||||
|
||||
@@ -342,14 +342,14 @@ func TestWorkspaceCreate_DBInsertError(t *testing.T) {
|
||||
// Transaction begins, workspace INSERT fails, transaction is rolled back.
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Failing Agent", nil, 3, "langgraph", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Failing Agent", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
mock.ExpectRollback()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
body := `{"name":"Failing Agent","model":"anthropic:claude-opus-4-7"}`
|
||||
body := `{"name":"Failing Agent"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -375,7 +375,7 @@ func TestWorkspaceCreate_DefaultsApplied(t *testing.T) {
|
||||
// Expect workspace INSERT with defaulted tier=3 (Privileged — the
|
||||
// handler default in workspace.go), runtime="langgraph"
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Default Agent", nil, 3, "langgraph", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Default Agent", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
@@ -391,7 +391,7 @@ func TestWorkspaceCreate_DefaultsApplied(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
body := `{"name":"Default Agent","model":"anthropic:claude-opus-4-7"}`
|
||||
body := `{"name":"Default Agent"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -423,7 +423,7 @@ func TestWorkspaceCreate_SaaSHardForcesTier4(t *testing.T) {
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "SaaS External Agent", nil, 4, "external", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "SaaS External Agent", nil, 4, "external", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
@@ -438,7 +438,7 @@ func TestWorkspaceCreate_SaaSHardForcesTier4(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
body := `{"name":"SaaS External Agent","runtime":"external","model":"external:custom","external":true,"url":"https://example.com/agent","tier":2}`
|
||||
body := `{"name":"SaaS External Agent","runtime":"external","external":true,"url":"https://example.com/agent","tier":2}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -464,7 +464,7 @@ func TestWorkspaceCreate_WithSecrets_Persists(t *testing.T) {
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Hermes Agent", nil, 3, "hermes", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Hermes Agent", nil, 3, "hermes", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// Secret inserted inside the same transaction.
|
||||
mock.ExpectExec("INSERT INTO workspace_secrets").
|
||||
@@ -479,7 +479,7 @@ func TestWorkspaceCreate_WithSecrets_Persists(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
body := `{"name":"Hermes Agent","runtime":"hermes","model":"anthropic:claude-opus-4-7","external":true,"secrets":{"HERMES_API_KEY":"sk-test-123"}}`
|
||||
body := `{"name":"Hermes Agent","runtime":"hermes","external":true,"secrets":{"HERMES_API_KEY":"sk-test-123"}}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -513,7 +513,7 @@ func TestWorkspaceCreate_SecretPersistFails_RollsBack(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
body := `{"name":"Rollback Agent","model":"anthropic:claude-opus-4-7","secrets":{"OPENAI_API_KEY":"sk-fail"}}`
|
||||
body := `{"name":"Rollback Agent","secrets":{"OPENAI_API_KEY":"sk-fail"}}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -548,7 +548,7 @@ func TestWorkspaceCreate_EmptySecrets_OK(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
body := `{"name":"No Secrets Agent","model":"anthropic:claude-opus-4-7","external":true,"secrets":{}}`
|
||||
body := `{"name":"No Secrets Agent","external":true,"secrets":{}}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -576,7 +576,7 @@ func TestWorkspaceCreate_ExternalURL_SSRFSafe(t *testing.T) {
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Ext Agent", nil, 3, "external", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Ext Agent", nil, 3, "external", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
// External URL update (localhost is explicitly allowed by validateAgentURL).
|
||||
@@ -587,7 +587,7 @@ func TestWorkspaceCreate_ExternalURL_SSRFSafe(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
body := `{"name":"Ext Agent","runtime":"external","model":"external:custom","external":true,"url":"http://localhost:8000"}`
|
||||
body := `{"name":"Ext Agent","runtime":"external","external":true,"url":"http://localhost:8000"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -615,7 +615,7 @@ func TestWorkspaceCreate_KimiRuntime_PreservesLabel(t *testing.T) {
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Kimi Agent", nil, 3, "kimi", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Kimi Agent", nil, 3, "kimi", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
// Pre-register flow: awaiting_agent + runtime preserved as "kimi"
|
||||
@@ -629,7 +629,7 @@ func TestWorkspaceCreate_KimiRuntime_PreservesLabel(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
body := `{"name":"Kimi Agent","runtime":"kimi","model":"kimi-coding/kimi-k2-coding-6","tier":3,"canvas":{"x":100,"y":100}}`
|
||||
body := `{"name":"Kimi Agent","runtime":"kimi","tier":3,"canvas":{"x":100,"y":100}}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -659,7 +659,7 @@ func TestWorkspaceCreate_ExternalURL_SSRFMetadataBlocked(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
body := `{"name":"Bad Agent","runtime":"external","model":"external:custom","external":true,"url":"http://169.254.169.254/latest/meta-data/"}`
|
||||
body := `{"name":"Bad Agent","runtime":"external","external":true,"url":"http://169.254.169.254/latest/meta-data/"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -690,7 +690,7 @@ func TestWorkspaceCreate_ExternalURL_SSRFLoopbackBlocked(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
body := `{"name":"Bad Loopback","runtime":"external","model":"external:custom","external":true,"url":"http://127.0.0.1:9000/a2a"}`
|
||||
body := `{"name":"Bad Loopback","runtime":"external","external":true,"url":"http://127.0.0.1:9000/a2a"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -1639,7 +1639,7 @@ runtime_config:
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(
|
||||
sqlmock.AnyArg(), "Hermes Agent", nil, 3, "hermes",
|
||||
(*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
@@ -1696,7 +1696,7 @@ model: anthropic:claude-sonnet-4-5
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(
|
||||
sqlmock.AnyArg(), "Legacy Agent", nil, 3, "langgraph",
|
||||
(*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
@@ -1749,7 +1749,7 @@ runtime_config:
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(
|
||||
sqlmock.AnyArg(), "Custom Hermes", nil, 3, "hermes",
|
||||
(*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
@@ -1844,43 +1844,39 @@ func TestWorkspaceCreate_188_TemplateConfigNoRuntimeKey_FailsClosed(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-2026-05-22 this test guarded "bare {name} → langgraph 201" — the
|
||||
// regression check for controlplane#188 (where an explicit runtime that
|
||||
// failed to resolve must NOT silently substitute langgraph) had a sibling
|
||||
// to ensure the LEGITIMATE bare default still landed on langgraph.
|
||||
//
|
||||
// Post-CTO-SSOT-directive (2026-05-22) bare body is 422 MODEL_REQUIRED
|
||||
// before reaching the langgraph branch — the gate runs AFTER the
|
||||
// langgraph-default assignment so the error body still surfaces
|
||||
// runtime=langgraph (helps the caller see "ok, langgraph WOULD have
|
||||
// been the runtime, but you still owe me a model"). The bare-body
|
||||
// langgraph 201 path no longer exists; what we guard now is the
|
||||
// 422-shape diagnostic.
|
||||
//
|
||||
// Bare-body-with-explicit-model 201 (the new "legitimate default" path)
|
||||
// is covered by TestWorkspaceCreate in handlers_test.go — no need to
|
||||
// duplicate the mock dance here.
|
||||
func TestWorkspaceCreate_188_NoTemplateNoRuntime_NowMODEL_REQUIRED(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
// Regression guard: the legitimate default path (no template, no runtime —
|
||||
// bare {"name":...}) MUST still default to langgraph and return 201. The
|
||||
// #188 fix must not break this.
|
||||
func TestWorkspaceCreate_188_NoTemplateNoRuntime_StillDefaultsLanggraph(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Plain Default", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
WithArgs(sqlmock.AnyArg(), float64(0), float64(0)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
body := `{"name":"Plain Default"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("bare-body create: expected 422 MODEL_REQUIRED, got %d: %s", w.Code, w.Body.String())
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 (legitimate default path), got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !bytes.Contains(w.Body.Bytes(), []byte(`"code":"MODEL_REQUIRED"`)) {
|
||||
t.Errorf("bare-body create: expected code=MODEL_REQUIRED in body, got %s", w.Body.String())
|
||||
}
|
||||
if !bytes.Contains(w.Body.Bytes(), []byte(`"runtime":"langgraph"`)) {
|
||||
t.Errorf("bare-body create: expected runtime=\"langgraph\" in 422 body (the gate runs AFTER the langgraph-default assignment so the diagnostic surfaces what runtime WOULD have been used), got %s", w.Body.String())
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1894,7 +1890,7 @@ func TestWorkspaceCreate_188_ExplicitRuntimeNoTemplate_OK(t *testing.T) {
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Explicit Codex", nil, 3, "codex", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Explicit Codex", nil, 3, "codex", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
@@ -1905,7 +1901,7 @@ func TestWorkspaceCreate_188_ExplicitRuntimeNoTemplate_OK(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
body := `{"name":"Explicit Codex","runtime":"codex","model":"gpt-5.5"}`
|
||||
body := `{"name":"Explicit Codex","runtime":"codex"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package pgplugin
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -247,9 +246,7 @@ func (h *Handler) forget(w http.ResponseWriter, r *http.Request, id string) {
|
||||
func writeJSON(w http.ResponseWriter, status int, body interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||||
log.Printf("pgplugin: JSON encode error: %v", err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, code contract.ErrorCode, message string, details map[string]interface{}) {
|
||||
|
||||
@@ -256,7 +256,6 @@ 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)
|
||||
@@ -376,7 +375,6 @@ 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)
|
||||
@@ -420,7 +418,6 @@ 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)
|
||||
@@ -703,7 +700,6 @@ 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)
|
||||
@@ -1102,7 +1098,6 @@ 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)
|
||||
@@ -1256,7 +1251,6 @@ 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)
|
||||
|
||||
@@ -1,31 +1,39 @@
|
||||
package models
|
||||
|
||||
// runtime_defaults.go — DELETED helper. Intentionally empty.
|
||||
// runtime_defaults.go — single source of truth for per-runtime defaults
|
||||
// the platform applies when the operator/agent didn't supply a value.
|
||||
//
|
||||
// Previously held `DefaultModel(runtime string) string` which returned
|
||||
// "sonnet" for claude-code and "anthropic:claude-opus-4-7" for everything
|
||||
// else. That function was a SOFT-FALLBACK bug magnet:
|
||||
// Why this lives in models/ (not handlers/): default selection is a
|
||||
// pure data fact about the runtime, not handler logic. Multiple
|
||||
// callers (Create-workspace handler, org-import handler, future
|
||||
// auto-provision paths) need the same answer; concentrating the
|
||||
// rule here means one edit when a runtime's default changes.
|
||||
//
|
||||
// - codex workspaces created without an explicit `model` silently
|
||||
// received `anthropic:claude-opus-4-7`. Codex adapter only supports
|
||||
// openai-* providers, so they wedged in `not_configured` with
|
||||
// `codex adapter: workspace config picks provider='anthropic' but
|
||||
// it is not in the providers registry`. The fallback never matched
|
||||
// a runtime that could actually use it (only langgraph + hermes
|
||||
// could even partially execute anthropic:claude-opus-4-7 without
|
||||
// extra credential plumbing). It existed as a "must return
|
||||
// something" placeholder that turned every silent miss into a
|
||||
// prod incident.
|
||||
// Related work (RFC #2873): this is the seed for a future
|
||||
// `RuntimeConfig` interface that will also expose `ProvisioningTimeout()`,
|
||||
// `CapabilitiesSupported()`, and other per-runtime facts. For now the
|
||||
// surface is one helper — extracted from the duplicate branch in
|
||||
// workspace_provision.go:537 and org_import.go:54 that diverged silently
|
||||
// during refactors before this consolidation.
|
||||
|
||||
// DefaultModel returns the model slug to use when a workspace is
|
||||
// created without an explicit model and the runtime can't infer one
|
||||
// from its own config.
|
||||
//
|
||||
// - The fallback hid the contract bug at every callsite: Create
|
||||
// handler, org_import, anywhere a stale CreateWorkspacePayload
|
||||
// bubbled through to provisionWorkspace.
|
||||
// - claude-code: "sonnet" — Anthropic's CLI accepts the short
|
||||
// name and resolves it via the operator's anthropic-oauth or
|
||||
// ANTHROPIC_API_KEY chain.
|
||||
// - everything else (hermes, langgraph, autogen, codex, openclaw,
|
||||
// external, ""): a fully-qualified
|
||||
// vendor:model slug that the universal MODEL_PROVIDER chain in
|
||||
// molecule-core PR #247 can route via per-vendor required_env.
|
||||
//
|
||||
// SSOT principle (CTO 2026-05-22T03:42Z, feedback_workspace_model_required_no_platform_default_dynamic_credential_intake):
|
||||
// model / provider / provider-credential are REQUIRED user input at
|
||||
// create time. The platform must not provide a default. The runtime
|
||||
// must not fall back. Decision belongs to the user (or to the agent
|
||||
// acting on the user's behalf), never to the platform.
|
||||
//
|
||||
// Callers that previously fell back to DefaultModel must now fail-closed
|
||||
// when model is empty after template-resolution.
|
||||
// The function never returns an empty string; an unknown runtime
|
||||
// gets the universal default rather than failing closed (matches the
|
||||
// pre-refactor behavior — both call sites used the same fallback).
|
||||
func DefaultModel(runtime string) string {
|
||||
if runtime == "claude-code" {
|
||||
return "sonnet"
|
||||
}
|
||||
return "anthropic:claude-opus-4-7"
|
||||
}
|
||||
|
||||
@@ -1,11 +1,59 @@
|
||||
package models
|
||||
|
||||
// runtime_defaults_test.go — previously pinned DefaultModel's contract
|
||||
// (claude-code → "sonnet", everything else → "anthropic:claude-opus-4-7").
|
||||
//
|
||||
// DefaultModel was removed as a soft-fallback bug magnet (CTO 2026-05-22):
|
||||
// model is REQUIRED user input; the platform must not provide a default.
|
||||
// See runtime_defaults.go for the deletion rationale, and the new
|
||||
// fail-closed gate in `handlers.WorkspaceHandler.Create` for the boundary
|
||||
// enforcement. No test stub here — the contract is "this function does
|
||||
// not exist", which the type-checker enforces at compile time.
|
||||
import "testing"
|
||||
|
||||
// TestDefaultModel pins the contract: known runtimes return their
|
||||
// expected default; unknowns and the empty string fall through to the
|
||||
// universal default. Add new runtimes here as `case` entries — pre-fix
|
||||
// adding a runtime required two source edits + an audit; post-SSOT it
|
||||
// requires one entry in DefaultModel + one assertion here.
|
||||
func TestDefaultModel(t *testing.T) {
|
||||
cases := []struct {
|
||||
runtime string
|
||||
want string
|
||||
}{
|
||||
// Known runtimes.
|
||||
{"claude-code", "sonnet"},
|
||||
|
||||
// Universal fallback for everything else. Each runtime is named
|
||||
// explicitly so a future drift (e.g., adding a hermes-specific
|
||||
// branch) shows up as a failure on the runtime that drifted, not
|
||||
// as a generic "unknown" failure.
|
||||
{"hermes", "anthropic:claude-opus-4-7"},
|
||||
{"langgraph", "anthropic:claude-opus-4-7"},
|
||||
{"autogen", "anthropic:claude-opus-4-7"},
|
||||
{"codex", "anthropic:claude-opus-4-7"},
|
||||
{"openclaw", "anthropic:claude-opus-4-7"},
|
||||
{"external", "anthropic:claude-opus-4-7"},
|
||||
|
||||
// Unknown / empty — fall through to universal default rather
|
||||
// than failing closed. Pre-refactor both call sites also fell
|
||||
// through; pinning the existing behavior, not changing it.
|
||||
{"", "anthropic:claude-opus-4-7"},
|
||||
{"some-future-runtime", "anthropic:claude-opus-4-7"},
|
||||
{"CLAUDE-CODE", "anthropic:claude-opus-4-7"}, // case-sensitive — matches prior behavior
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.runtime, func(t *testing.T) {
|
||||
got := DefaultModel(tc.runtime)
|
||||
if got != tc.want {
|
||||
t.Errorf("DefaultModel(%q) = %q, want %q", tc.runtime, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultModel_NeverEmpty — invariant: no input produces an empty
|
||||
// string. The handlers that consume this would write empty into
|
||||
// config.yaml, which the runtime then can't dispatch — pinning the
|
||||
// non-empty contract here protects against a future "return early on
|
||||
// unknown runtime" change that would silently break workspace creation.
|
||||
func TestDefaultModel_NeverEmpty(t *testing.T) {
|
||||
for _, runtime := range []string{
|
||||
"", "claude-code", "hermes", "unknown-runtime",
|
||||
} {
|
||||
if got := DefaultModel(runtime); got == "" {
|
||||
t.Errorf("DefaultModel(%q) returned empty string", runtime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ type Workspace struct {
|
||||
Name string `json:"name" db:"name"`
|
||||
Role sql.NullString `json:"role" db:"role"`
|
||||
Tier int `json:"tier" db:"tier"`
|
||||
AwarenessNamespace sql.NullString `json:"awareness_namespace" db:"awareness_namespace"`
|
||||
Status string `json:"status" db:"status"`
|
||||
SourceBundleID sql.NullString `json:"source_bundle_id" db:"source_bundle_id"`
|
||||
AgentCard json.RawMessage `json:"agent_card" db:"agent_card"`
|
||||
@@ -206,8 +207,7 @@ type CreateWorkspacePayload struct {
|
||||
} `json:"canvas"`
|
||||
// InitialMemories is an optional list of memories to seed into the
|
||||
// workspace immediately after creation. Each entry is inserted into
|
||||
// agent_memories under the workspace's v2 memory namespace
|
||||
// ("workspace:<id>"). Issue #1050.
|
||||
// agent_memories with the workspace's awareness namespace. Issue #1050.
|
||||
InitialMemories []MemorySeed `json:"initial_memories"`
|
||||
}
|
||||
|
||||
|
||||
@@ -241,12 +241,9 @@ 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, readErr := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
if readErr != nil {
|
||||
return "", fmt.Errorf("cp provisioner: read response body: %w", readErr)
|
||||
}
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
var result cpProvisionResponse
|
||||
unmarshalErr := json.Unmarshal(respBody, &result)
|
||||
json.Unmarshal(respBody, &result)
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
// Prefer the structured {"error":"..."} field. Do NOT fall back
|
||||
@@ -260,10 +257,6 @@ 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,
|
||||
@@ -416,11 +409,7 @@ 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, 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)
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return fmt.Errorf("cp provisioner: stop %s: unexpected %d: %s",
|
||||
workspaceID, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
@@ -442,26 +442,6 @@ 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
|
||||
|
||||
@@ -102,6 +102,8 @@ type WorkspaceConfig struct {
|
||||
DiskGB int32 // Optional CP root volume size override in GiB (SaaS only)
|
||||
EnvVars map[string]string // Additional env vars (API keys, etc.)
|
||||
PlatformURL string
|
||||
AwarenessURL string
|
||||
AwarenessNamespace string
|
||||
WorkspaceAccess string // #65: "none" (default), "read_only", or "read_write"
|
||||
ResetClaudeSession bool // #12: if true, discard the claude-sessions volume before start (fresh session dir)
|
||||
|
||||
@@ -704,19 +706,11 @@ func buildContainerEnv(cfg WorkspaceConfig) []string {
|
||||
// still override (Dockerfile ENV is overridden by docker -e at runtime).
|
||||
"PYTHONPATH=/app",
|
||||
}
|
||||
// #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
|
||||
if cfg.AwarenessNamespace != "" && cfg.AwarenessURL != "" {
|
||||
env = append(env, fmt.Sprintf("AWARENESS_NAMESPACE=%s", cfg.AwarenessNamespace))
|
||||
env = append(env, fmt.Sprintf("AWARENESS_URL=%s", cfg.AwarenessURL))
|
||||
}
|
||||
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
|
||||
@@ -734,19 +728,6 @@ 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.
|
||||
|
||||
@@ -692,6 +692,39 @@ func TestBuildContainerEnv_MoleculeAIURLAlwaysMatchesPlatformURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildContainerEnv_AwarenessOnlyWhenBothSet(t *testing.T) {
|
||||
// Both set → both injected.
|
||||
cfg := WorkspaceConfig{
|
||||
WorkspaceID: "ws-x",
|
||||
PlatformURL: "http://localhost:8080",
|
||||
AwarenessURL: "http://awareness:9000",
|
||||
AwarenessNamespace: "ns-1",
|
||||
}
|
||||
env := buildContainerEnv(cfg)
|
||||
hasNS := false
|
||||
hasURL := false
|
||||
for _, e := range env {
|
||||
if e == "AWARENESS_NAMESPACE=ns-1" {
|
||||
hasNS = true
|
||||
}
|
||||
if e == "AWARENESS_URL=http://awareness:9000" {
|
||||
hasURL = true
|
||||
}
|
||||
}
|
||||
if !hasNS || !hasURL {
|
||||
t.Errorf("both awareness vars must be present: env=%v", env)
|
||||
}
|
||||
|
||||
// Only namespace set → neither injected (must be both-or-nothing).
|
||||
cfg.AwarenessURL = ""
|
||||
env2 := buildContainerEnv(cfg)
|
||||
for _, e := range env2 {
|
||||
if strings.HasPrefix(e, "AWARENESS_") {
|
||||
t.Errorf("awareness vars must NOT be injected when URL is missing: got %q", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildContainerEnv_CustomEnvVarsAppended(t *testing.T) {
|
||||
// NOTE: this test previously asserted GITHUB_TOKEN passed through
|
||||
// verbatim. That assertion encoded the forensic #145 latent leak as
|
||||
@@ -737,12 +770,9 @@ 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", "GITLAB_TOKEN", "GL_TOKEN", "BITBUCKET_TOKEN",
|
||||
"GITEA_TOKEN", "GITHUB_TOKEN", "GH_TOKEN",
|
||||
"GITLAB_TOKEN", "GL_TOKEN", "BITBUCKET_TOKEN",
|
||||
}
|
||||
|
||||
t.Run("normal path — SCM tokens explicitly set in EnvVars", func(t *testing.T) {
|
||||
@@ -750,9 +780,6 @@ 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",
|
||||
@@ -768,13 +795,6 @@ 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) {
|
||||
@@ -835,106 +855,6 @@ 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 {
|
||||
|
||||
@@ -93,9 +93,6 @@ func sweepOnlineWorkspaces(ctx context.Context, checker ContainerChecker, onOffl
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("Health sweep: rows error: %v", err)
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
running, err := checker.IsRunning(ctx, id)
|
||||
@@ -162,9 +159,6 @@ func sweepStaleRemoteWorkspaces(ctx context.Context, onOffline OfflineHandler) {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("Health sweep: rows error: %v", err)
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
// External workspaces flip to 'awaiting_agent' (re-registrable
|
||||
|
||||
@@ -166,9 +166,6 @@ func sweepStuckProvisioning(ctx context.Context, emitter ProvisionTimeoutEmitter
|
||||
ids = append(ids, c)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("Provision-timeout sweep: rows error: %v", err)
|
||||
}
|
||||
|
||||
for _, c := range ids {
|
||||
timeout := provisioningTimeoutFor(c.runtime, lookup)
|
||||
|
||||
@@ -81,7 +81,6 @@ 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.
|
||||
|
||||
@@ -182,9 +182,6 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
|
||||
// URLs, so keep the endpoint admin-gated from the first unavailable
|
||||
// state rather than widening it later.
|
||||
wsAdmin.GET("/workspaces/:id/display", wh.Display)
|
||||
wsAdmin.GET("/workspaces/:id/display/control", wh.DisplayControl)
|
||||
wsAdmin.POST("/workspaces/:id/display/control/acquire", wh.AcquireDisplayControl)
|
||||
wsAdmin.POST("/workspaces/:id/display/control/release", wh.ReleaseDisplayControl)
|
||||
|
||||
// Admin memory backup/restore (#1051) — bulk export/import of agent
|
||||
// memories for safe Docker rebuilds. Matches workspaces by name on import.
|
||||
|
||||
@@ -18,7 +18,6 @@ func buildWorkspaceDisplayEngine(t *testing.T) *gin.Engine {
|
||||
r := gin.New()
|
||||
wh := handlers.NewWorkspaceHandler(nil, nil, "http://localhost:8080", t.TempDir())
|
||||
r.GET("/workspaces/:id/display", middleware.AdminAuth(db.DB), wh.Display)
|
||||
r.POST("/workspaces/:id/display/control/acquire", middleware.AdminAuth(db.DB), wh.AcquireDisplayControl)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -40,22 +39,3 @@ func TestWorkspaceDisplayRoute_RequiresAdminAuth(t *testing.T) {
|
||||
t.Errorf("sqlmock unmet: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplayControlRoute_RequiresAdminAuth(t *testing.T) {
|
||||
t.Setenv("ADMIN_TOKEN", "test-admin-secret-not-presented-by-caller")
|
||||
mock := setupRouterTestDB(t)
|
||||
mock.ExpectQuery("SELECT COUNT.*FROM workspace_auth_tokens").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
|
||||
r := buildWorkspaceDisplayEngine(t)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/workspaces/ws-display/display/control/acquire", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401 for unauthenticated request, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock unmet: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -832,18 +832,9 @@ func (s *Scheduler) sweepPhantomBusy(ctx context.Context) {
|
||||
// exhaustion, SDK internal errors) — the error surfaces only in the response
|
||||
// body under result.kind or result.result_kind.
|
||||
//
|
||||
// Returns an empty string when the response is clean (result_kind is "ok",
|
||||
// "message" — the A2A-SDK canonical successful Message envelope — or absent).
|
||||
// Returns the result_kind value when it is a non-ok signal, so callers can
|
||||
// propagate it as the schedule's last_status.
|
||||
//
|
||||
// Known successful (= treat-as-ok) kinds (resultOKKinds):
|
||||
// - "ok" — explicit success signal
|
||||
// - "message" — A2A-SDK Message envelope (`{"result":{"kind":"message","parts":[...]}}`),
|
||||
// emitted by every successful agent reply. Fix: #1696 originally allow-listed only
|
||||
// "ok" / empty, which mis-flagged every successful agent response as an SDK error
|
||||
// (PM scheduler observed 21 consecutive false-failure ticks before auto-disable;
|
||||
// screenshot 2026-05-23). See [#1696 follow-up].
|
||||
// Returns an empty string when the response is clean (result_kind is "ok" or
|
||||
// absent). Returns the result_kind value when it is a non-ok signal, so callers
|
||||
// can propagate it as the schedule's last_status.
|
||||
//
|
||||
// Known non-ok kinds:
|
||||
// - "rate_limited" — LLM API rate-limit hit (Max-plan, etc.)
|
||||
@@ -851,59 +842,6 @@ func (s *Scheduler) sweepPhantomBusy(ctx context.Context) {
|
||||
// - "sdk_error" — SDK threw an internal error
|
||||
//
|
||||
// See #1696.
|
||||
//
|
||||
// resultOKKinds is the allowlist of `result.kind` values that are
|
||||
// UNCONDITIONALLY successful (no further parsing needed). Anything
|
||||
// outside this set is treated as a non-ok SDK signal, EXCEPT `task`
|
||||
// which is gated separately on `result.status.state` (see
|
||||
// classifyTaskState — A2A Task can be either in-progress or terminally
|
||||
// failed, depending on its status).
|
||||
//
|
||||
// Add to this list when new always-success envelope kinds are introduced
|
||||
// upstream. NEVER add an envelope that can carry a failure sub-state.
|
||||
var resultOKKinds = map[string]struct{}{
|
||||
"": {}, // absent / empty → treat as ok (no signal)
|
||||
"ok": {}, // explicit success
|
||||
"message": {}, // A2A-SDK Message envelope (always a successful agent reply)
|
||||
}
|
||||
|
||||
// taskOKStates is the A2A Task `status.state` allowlist for results that
|
||||
// have `kind: "task"`. Tasks can be in-progress (submitted/working) or
|
||||
// terminally successful (completed) — those are clean signals to the
|
||||
// scheduler. Terminal failure states (failed/canceled/rejected) are
|
||||
// surfaced as the scheduler's last_status so operators can see the real
|
||||
// state. Cf. CR2 review feedback on #1716.
|
||||
var taskOKStates = map[string]struct{}{
|
||||
"": {}, // status.state absent → conservative: don't fire false-failure
|
||||
"submitted": {}, // task accepted, not yet running
|
||||
"working": {}, // task in progress
|
||||
"completed": {}, // task finished successfully
|
||||
}
|
||||
|
||||
// classifyTaskState inspects `result.status.state` (or `result.status_state`
|
||||
// legacy variant) and returns "" when the state is in taskOKStates (success
|
||||
// or in-progress) or the state string when it is a terminal failure that
|
||||
// should propagate as last_status.
|
||||
func classifyTaskState(result map[string]json.RawMessage) string {
|
||||
rawStatus, ok := result["status"]
|
||||
if !ok {
|
||||
return "" // no status block → no signal, leave clean
|
||||
}
|
||||
var status map[string]json.RawMessage
|
||||
if err := json.Unmarshal(rawStatus, &status); err != nil {
|
||||
return ""
|
||||
}
|
||||
if rawState, ok := status["state"]; ok {
|
||||
var s string
|
||||
if json.Unmarshal(rawState, &s) == nil {
|
||||
if _, isOK := taskOKStates[s]; !isOK {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func detectResultKind(body []byte) string {
|
||||
if len(body) == 0 {
|
||||
return ""
|
||||
@@ -916,32 +854,18 @@ func detectResultKind(body []byte) string {
|
||||
if rawResult, ok := top["result"]; ok {
|
||||
var result map[string]json.RawMessage
|
||||
if err := json.Unmarshal(rawResult, &result); err == nil {
|
||||
// result.kind (canonical JSON-RPC envelope field).
|
||||
// result.kind (canonical JSON-RPC error envelope field).
|
||||
if rawKind, ok := result["kind"]; ok {
|
||||
var k string
|
||||
if json.Unmarshal(rawKind, &k) == nil {
|
||||
// Special-case task: success or failure depends on status.state.
|
||||
if k == "task" {
|
||||
if bad := classifyTaskState(result); bad != "" {
|
||||
return bad
|
||||
}
|
||||
// task with ok / in-progress state → clean
|
||||
} else if _, isOK := resultOKKinds[k]; !isOK {
|
||||
return k
|
||||
}
|
||||
if json.Unmarshal(rawKind, &k) == nil && k != "" && k != "ok" {
|
||||
return k
|
||||
}
|
||||
}
|
||||
// result.result_kind (legacy / alternative field name).
|
||||
if rawKind, ok := result["result_kind"]; ok {
|
||||
var k string
|
||||
if json.Unmarshal(rawKind, &k) == nil {
|
||||
if k == "task" {
|
||||
if bad := classifyTaskState(result); bad != "" {
|
||||
return bad
|
||||
}
|
||||
} else if _, isOK := resultOKKinds[k]; !isOK {
|
||||
return k
|
||||
}
|
||||
if json.Unmarshal(rawKind, &k) == nil && k != "" && k != "ok" {
|
||||
return k
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -702,61 +702,6 @@ func TestDetectResultKind(t *testing.T) {
|
||||
body: `{"result":{"result_kind":"ok","parts":[{"text":"hello"}]}}`,
|
||||
wantKind: "",
|
||||
},
|
||||
{
|
||||
// REGRESSION GUARD: A2A-SDK canonical Message envelope.
|
||||
// Pre-fix, every successful agent reply was mis-flagged as an SDK
|
||||
// error (PM scheduler hit 21 consecutive false-failure ticks before
|
||||
// auto-disable; canvas screenshot 2026-05-23).
|
||||
name: "clean ok response — result.kind=message (A2A Message envelope)",
|
||||
body: `{"jsonrpc":"2.0","result":{"kind":"message","parts":[{"kind":"text","text":"hello"}]},"id":"1"}`,
|
||||
wantKind: "",
|
||||
},
|
||||
{
|
||||
name: "clean ok response — result.result_kind=message",
|
||||
body: `{"result":{"result_kind":"message","parts":[{"text":"hello"}]}}`,
|
||||
wantKind: "",
|
||||
},
|
||||
{
|
||||
// A2A Task envelope, in-progress — `status.state` discriminator is
|
||||
// `submitted` or `working` → treat as clean (not an SDK error).
|
||||
name: "clean ok — task envelope state=working",
|
||||
body: `{"result":{"kind":"task","task_id":"abc","status":{"state":"working"}}}`,
|
||||
wantKind: "",
|
||||
},
|
||||
{
|
||||
name: "clean ok — task envelope state=submitted",
|
||||
body: `{"result":{"kind":"task","status":{"state":"submitted"}}}`,
|
||||
wantKind: "",
|
||||
},
|
||||
{
|
||||
name: "clean ok — task envelope state=completed",
|
||||
body: `{"result":{"kind":"task","status":{"state":"completed"}}}`,
|
||||
wantKind: "",
|
||||
},
|
||||
{
|
||||
// Conservative: missing status.state → don't fire false-failure.
|
||||
name: "clean ok — task envelope no status block",
|
||||
body: `{"result":{"kind":"task","task_id":"abc"}}`,
|
||||
wantKind: "",
|
||||
},
|
||||
{
|
||||
// REGRESSION GUARD: terminal failure states MUST propagate as last_status.
|
||||
// Without taskOKStates gating, the blanket "task" allowlist would have
|
||||
// masked these — CR2 review feedback on #1716.
|
||||
name: "SDK error — task envelope state=failed",
|
||||
body: `{"result":{"kind":"task","status":{"state":"failed"}}}`,
|
||||
wantKind: "failed",
|
||||
},
|
||||
{
|
||||
name: "SDK error — task envelope state=canceled",
|
||||
body: `{"result":{"kind":"task","status":{"state":"canceled"}}}`,
|
||||
wantKind: "canceled",
|
||||
},
|
||||
{
|
||||
name: "SDK error — task envelope state=rejected",
|
||||
body: `{"result":{"kind":"task","status":{"state":"rejected"}}}`,
|
||||
wantKind: "rejected",
|
||||
},
|
||||
{
|
||||
name: "SDK error — result.kind=rate_limited",
|
||||
body: `{"result":{"kind":"rate_limited","parts":[{"text":"error"}]}}`,
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
DROP INDEX IF EXISTS idx_workspace_display_control_locks_expires;
|
||||
DROP TABLE IF EXISTS workspace_display_control_locks;
|
||||
@@ -1,11 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS workspace_display_control_locks (
|
||||
workspace_id uuid PRIMARY KEY REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
controller text NOT NULL CHECK (controller IN ('user', 'agent')),
|
||||
controlled_by text NOT NULL CHECK (length(controlled_by) > 0 AND length(controlled_by) <= 200),
|
||||
expires_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_display_control_locks_expires
|
||||
ON workspace_display_control_locks (expires_at);
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
-- Reverse of 20260523130000_drop_workspaces_awareness_namespace.up.sql.
|
||||
--
|
||||
-- Restores the workspaces.awareness_namespace column verbatim from
|
||||
-- migration 010_workspace_awareness.sql so a down-cycle leaves the
|
||||
-- schema bit-identical to the pre-drop state. The column will be
|
||||
-- NULL on all rows after re-add — handlers no longer write to it and
|
||||
-- callers no longer read it, so this is functionally inert without
|
||||
-- a paired code revert.
|
||||
|
||||
ALTER TABLE workspaces
|
||||
ADD COLUMN IF NOT EXISTS awareness_namespace TEXT;
|
||||
@@ -1,19 +0,0 @@
|
||||
-- Issue #1735 — drop the workspaces.awareness_namespace column.
|
||||
--
|
||||
-- "Awareness namespaces" were a memory-routing surface (env vars
|
||||
-- AWARENESS_URL / AWARENESS_NAMESPACE) that was plumbed across the
|
||||
-- platform but never wired in any production or staging environment
|
||||
-- (verified 2026-05-23 via Railway GraphQL on the controlplane service:
|
||||
-- AWARENESS_* unset in both env IDs 59227671-… and 639539ec-…).
|
||||
--
|
||||
-- The column added by migration 010_workspace_awareness.sql was only
|
||||
-- ever populated with the canonical "workspace:<id>" string, which is
|
||||
-- also the v2 memory namespace string (see internal/memory/namespace/
|
||||
-- resolver.go:186). Removing the column does not change any agent-
|
||||
-- visible memory namespace — handlers now compute the same
|
||||
-- "workspace:<id>" string inline when inserting into agent_memories.
|
||||
--
|
||||
-- Related: #1733 (memory SSOT consolidation), #1734 (Memory tab bug).
|
||||
|
||||
ALTER TABLE workspaces
|
||||
DROP COLUMN IF EXISTS awareness_namespace;
|
||||
Reference in New Issue
Block a user