Compare commits

..

3 Commits

Author SHA1 Message Date
fullstack-engineer 8a981a472a test(handlers): add coverage for plugins_listing.go
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 11s
CI / Detect changes (pull_request) Successful in 10s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 16s
E2E API Smoke Test / detect-changes (pull_request) Successful in 16s
E2E Chat / detect-changes (pull_request) Successful in 11s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 7s
Harness Replays / detect-changes (pull_request) Successful in 6s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 18s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
gate-check-v3 / gate-check (pull_request) Successful in 5s
qa-review / approved (pull_request) Successful in 6s
security-review / approved (pull_request) Successful in 7s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 7s
sop-tier-check / tier-check (pull_request) Successful in 6s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m19s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 30s
Harness Replays / Harness Replays (pull_request) Successful in 3s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 2s
E2E Chat / E2E Chat (pull_request) Failing after 1m2s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1m47s
CI / Platform (Go) (pull_request) Successful in 5m15s
CI / Canvas (Next.js) (pull_request) Successful in 6m27s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Python Lint & Test (pull_request) Successful in 6m57s
CI / all-required (pull_request) Successful in 7m2s
audit-force-merge / audit (pull_request) Has been skipped
Add 21 tests for the plugins registry listing surface:
- parseManifestYAML: full YAML, minimal fields, missing yaml,
  bad YAML, partial fields (wrong types for arrays)
- listRegistryFiltered: empty dir, nonexistent dir, no yaml,
  valid yaml, files-ignored, runtime filter matches/excludes,
  unspecified-runtime always included, multiple matching
- ListRegistry (GET /plugins): no filter, with runtime filter,
  empty on no matches
- ListAvailableForWorkspace (GET /workspaces/:id/plugins/available):
  runtimeLookup returns runtime, errors, nil lookup, unspecified
  runtime always included

No handler logic changed; all tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 07:21:27 +00:00
fullstack-engineer 3d0d9b1818 test(handlers): add Uninstall 503 coverage for plugins_install.go (closes #1377) (#1378)
Block internal-flavored paths / Block forbidden paths (push) Successful in 5s
CI / Detect changes (push) Successful in 12s
CI / Shellcheck (E2E scripts) (push) Successful in 15s
E2E API Smoke Test / detect-changes (push) Successful in 11s
Harness Replays / detect-changes (push) Successful in 7s
E2E Chat / detect-changes (push) Successful in 10s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 7s
Handlers Postgres Integration / detect-changes (push) Successful in 14s
Harness Replays / Harness Replays (push) Successful in 2s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Failing after 1m22s
E2E API Smoke Test / E2E API Smoke Test (push) Failing after 2m9s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m40s
CI / Platform (Go) (push) Successful in 3m45s
CI / Canvas (Next.js) (push) Successful in 5m23s
CI / Canvas Deploy Reminder (push) Successful in 2s
E2E Chat / E2E Chat (push) Failing after 6m14s
CI / Python Lint & Test (push) Successful in 7m7s
CI / all-required (push) Successful in 7m11s
Co-authored-by: Molecule AI Fullstack Engineer <fullstack-engineer@agents.moleculesai.app>
Co-committed-by: Molecule AI Fullstack Engineer <fullstack-engineer@agents.moleculesai.app>
2026-05-18 06:51:21 +00:00
fullstack-engineer 1c61db9042 test: PatchAbilities handler + resolveWorkspaceName coverage (#1481)
CI / Shellcheck (E2E scripts) (push) Waiting to run
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
CI / all-required (push) Waiting to run
E2E API Smoke Test / detect-changes (push) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
CI / Platform (Go) (push) Has been cancelled
Block internal-flavored paths / Block forbidden paths (push) Has been cancelled
CI / Canvas (Next.js) (push) Has been cancelled
Handlers Postgres Integration / detect-changes (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
Harness Replays / detect-changes (push) Successful in 7s
E2E Chat / detect-changes (push) Successful in 11s
Harness Replays / Harness Replays (push) Successful in 2s
E2E Chat / E2E Chat (push) Failing after 6m10s
Co-authored-by: Molecule AI Fullstack Engineer <fullstack-engineer@agents.moleculesai.app>
Co-committed-by: Molecule AI Fullstack Engineer <fullstack-engineer@agents.moleculesai.app>
2026-05-18 06:51:20 +00:00
14 changed files with 836 additions and 258 deletions
-23
View File
@@ -206,29 +206,6 @@ CANDIDATES=$(jq -r --arg author "$PR_AUTHOR" --arg head "$PR_HEAD_SHA" "$JQ_FILT
debug "candidate non-author approvers: $(echo "$CANDIDATES" | tr '\n' ' ')"
if [ -z "$CANDIDATES" ]; then
# --- Guardrail (internal#503): explain the most common false
# "no candidates" red. Gitea's review event enum is EXACTLY
# APPROVED/REQUEST_CHANGES/COMMENT/PENDING. A wrong value ("APPROVE",
# lowercase, ...) is silently accepted (HTTP 200) and stored as
# state=PENDING. A correctly-started draft review has an EMPTY body;
# a NON-empty body + state==PENDING by a non-author == an intended
# verdict mis-filed by a wrong event string. Surface it actionably.
# This does NOT change the gate result (still fail-closed below) — it
# only converts a mystery red into a named, self-fixing error.
MISFILED_FILTER='.[]
| select(.state == "PENDING")
| select(.dismissed != true)
| select(.user.login != $author)
| select(((.body // "") | gsub("^\\s+|\\s+$";"") | length) > 0)
| "\(.id)\t\(.user.login)"'
MISFILED=$(jq -r --arg author "$PR_AUTHOR" "$MISFILED_FILTER" "$REVIEWS_JSON" 2>/dev/null || true)
if [ -n "$MISFILED" ]; then
echo "::error::${TEAM}-review: non-author review(s) were SUBMITTED but stored as PENDING — almost certainly the wrong Gitea review event string (internal#503)."
echo "::error::Gitea accepts ONLY the exact enum APPROVED / REQUEST_CHANGES / COMMENT. 'APPROVE' or lowercase is silently (HTTP 200) filed as PENDING and is invisible to this gate."
printf '%s\n' "$MISFILED" | while IFS="$(printf '\t')" read -r _rid _rl; do
[ -n "${_rid:-}" ] && echo "::error:: review id=${_rid} by '${_rl}': RE-SUBMIT via POST ${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/reviews with {\"event\":\"APPROVED\"} (correct enum) — do NOT edit the DB."
done
fi
echo "::error::${TEAM}-review awaiting non-author APPROVE from ${TEAM} team (no candidates yet)"
exit 1
fi
+5 -7
View File
@@ -538,13 +538,11 @@ jobs:
all-required:
# Aggregator sentinel — RFC internal#219 §2 (Phase 4 — closes internal#286).
#
# Emits `CI / all-required (<event>)` where <event> is the workflow trigger
# (e.g. `CI / all-required (pull_request)`, `CI / all-required (push)`).
# Branch protection MUST be updated to require the event-suffixed name —
# requiring `CI / all-required` (bare, no suffix) silently blocks all merges
# because Gitea treats absent status contexts as pending (not skipped), and
# no workflow emits the bare name. Fixed: BP now requires
# `CI / all-required (pull_request)` per issue #1473.
# Single stable required-status name that branch protection points at;
# CI churns underneath in `needs:` without any protection edits. Mirrors
# the molecule-controlplane Phase 2a impl shipped in CP PR#112 and
# referenced by `internal#286` ("Phase 4 is a single small PR... mirrors
# CP's existing one").
#
# Closes the failure mode where status_check_contexts on molecule-core/main
# only listed `Secret scan` + `sop-tier-check` (the 2 meta-gates), so real
-4
View File
@@ -52,9 +52,5 @@ jobs:
# explicitly instead of the combined state avoids false-pause when
# non-blocking jobs (continue-on-error: true) have failed — those
# failures pollute combined state but do not gate merges.
# NOTE: the event-suffixed context name is intentional — branch protection
# MUST require `CI / all-required (pull_request)` (with suffix), NOT the
# bare `CI / all-required`. Gitea treats absent contexts as pending, not
# skipped; requiring the bare name silently blocks all merges (issue #1473).
PUSH_REQUIRED_CONTEXTS: CI / all-required (push)
run: python3 .gitea/scripts/gitea-merge-queue.py
+4 -19
View File
@@ -104,7 +104,7 @@ jobs:
with:
python-version: "3.11"
- name: Compute next version from PyPI latest and existing tags
- name: Compute next version from PyPI latest
id: bump
run: |
set -eu
@@ -112,24 +112,9 @@ jobs:
| python -c "import sys,json; print(json.load(sys.stdin)['info']['version'])")
MAJOR=$(echo "$LATEST" | cut -d. -f1)
MINOR=$(echo "$LATEST" | cut -d. -f2)
TAG_LATEST=$(git tag --list "runtime-v${MAJOR}.${MINOR}.*" \
| sed -E 's/^runtime-v//' \
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' \
| sort -V \
| tail -1 || true)
VERSION=$(PYPI_LATEST="$LATEST" TAG_LATEST="$TAG_LATEST" python - <<'PY'
import os
def parse(v):
return tuple(int(part) for part in v.split("."))
pypi = os.environ["PYPI_LATEST"]
tag = os.environ.get("TAG_LATEST") or pypi
base = max(parse(pypi), parse(tag))
print(f"{base[0]}.{base[1]}.{base[2] + 1}")
PY
)
echo "PyPI latest=$LATEST, latest runtime tag=${TAG_LATEST:-none} -> next=$VERSION"
PATCH=$(echo "$LATEST" | cut -d. -f3)
VERSION="${MAJOR}.${MINOR}.$((PATCH+1))"
echo "PyPI latest=$LATEST -> next=$VERSION"
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::computed version $VERSION does not match PEP 440 X.Y.Z"
exit 1
-13
View File
@@ -30,11 +30,6 @@ jobs:
scan:
name: Scan diff for credential-shaped strings
runs-on: ubuntu-latest
# Hard CI gate — must complete or the PR is unmergable. 10-minute ceiling
# is generous for a diff-scan against a single SHA. If this times out, the
# runner is frozen and holding a slot — the step timeout triggers clean
# failure, releasing the runner for the next job.
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -138,14 +133,6 @@ jobs:
[ -z "$f" ] && continue
[ "$f" = "$SELF_GITHUB" ] && continue
[ "$f" = "$SELF_GITEA" ] && continue
# Test-fixture exclude (internal#425): the secrets-detector's OWN
# unit-test corpus deliberately embeds credential-SHAPED example
# strings to exercise the detector. Verified 2026-05-18 synthetic
# (fabricated ghp_* fixtures, not real). Without this the scanner
# self-trips on its own fixtures and fail-closes every deploy.
# Same rationale as the SELF_* excludes above; gate NOT weakened
# (all other paths still fully scanned).
[ "$f" = "workspace-server/internal/secrets/patterns_test.go" ] && continue
if [ -n "$DIFF_RANGE" ]; then
ADDED=$(git diff --no-color --unified=0 "$BASE" "$HEAD" -- "$f" 2>/dev/null | grep -E '^\+[^+]' || true)
else
@@ -0,0 +1,102 @@
import { describe, it, expect, beforeEach } from "vitest";
import { useCanvasStore } from "@/store/canvas";
import { resolveWorkspaceName } from "../hooks/resolveWorkspaceName";
beforeEach(() => {
// Reset store to a clean slate between tests so node lookup is deterministic.
useCanvasStore.setState({ nodes: [] });
});
describe("resolveWorkspaceName", () => {
it("returns the workspace name when a node with that ID exists", () => {
useCanvasStore.setState({
nodes: [
{
id: "ws-alpha-001",
type: "workspace",
data: { name: "Alpha Agent" },
position: { x: 0, y: 0 },
},
],
});
expect(resolveWorkspaceName("ws-alpha-001")).toBe("Alpha Agent");
});
it("falls back to the first 8 chars of the ID when no matching node exists", () => {
expect(resolveWorkspaceName("ws-zzz-not-found")).toBe("ws-zzz-n");
});
it("falls back to the first 8 chars when the node exists but has no name", () => {
useCanvasStore.setState({
nodes: [
{
id: "ws-no-name",
type: "workspace",
// data.name is deliberately absent
data: {},
position: { x: 0, y: 0 },
},
],
});
expect(resolveWorkspaceName("ws-no-name")).toBe("ws-no-na");
});
it("returns the first 8 chars for a very short ID", () => {
expect(resolveWorkspaceName("ab")).toBe("ab");
});
it("returns the first 8 chars when the ID is exactly 8 characters", () => {
// slice(0,8) of an 8-char string is the full string
const id = "12345678";
expect(resolveWorkspaceName(id)).toBe(id);
});
it("picks the right node when multiple workspaces share a prefix", () => {
useCanvasStore.setState({
nodes: [
{
id: "00000000-0000-0000-0000-000000000001",
type: "workspace",
data: { name: "Backend Agent" },
position: { x: 0, y: 0 },
},
{
id: "00000000-0000-0000-0000-000000000002",
type: "workspace",
data: { name: "Frontend Agent" },
position: { x: 100, y: 0 },
},
],
});
expect(resolveWorkspaceName("00000000-0000-0000-0000-000000000002")).toBe(
"Frontend Agent"
);
expect(resolveWorkspaceName("00000000-0000-0000-0000-000000000001")).toBe(
"Backend Agent"
);
});
it("does not mutate store state between calls", () => {
useCanvasStore.setState({
nodes: [
{
id: "stable-id",
type: "workspace",
data: { name: "Stable Workspace" },
position: { x: 0, y: 0 },
},
],
});
resolveWorkspaceName("stable-id");
resolveWorkspaceName("unknown-id");
// Store nodes must be unchanged — resolveWorkspaceName is read-only.
const nodes = useCanvasStore.getState().nodes;
expect(nodes).toHaveLength(1);
expect((nodes[0] as { id: string }).id).toBe("stable-id");
});
});
-1
View File
@@ -58,7 +58,6 @@ TOP_LEVEL_MODULES = {
"a2a_response",
"a2a_tools",
"a2a_tools_delegation",
"a2a_tools_identity",
"a2a_tools_inbox",
"a2a_tools_memory",
"a2a_tools_messaging",
@@ -0,0 +1,53 @@
package handlers
// plugins_install_test.go — additional coverage for plugins_install.go.
//
// Gaps filled vs. existing test files:
// - plugins_install_external_test.go: Install + Uninstall 422 (external runtime) ✓ covered
// - plugins_test.go: Install 400 (missing source, invalid body, etc.) ✓ covered
// Uninstall 400 (invalid plugin name, empty name) ✓ covered
// Download auth gate ✓ covered
// - org_import_helpers_test.go: countWorkspaces, envRequirementKey, sanitizeEnvMembers,
// flattenAndSortRequirements, collectOrgEnv ✓ covered
//
// New test added here:
// - Uninstall 503: container not running, no SaaS dispatch.
//
// NOTE: validateWorkspaceID is not called inside the Install/Uninstall handlers.
// UUID validation is the responsibility of the WorkspaceAuth middleware, so no
// 400 test is needed here for UUID format.
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// TestPluginUninstall_ContainerNotRunning_Returns503 exercises the 503 path
// where neither a local Docker container nor a SaaS instance-id dispatch
// resolves. The handler must return "workspace container not running" — NOT a
// generic 500 or a misleading 422 (external-runtime) message.
func TestPluginUninstall_ContainerNotRunning_Returns503(t *testing.T) {
// No docker client + no instance-id lookup → falls through to 503.
h := NewPluginsHandler(t.TempDir(), nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"},
{Key: "name", Value: "some-plugin"},
}
c.Request = httptest.NewRequest("DELETE",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/plugins/some-plugin", nil)
h.Uninstall(c)
require.Equal(t, http.StatusServiceUnavailable, w.Code)
var body map[string]string
json.Unmarshal(w.Body.Bytes(), &body)
require.Equal(t, "workspace container not running", body["error"])
}
@@ -0,0 +1,472 @@
package handlers
// Unit tests for plugins_listing.go:
// - parseManifestYAML: full YAML, missing fields, empty YAML
// - listRegistryFiltered: empty/missing dir, no yaml, valid yaml, runtime filter
// - ListRegistry (GET /plugins): no filter, with runtime filter
// - ListAvailableForWorkspace (GET /workspaces/:id/plugins/available): runtimeLookup stub
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
)
// -------- parseManifestYAML --------
func TestParseManifestYAML_FullPlugin(t *testing.T) {
data := []byte(`
name: molecule-audit
version: 1.2.3
description: Security audit plugin for Claude Code
author: Molecule AI
tags:
- security
- audit
skills:
- security-scan
- compliance-check
runtimes:
- claude_code
- hermes
`)
info := parseManifestYAML("fallback-name", data)
if info.Name != "fallback-name" {
t.Errorf("Name = %q; want fallback-name", info.Name)
}
if info.Version != "1.2.3" {
t.Errorf("Version = %q; want 1.2.3", info.Version)
}
if info.Description != "Security audit plugin for Claude Code" {
t.Errorf("Description = %q; want full description", info.Description)
}
if info.Author != "Molecule AI" {
t.Errorf("Author = %q; want Molecule AI", info.Author)
}
if len(info.Tags) != 2 || info.Tags[0] != "security" || info.Tags[1] != "audit" {
t.Errorf("Tags = %v; want [security audit]", info.Tags)
}
if len(info.Skills) != 2 || info.Skills[0] != "security-scan" || info.Skills[1] != "compliance-check" {
t.Errorf("Skills = %v; want [security-scan compliance-check]", info.Skills)
}
if len(info.Runtimes) != 2 || info.Runtimes[0] != "claude_code" || info.Runtimes[1] != "hermes" {
t.Errorf("Runtimes = %v; want [claude_code hermes]", info.Runtimes)
}
}
func TestParseManifestYAML_MinimalFields(t *testing.T) {
// Only name field; all others should be zero-value.
data := []byte(`name: minimal-plugin`)
info := parseManifestYAML("fallback", data)
if info.Name != "fallback" {
t.Errorf("Name = %q; want fallback", info.Name)
}
if info.Version != "" {
t.Errorf("Version = %q; want empty", info.Version)
}
if info.Description != "" {
t.Errorf("Description = %q; want empty", info.Description)
}
if len(info.Tags) != 0 {
t.Errorf("Tags = %v; want []", info.Tags)
}
if len(info.Skills) != 0 {
t.Errorf("Skills = %v; want []", info.Skills)
}
if len(info.Runtimes) != 0 {
t.Errorf("Runtimes = %v; want []", info.Runtimes)
}
}
func TestParseManifestYAML_MissingPluginYAML(t *testing.T) {
// No plugin.yaml present → returns fallback name only.
info := parseManifestYAML("no-file", nil)
if info.Name != "no-file" {
t.Errorf("Name = %q; want no-file", info.Name)
}
}
func TestParseManifestYAML_BadYAML(t *testing.T) {
// Malformed YAML → returns fallback name only (no panic).
info := parseManifestYAML("bad-yaml", []byte("not: [yaml: at all"))
if info.Name != "bad-yaml" {
t.Errorf("Name = %q; want bad-yaml", info.Name)
}
if info.Version != "" {
t.Errorf("Version = %q; want empty after bad YAML", info.Version)
}
}
func TestParseManifestYAML_PartialFields(t *testing.T) {
// Present tags/skills/runtimes that are not []interface{} (e.g. wrong type)
// should not panic and should leave the field empty.
data := []byte(`
name: partial
tags: "not-an-array"
skills: 123
runtimes: true
`)
info := parseManifestYAML("partial", data)
if info.Name != "partial" {
t.Errorf("Name = %q; want partial", info.Name)
}
if len(info.Tags) != 0 {
t.Errorf("Tags = %v; want [] (wrong type)", info.Tags)
}
if len(info.Skills) != 0 {
t.Errorf("Skills = %v; want [] (wrong type)", info.Skills)
}
if len(info.Runtimes) != 0 {
t.Errorf("Runtimes = %v; want [] (wrong type)", info.Runtimes)
}
}
// -------- listRegistryFiltered --------
func makeTestHandler(t *testing.T, pluginsDir string) *PluginsHandler {
// Construct a minimal PluginsHandler with a nil docker client
// (filesystem paths are tested directly; container-dependent paths are
// tested separately or skipped in this file).
h := &PluginsHandler{pluginsDir: pluginsDir}
return h
}
func writePluginYAML(t *testing.T, dir, name, content string) {
path := filepath.Join(dir, name, "plugin.yaml")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("writeFile: %v", err)
}
}
func TestListRegistryFiltered_EmptyDir(t *testing.T) {
dir := t.TempDir()
h := makeTestHandler(t, dir)
got := h.listRegistryFiltered("")
if len(got) != 0 {
t.Errorf("expected empty list for empty dir; got %d plugins", len(got))
}
}
func TestListRegistryFiltered_NonExistentDir(t *testing.T) {
h := makeTestHandler(t, "/does/not/exist")
got := h.listRegistryFiltered("")
if len(got) != 0 {
t.Errorf("expected empty list for nonexistent dir; got %d plugins", len(got))
}
}
func TestListRegistryFiltered_NoPluginYAML(t *testing.T) {
// Plugin directory exists but has no plugin.yaml → fallback name only.
dir := t.TempDir()
writePluginYAML(t, dir, "no-manifest-plugin", "")
h := makeTestHandler(t, dir)
got := h.listRegistryFiltered("")
if len(got) != 1 {
t.Fatalf("expected 1 plugin; got %d", len(got))
}
if got[0].Name != "no-manifest-plugin" {
t.Errorf("Name = %q; want no-manifest-plugin", got[0].Name)
}
}
func TestListRegistryFiltered_ValidPlugin(t *testing.T) {
dir := t.TempDir()
writePluginYAML(t, dir, "molecule-audit", `
name: molecule-audit
version: 1.0.0
description: Security audit plugin
author: Molecule AI
tags:
- security
skills:
- audit
runtimes:
- hermes
`)
h := makeTestHandler(t, dir)
got := h.listRegistryFiltered("")
if len(got) != 1 {
t.Fatalf("expected 1 plugin; got %d", len(got))
}
if got[0].Name != "molecule-audit" {
t.Errorf("Name = %q; want molecule-audit", got[0].Name)
}
if got[0].Version != "1.0.0" {
t.Errorf("Version = %q; want 1.0.0", got[0].Version)
}
if len(got[0].Tags) != 1 || got[0].Tags[0] != "security" {
t.Errorf("Tags = %v; want [security]", got[0].Tags)
}
}
func TestListRegistryFiltered_FilesIgnored(t *testing.T) {
// Regular files in pluginsDir are skipped (only directories are scanned).
dir := t.TempDir()
writePluginYAML(t, dir, "real-plugin", `
name: real-plugin
version: 1.0.0
`)
f, err := os.Create(filepath.Join(dir, "not-a-plugin.txt"))
if err != nil {
t.Fatal(err)
}
f.Close()
h := makeTestHandler(t, dir)
got := h.listRegistryFiltered("")
if len(got) != 1 || got[0].Name != "real-plugin" {
t.Errorf("expected only real-plugin; got %v", got)
}
}
func TestListRegistryFiltered_RuntimeFilterMatches(t *testing.T) {
dir := t.TempDir()
writePluginYAML(t, dir, "cc-plugin", `
name: cc-plugin
runtimes: [claude_code]
`)
writePluginYAML(t, dir, "hermes-plugin", `
name: hermes-plugin
runtimes: [hermes]
`)
h := makeTestHandler(t, dir)
// With hermes filter → only hermes-plugin returned.
got := h.listRegistryFiltered("hermes")
if len(got) != 1 || got[0].Name != "hermes-plugin" {
t.Errorf("expected [hermes-plugin]; got %v", got)
}
// With claude-code filter → hyphen normalises to underscore → cc-plugin returned.
got2 := h.listRegistryFiltered("claude-code")
if len(got2) != 1 || got2[0].Name != "cc-plugin" {
t.Errorf("expected [cc-plugin] with claude-code filter; got %v", got2)
}
}
func TestListRegistryFiltered_RuntimeFilterExcludes(t *testing.T) {
// Plugin declares hermes; query asks for claude-code → plugin excluded.
dir := t.TempDir()
writePluginYAML(t, dir, "hermes-only", `
name: hermes-only
runtimes: [hermes]
`)
h := makeTestHandler(t, dir)
got := h.listRegistryFiltered("claude_code")
if len(got) != 0 {
t.Errorf("expected empty list for mismatched runtime; got %v", got)
}
}
func TestListRegistryFiltered_UnspecifiedRuntimeIncluded(t *testing.T) {
// Plugin with no runtimes field is included in any filtered query
// ("unspecified = try it" contract).
dir := t.TempDir()
writePluginYAML(t, dir, "universal-plugin", `
name: universal-plugin
runtimes: []
`)
h := makeTestHandler(t, dir)
got := h.listRegistryFiltered("any-runtime")
if len(got) != 1 || got[0].Name != "universal-plugin" {
t.Errorf("expected [universal-plugin] with any runtime filter; got %v", got)
}
}
func TestListRegistryFiltered_MultipleMatching(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{"plugin-a", "plugin-b", "plugin-c"} {
writePluginYAML(t, dir, name, `name: `+name+`
runtimes: [hermes, claude_code]
`)
}
h := makeTestHandler(t, dir)
got := h.listRegistryFiltered("hermes")
if len(got) != 3 {
t.Errorf("expected 3 plugins; got %d: %v", len(got), got)
}
}
// -------- ListRegistry (GET /plugins) --------
func listRegistryReq(runtime string) (*http.Request, *httptest.ResponseRecorder, *gin.Context) {
url := "/plugins"
if runtime != "" {
url += "?runtime=" + runtime
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", url, nil)
return c.Request, w, c
}
func TestListRegistry_NoFilter(t *testing.T) {
dir := t.TempDir()
writePluginYAML(t, dir, "test-plugin", `
name: test-plugin
version: 0.1.0
`)
h := makeTestHandler(t, dir)
_, w, c := listRegistryReq("")
h.ListRegistry(c)
if w.Code != http.StatusOK {
t.Errorf("expected 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("unmarshal: %v", err)
}
if len(resp) != 1 || resp[0]["name"] != "test-plugin" {
t.Errorf("unexpected response: %v", resp)
}
}
func TestListRegistry_WithRuntimeFilter(t *testing.T) {
dir := t.TempDir()
writePluginYAML(t, dir, "hermes-plugin", `
name: hermes-plugin
runtimes: [hermes]
`)
writePluginYAML(t, dir, "cc-plugin", `
name: cc-plugin
runtimes: [claude_code]
`)
h := makeTestHandler(t, dir)
_, w, c := listRegistryReq("hermes")
h.ListRegistry(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200; got %d", w.Code)
}
var resp []map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if len(resp) != 1 || resp[0]["name"] != "hermes-plugin" {
t.Errorf("expected [hermes-plugin]; got %v", resp)
}
}
func TestListRegistry_EmptyOnNoMatches(t *testing.T) {
dir := t.TempDir()
writePluginYAML(t, dir, "cc-plugin", `name: cc-plugin
runtimes: [claude_code]
`)
h := makeTestHandler(t, dir)
_, w, c := listRegistryReq("nonexistent")
h.ListRegistry(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200; got %d", w.Code)
}
var resp []map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if len(resp) != 0 {
t.Errorf("expected empty list; got %v", resp)
}
}
// -------- ListAvailableForWorkspace (GET /workspaces/:id/plugins/available) --------
func listAvailableReq(workspaceID string) (*http.Request, *httptest.ResponseRecorder, *gin.Context) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: workspaceID}}
c.Request = httptest.NewRequest("GET", "/workspaces/"+workspaceID+"/plugins/available", nil)
return c.Request, w, c
}
func TestListAvailableForWorkspace_RuntimeLookupReturnsRuntime(t *testing.T) {
dir := t.TempDir()
writePluginYAML(t, dir, "hermes-plugin", `
name: hermes-plugin
runtimes: [hermes]
`)
writePluginYAML(t, dir, "cc-plugin", `
name: cc-plugin
runtimes: [claude_code]
`)
h := makeTestHandler(t, dir)
h.runtimeLookup = func(workspaceID string) (string, error) {
return "hermes", nil
}
_, w, c := listAvailableReq("00000000-0000-0000-0000-000000000001")
h.ListAvailableForWorkspace(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200; got %d", w.Code)
}
var resp []map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if len(resp) != 1 || resp[0]["name"] != "hermes-plugin" {
t.Errorf("expected [hermes-plugin]; got %v", resp)
}
}
func TestListAvailableForWorkspace_RuntimeLookupErrors(t *testing.T) {
// runtimeLookup error → runtime="" → full registry returned.
dir := t.TempDir()
writePluginYAML(t, dir, "plugin-a", `name: plugin-a
runtimes: [hermes]
`)
writePluginYAML(t, dir, "plugin-b", `name: plugin-b
runtimes: [claude_code]
`)
h := makeTestHandler(t, dir)
h.runtimeLookup = func(workspaceID string) (string, error) {
return "", errors.New("runtime lookup failed")
}
_, w, c := listAvailableReq("00000000-0000-0000-0000-000000000002")
h.ListAvailableForWorkspace(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200; got %d", w.Code)
}
var resp []map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if len(resp) != 2 {
t.Errorf("expected 2 plugins (full registry fallback); got %d: %v", len(resp), resp)
}
}
func TestListAvailableForWorkspace_NoRuntimeLookup(t *testing.T) {
// runtimeLookup nil → full registry (no filter).
dir := t.TempDir()
writePluginYAML(t, dir, "plugin-x", `name: plugin-x`)
h := makeTestHandler(t, dir)
// runtimeLookup is nil by default from makeTestHandler.
_, w, c := listAvailableReq("00000000-0000-0000-0000-000000000003")
h.ListAvailableForWorkspace(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200; got %d", w.Code)
}
var resp []map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if len(resp) != 1 || resp[0]["name"] != "plugin-x" {
t.Errorf("expected [plugin-x]; got %v", resp)
}
}
func TestListAvailableForWorkspace_UnspecifiedRuntimePluginsAlwaysIncluded(t *testing.T) {
// Plugins with empty runtimes list should always be included
// regardless of workspace runtime.
dir := t.TempDir()
writePluginYAML(t, dir, "universal", `name: universal
runtimes: []
`)
writePluginYAML(t, dir, "cc-only", `name: cc-only
runtimes: [claude_code]
`)
h := makeTestHandler(t, dir)
h.runtimeLookup = func(id string) (string, error) { return "hermes", nil }
_, w, c := listAvailableReq("ws-001")
h.ListAvailableForWorkspace(c)
var resp []map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
// "universal" has no runtimes (try-it); "cc-only" doesn't support hermes.
if len(resp) != 1 || resp[0]["name"] != "universal" {
t.Errorf("expected [universal]; got %v", resp)
}
}
@@ -198,17 +198,6 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
// back to its compiled-in Anthropic default and 401s when the user's
// key is for a different provider. Non-hermes runtimes are unaffected
// (the server still passes model through, they just don't use it).
// runtimeExplicitlyRequested is true when the caller expressed intent for
// a SPECIFIC runtime — either by passing `runtime` directly, or by naming
// a `template` (a template encodes a runtime). When true, we must NOT
// silently fall back to langgraph if that intent can't be honored: that
// is the molecule-controlplane#188 / #184 contract violation (caller asks
// for codex/claude-code, gets a langgraph workspace, 201, no error — a
// false success). #188 mandates fail-closed (error+notify) on mismatch,
// not an advisory degrade. The legitimate "no template, no runtime →
// langgraph default" path (bare {"name":...}) is unaffected.
runtimeExplicitlyRequested := payload.Runtime != "" || payload.Template != ""
templateRuntimeResolved := payload.Runtime != ""
if payload.Template != "" && (payload.Runtime == "" || payload.Model == "") {
// #226: payload.Template is attacker-controllable. resolveInsideRoot
// rejects absolute paths and any ".." that escapes configsDir so the
@@ -241,9 +230,6 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
switch {
case payload.Runtime == "" && !indented && strings.HasPrefix(stripped, "runtime:") && !strings.HasPrefix(stripped, "runtime_config"):
payload.Runtime = strings.TrimSpace(strings.TrimPrefix(stripped, "runtime:"))
if payload.Runtime != "" {
templateRuntimeResolved = true
}
case payload.Model == "" && !indented && strings.HasPrefix(stripped, "model:"):
// Legacy top-level `model:` — pre-runtime_config templates.
payload.Model = strings.Trim(strings.TrimSpace(strings.TrimPrefix(stripped, "model:")), `"'`)
@@ -256,27 +242,7 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
}
}
}
// Fail-closed (molecule-controlplane#188 / #184): if the caller expressed
// intent for a specific runtime (passed `runtime`, or named a `template`)
// but we could NOT resolve a concrete runtime from it (template's
// config.yaml unreadable, or it has no `runtime:` key), DO NOT silently
// substitute langgraph and return 201 — that is the silent contract
// violation that produced 5/5 wrong workspaces and a false codex E2E pass.
// Return 422 so the caller learns the requested runtime was not honored.
// The platform-side CP fix (controlplane#188) is the sibling gate; this
// closes the ws-server `Create` boundary the product UI actually hits.
if payload.Runtime == "" && runtimeExplicitlyRequested && !templateRuntimeResolved {
log.Printf("Create: FAIL-CLOSED (controlplane#188) — template=%q requested but runtime could not be resolved; refusing silent langgraph fallback", payload.Template)
c.JSON(http.StatusUnprocessableEntity, gin.H{
"error": "runtime could not be resolved from the requested template; refusing to silently provision langgraph (controlplane#188). Pass an explicit \"runtime\", or use a template whose config.yaml declares one.",
"template": payload.Template,
"code": "RUNTIME_UNRESOLVED",
})
return
}
if payload.Runtime == "" {
// Legitimate default path: no template AND no runtime requested
// (bare {"name":...}) — langgraph is the intended default here.
payload.Runtime = "langgraph"
}
@@ -0,0 +1,193 @@
package handlers
import (
"bytes"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
)
// patchReq builds a gin context for a PATCH request to /workspaces/:id/abilities.
func patchReq(id, body string) (*http.Request, *httptest.ResponseRecorder, *gin.Context) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: id}}
c.Request = httptest.NewRequest("PATCH", "/workspaces/"+id+"/abilities", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
return c.Request, w, c
}
func TestPatchAbilities_InvalidWorkspaceID(t *testing.T) {
setupTestDB(t)
// "not-a-uuid" fails validateWorkspaceID
_, w, c := patchReq("not-a-uuid", `{"broadcast_enabled":true}`)
PatchAbilities(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestPatchAbilities_EmptyBody(t *testing.T) {
setupTestDB(t)
id := "00000000-0000-0000-0000-000000000001"
// Empty JSON object — no ability fields present
_, w, c := patchReq(id, `{}`)
PatchAbilities(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]string
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["error"] != "at least one ability field required" {
t.Errorf("expected 'at least one ability field required', got %v", resp["error"])
}
}
func TestPatchAbilities_WorkspaceNotFound(t *testing.T) {
mock := setupTestDB(t)
id := "00000000-0000-0000-0000-000000000002"
// SELECT EXISTS returns false (workspace does not exist)
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`).
WithArgs(id).
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
_, w, c := patchReq(id, `{"broadcast_enabled":true}`)
PatchAbilities(c)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
}
}
func TestPatchAbilities_SetBroadcastEnabledTrue(t *testing.T) {
mock := setupTestDB(t)
id := "00000000-0000-0000-0000-000000000003"
// SELECT EXISTS → true
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`).
WithArgs(id).
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
// UPDATE broadcast_enabled = true
mock.ExpectExec(`UPDATE workspaces SET broadcast_enabled = \$2, updated_at = now\(\) WHERE id = \$1`).
WithArgs(id, true).
WillReturnResult(sqlmock.NewResult(0, 1))
_, w, c := patchReq(id, `{"broadcast_enabled":true}`)
PatchAbilities(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]string
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["status"] != "updated" {
t.Errorf("expected status=updated, got %v", resp["status"])
}
}
func TestPatchAbilities_SetTalkToUserEnabledFalse(t *testing.T) {
mock := setupTestDB(t)
id := "00000000-0000-0000-0000-000000000004"
// SELECT EXISTS → true
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`).
WithArgs(id).
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
// UPDATE talk_to_user_enabled = false
mock.ExpectExec(`UPDATE workspaces SET talk_to_user_enabled = \$2, updated_at = now\(\) WHERE id = \$1`).
WithArgs(id, false).
WillReturnResult(sqlmock.NewResult(0, 1))
_, w, c := patchReq(id, `{"talk_to_user_enabled":false}`)
PatchAbilities(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
}
func TestPatchAbilities_BothFields(t *testing.T) {
mock := setupTestDB(t)
id := "00000000-0000-0000-0000-000000000005"
// SELECT EXISTS → true
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`).
WithArgs(id).
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
// UPDATE broadcast_enabled = false
mock.ExpectExec(`UPDATE workspaces SET broadcast_enabled = \$2, updated_at = now\(\) WHERE id = \$1`).
WithArgs(id, false).
WillReturnResult(sqlmock.NewResult(0, 1))
// UPDATE talk_to_user_enabled = true
mock.ExpectExec(`UPDATE workspaces SET talk_to_user_enabled = \$2, updated_at = now\(\) WHERE id = \$1`).
WithArgs(id, true).
WillReturnResult(sqlmock.NewResult(0, 1))
_, w, c := patchReq(id, `{"broadcast_enabled":false,"talk_to_user_enabled":true}`)
PatchAbilities(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
}
func TestPatchAbilities_BroadcastUpdateFails(t *testing.T) {
mock := setupTestDB(t)
id := "00000000-0000-0000-0000-000000000006"
// SELECT EXISTS → true
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`).
WithArgs(id).
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
// UPDATE fails
mock.ExpectExec(`UPDATE workspaces SET broadcast_enabled = \$2, updated_at = now\(\) WHERE id = \$1`).
WithArgs(id, true).
WillReturnError(sql.ErrConnDone)
_, w, c := patchReq(id, `{"broadcast_enabled":true}`)
PatchAbilities(c)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String())
}
}
func TestPatchAbilities_TalkToUserUpdateFails(t *testing.T) {
mock := setupTestDB(t)
id := "00000000-0000-0000-0000-000000000007"
// SELECT EXISTS → true
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`).
WithArgs(id).
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
// UPDATE broadcast_enabled skipped (not in payload)
// UPDATE talk_to_user_enabled fails
mock.ExpectExec(`UPDATE workspaces SET talk_to_user_enabled = \$2, updated_at = now\(\) WHERE id = \$1`).
WithArgs(id, false).
WillReturnError(sql.ErrConnDone)
_, w, c := patchReq(id, `{"talk_to_user_enabled":false}`)
PatchAbilities(c)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String())
}
}
@@ -718,7 +718,7 @@ func TestWorkspaceList_Empty(t *testing.T) {
"parent_id", "active_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled",
"broadcast_enabled", "talk_to_user_enabled",
}))
w := httptest.NewRecorder()
@@ -1770,147 +1770,3 @@ runtime_config:
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
}
// ==================== #188 fail-closed: template/runtime contract ====================
//
// molecule-controlplane#188 / #184: if a caller names a `template` (intent
// for a specific runtime) but the runtime cannot be resolved from it, the
// server MUST NOT silently provision langgraph and return 201 — that false
// success produced 5/5 wrong workspaces and a bogus codex E2E pass. These
// tests pin the fail-closed boundary at the ws-server `Create` handler (the
// path the product UI hits), and guard the legitimate default path against
// regression.
// Template requested but its dir/config.yaml is absent → 422, not silent
// langgraph 201.
func TestWorkspaceCreate_188_TemplateMissingRuntime_FailsClosed(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
// configsDir is an empty temp dir → resolveInsideRoot succeeds (the path
// is inside root) but config.yaml read fails → runtime cannot be resolved.
configsDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(configsDir, "ghost-template"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", configsDir)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Ghost","template":"ghost-template"}`
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 (fail-closed, controlplane#188), 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("parse: %v", err)
}
if resp["code"] != "RUNTIME_UNRESOLVED" {
t.Errorf("expected code RUNTIME_UNRESOLVED, got %v", resp["code"])
}
}
// Template config.yaml has no `runtime:` key → 422, not silent langgraph.
func TestWorkspaceCreate_188_TemplateConfigNoRuntimeKey_FailsClosed(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
configsDir := t.TempDir()
tdir := filepath.Join(configsDir, "noruntime-template")
if err := os.MkdirAll(tdir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
// config.yaml exists but declares no runtime.
if err := os.WriteFile(filepath.Join(tdir, "config.yaml"), []byte("name: noruntime\n"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", configsDir)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"NoRuntime","template":"noruntime-template"}`
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 (fail-closed), got %d: %s", w.Code, w.Body.String())
}
}
// 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.StatusCreated {
t.Fatalf("expected 201 (legitimate default path), got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// Explicit runtime, no template → honored, 201 (no template resolution
// needed; runtimeExplicitlyRequested true but already resolved).
func TestWorkspaceCreate_188_ExplicitRuntimeNoTemplate_OK(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(), "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").
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":"Explicit Codex","runtime":"codex"}`
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("expected 201, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
@@ -81,11 +81,11 @@ func TestPositiveMatches(t *testing.T) {
fixture string
expectedName string
}{
{"ghp_" + "EXAMPLE111122223333444455556666777788889999", "github-pat-classic"},
{"ghs_" + "EXAMPLE111122223333444455556666777788889999", "github-app-installation-token"},
{"gho_" + "EXAMPLE111122223333444455556666777788889999", "github-oauth-user-to-server"},
{"ghu_" + "EXAMPLE111122223333444455556666777788889999", "github-oauth-user"},
{"ghr_" + "EXAMPLE111122223333444455556666777788889999", "github-oauth-refresh"},
{"ghp_EXAMPLE111122223333444455556666777788889999", "github-pat-classic"},
{"ghs_EXAMPLE111122223333444455556666777788889999", "github-app-installation-token"},
{"gho_EXAMPLE111122223333444455556666777788889999", "github-oauth-user-to-server"},
{"ghu_EXAMPLE111122223333444455556666777788889999", "github-oauth-user"},
{"ghr_EXAMPLE111122223333444455556666777788889999", "github-oauth-refresh"},
{"github_pat_EXAMPLE" + strings.Repeat("1", 80), "github-pat-fine-grained"},
{"sk-ant-EXAMPLE" + strings.Repeat("1", 40), "anthropic-api-key"},
{"sk-proj-EXAMPLE" + strings.Repeat("1", 40), "openai-project-key"},
@@ -156,7 +156,7 @@ func TestNegativeShapes(t *testing.T) {
// makes ScanString do its own thing (e.g. accidentally normalise
// case) would diverge silently.
func TestScanString_NoOp(t *testing.T) {
in := "ghp_" + "EXAMPLE111122223333444455556666777788889999"
in := "ghp_EXAMPLE111122223333444455556666777788889999"
m1, err1 := ScanBytes([]byte(in))
if err1 != nil {
t.Fatalf("ScanBytes errored: %v", err1)
-6
View File
@@ -172,12 +172,6 @@ async def handle_tool_call(name: str, arguments: dict) -> str:
arguments.get("message", ""),
workspace_id=arguments.get("workspace_id") or None,
)
elif name == "get_runtime_identity":
return await tool_get_runtime_identity()
elif name == "update_agent_card":
return await tool_update_agent_card(
arguments.get("card"),
)
return f"Unknown tool: {name}"