Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7da843f2fa | |||
| e5521c7675 | |||
| a52110502d | |||
| 69bcc55ad3 | |||
| 36c63798eb | |||
| 43422e0ba9 | |||
| 0ffb29f371 | |||
| 226698239f | |||
| 3c82b39f3d | |||
| 4d32736e25 | |||
| 691d341fbb | |||
| ef42e17224 | |||
| b13c9f94f1 | |||
| 600f88b172 | |||
| df94fd1764 | |||
| 8346b06291 | |||
| b7da21063e | |||
| 2f7b5ad871 | |||
| 213ea06840 | |||
| f07dfa7af6 | |||
| 93f5a4aac3 | |||
| e5d6e45ab1 | |||
| a1cf56cdab | |||
| 436fae8949 | |||
| 2d1a853bf9 | |||
| 5551ef40e3 | |||
| 656176d511 | |||
| 1424af51fa | |||
| 7f0f33739b | |||
| 339d73d9d4 | |||
| 50fe4976e6 | |||
| e05fc4daae | |||
| 6c7f66fa31 | |||
| acf784cd81 | |||
| 543519ed69 | |||
| 010ec0f81b | |||
| bc73f6397a | |||
| e79a842859 | |||
| c3bcf903bd | |||
| 008a19dbdd | |||
| e51dae906f | |||
| f1f7492b66 | |||
| 3161d43cec | |||
| 29349e7af0 | |||
| 78e1025f41 | |||
| af3d98e478 | |||
| 321d051c9f |
@@ -128,6 +128,7 @@ fi
|
||||
PR_AUTHOR=$(jq -r '.user.login // ""' "$PR_JSON")
|
||||
PR_HEAD_SHA=$(jq -r '.head.sha // ""' "$PR_JSON")
|
||||
PR_BASE_REF=$(jq -r '.base.ref // ""' "$PR_JSON")
|
||||
PR_BASE_SHA=$(jq -r '.base.sha // ""' "$PR_JSON")
|
||||
PR_STATE=$(jq -r '.state // ""' "$PR_JSON")
|
||||
DEFAULT_BRANCH="${DEFAULT_BRANCH:-main}"
|
||||
debug "pr_author=${PR_AUTHOR} pr_head=${PR_HEAD_SHA:0:7} pr_base=${PR_BASE_REF} pr_state=${PR_STATE}"
|
||||
@@ -136,6 +137,10 @@ if [ "$PR_STATE" != "open" ]; then
|
||||
echo "::notice::PR ${PR_NUMBER} is ${PR_STATE} — exiting 0 (closed PRs do not gate)"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$PR_HEAD_SHA" = "$PR_BASE_SHA" ]; then
|
||||
echo "::notice::PR ${PR_NUMBER} has no diff (head == base) — exiting 0 (empty PRs do not gate)"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$PR_BASE_REF" != "$DEFAULT_BRANCH" ]; then
|
||||
echo "::notice::PR ${PR_NUMBER} targets ${PR_BASE_REF:-<unknown>} not ${DEFAULT_BRANCH} — ${TEAM}-review gate not applicable"
|
||||
exit 0
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def load_workflow(name: str) -> dict:
|
||||
with (ROOT / "workflows" / name).open() as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
def test_all_required_uses_dedicated_meta_runner_lane():
|
||||
workflow = load_workflow("ci.yml")
|
||||
all_required = workflow["jobs"]["all-required"]
|
||||
|
||||
assert all_required["runs-on"] == "ci-meta"
|
||||
assert "needs" not in all_required
|
||||
|
||||
|
||||
def test_all_required_reuses_path_filter_before_polling():
|
||||
workflow = load_workflow("ci.yml")
|
||||
all_required = workflow["jobs"]["all-required"]
|
||||
rendered = str(all_required)
|
||||
|
||||
assert "--profile ci" in rendered
|
||||
assert ".gitea/scripts/detect-changes.py" in rendered
|
||||
assert "REQUIRE_PLATFORM" in rendered
|
||||
assert "REQUIRE_CANVAS" in rendered
|
||||
assert "REQUIRE_SCRIPTS" in rendered
|
||||
@@ -0,0 +1,187 @@
|
||||
# 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. mc#774
|
||||
# internal#418 — tracked: arm64 advisory pilot, non-gating by design.
|
||||
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"
|
||||
+30
-5
@@ -476,7 +476,11 @@ jobs:
|
||||
# jobs settle, leaving branch protection with a permanent pending
|
||||
# `CI / all-required` context. Instead, this independent sentinel polls the
|
||||
# required commit-status contexts for this SHA and fails if any fail, skip,
|
||||
# or never emit.
|
||||
# or never emit. It runs the same path detector as `changes` and only waits
|
||||
# for path-relevant jobs; Gitea can otherwise leave needs/output-skipped
|
||||
# jobs permanently pending with "Blocked by required conditions". It runs on
|
||||
# the dedicated `ci-meta` lane so the poller does not occupy the same
|
||||
# general runner pool as the jobs it is waiting for.
|
||||
#
|
||||
# canvas-deploy-reminder is intentionally NOT included in all-required.needs.
|
||||
# It is an informational main-push reminder, not a PR quality gate. Keeping
|
||||
@@ -484,9 +488,24 @@ jobs:
|
||||
# sentinel before the `always()` guard can emit a branch-protection status.
|
||||
#
|
||||
continue-on-error: false
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ci-meta
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: check
|
||||
env:
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
PUSH_BEFORE: ${{ github.event.before }}
|
||||
run: |
|
||||
python3 .gitea/scripts/detect-changes.py \
|
||||
--profile ci \
|
||||
--event-name "${{ github.event_name }}" \
|
||||
--pr-base-sha "$PR_BASE_SHA" \
|
||||
--base-ref "$PR_BASE_REF" \
|
||||
--push-before "${GITHUB_EVENT_BEFORE:-$PUSH_BEFORE}"
|
||||
- name: Wait for required CI contexts
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -494,6 +513,9 @@ jobs:
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
COMMIT_SHA: ${{ github.sha }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REQUIRE_PLATFORM: ${{ steps.check.outputs.platform }}
|
||||
REQUIRE_CANVAS: ${{ steps.check.outputs.canvas }}
|
||||
REQUIRE_SCRIPTS: ${{ steps.check.outputs.scripts }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY'
|
||||
@@ -511,11 +533,14 @@ jobs:
|
||||
event = os.environ["EVENT_NAME"]
|
||||
required = [
|
||||
f"CI / Detect changes ({event})",
|
||||
f"CI / Platform (Go) ({event})",
|
||||
f"CI / Canvas (Next.js) ({event})",
|
||||
f"CI / Shellcheck (E2E scripts) ({event})",
|
||||
f"CI / Python Lint & Test ({event})",
|
||||
]
|
||||
if os.environ.get("REQUIRE_PLATFORM") == "true":
|
||||
required.append(f"CI / Platform (Go) ({event})")
|
||||
if os.environ.get("REQUIRE_CANVAS") == "true":
|
||||
required.append(f"CI / Canvas (Next.js) ({event})")
|
||||
if os.environ.get("REQUIRE_SCRIPTS") == "true":
|
||||
required.append(f"CI / Shellcheck (E2E scripts) ({event})")
|
||||
terminal_bad = {"failure", "error"}
|
||||
deadline = time.time() + 40 * 60
|
||||
last_summary = None
|
||||
|
||||
@@ -25,7 +25,7 @@ permissions:
|
||||
jobs:
|
||||
shellcheck-arm64:
|
||||
name: shellcheck-arm64 (pilot)
|
||||
runs-on: [self-hosted, arm64]
|
||||
runs-on: [self-hosted, arm64-darwin]
|
||||
# NOT a required check; safe to sit pending until Mac runner is up.
|
||||
# If the Mac runner has trouble pulling actions/checkout we fall
|
||||
# back to a plain git clone (see step 'fallback clone').
|
||||
@@ -52,6 +52,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Install shellcheck (arm64)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -eu
|
||||
if command -v shellcheck >/dev/null 2>&1; then
|
||||
@@ -71,11 +72,16 @@ jobs:
|
||||
shellcheck --version | head -2
|
||||
|
||||
- name: Run shellcheck on .gitea/scripts/*.sh
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -eu
|
||||
# Only the scripts we control under .gitea/scripts. Pilot
|
||||
# scope is intentionally narrow — broaden in a follow-up
|
||||
# once the lane is proven.
|
||||
if ! command -v shellcheck >/dev/null 2>&1; then
|
||||
echo "WARN: shellcheck binary not found — skipping (pilot mode)"
|
||||
exit 0
|
||||
fi
|
||||
mapfile -t TARGETS < <(find .gitea/scripts -maxdepth 2 -type f -name '*.sh' | sort)
|
||||
if [ "${#TARGETS[@]}" -eq 0 ]; then
|
||||
echo "No .sh files found under .gitea/scripts — nothing to check"
|
||||
|
||||
@@ -73,6 +73,17 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
# Keep Docker auth/buildx state inside the job temp dir. Publish
|
||||
# runners can inherit a HOME/DOCKER_CONFIG path that is host-owned
|
||||
# and not writable from the job container; docker login otherwise
|
||||
# fails before the image build starts.
|
||||
- name: Prepare writable Docker config
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export DOCKER_CONFIG="$RUNNER_TEMP/docker-config"
|
||||
mkdir -p "$DOCKER_CONFIG/buildx/certs"
|
||||
echo "DOCKER_CONFIG=$DOCKER_CONFIG" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Log in to ECR
|
||||
env:
|
||||
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||
|
||||
@@ -234,6 +234,8 @@ jobs:
|
||||
name: Production auto-deploy
|
||||
needs: build-and-push
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
# Side-effect deploy only; image publish success is the durable artifact. mc#774
|
||||
continue-on-error: true
|
||||
# Publish/release lane (internal#462) — production deploy of a merged
|
||||
# fix; reserved capacity, never queued behind PR-CI.
|
||||
runs-on: publish
|
||||
|
||||
Generated
+7
@@ -8,6 +8,7 @@
|
||||
"name": "molecule-monorepo-canvas",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@novnc/novnc": "^1.7.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
@@ -1110,6 +1111,12 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@novnc/novnc": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.7.0.tgz",
|
||||
"integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==",
|
||||
"license": "MPL-2.0"
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.127.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@novnc/novnc": "^1.7.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
|
||||
@@ -33,6 +33,8 @@ interface HermesProvider {
|
||||
models: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_CREATE_MODEL = "anthropic:claude-opus-4-7";
|
||||
|
||||
// All providers supported by Hermes runtime via providers.resolve_provider().
|
||||
// `defaultModel` is the slug injected into the workspace provision request
|
||||
// when the user picks this provider — template-hermes's derive-provider.sh
|
||||
@@ -68,6 +70,10 @@ export function CreateWorkspaceButton() {
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [workspaces, setWorkspaces] = useState<WorkspaceOption[]>([]);
|
||||
const [displayEnabled, setDisplayEnabled] = useState(false);
|
||||
const [displayInstanceType, setDisplayInstanceType] = useState("t3.xlarge");
|
||||
const [displayRootGB, setDisplayRootGB] = useState("80");
|
||||
const [displayResolution, setDisplayResolution] = useState("1920x1080");
|
||||
// Templates fetched from /api/templates — drives the dynamic provider
|
||||
// filter below. Same data source ConfigTab uses (PR #2454). When the
|
||||
// selected template declares `runtime_config.providers` in its
|
||||
@@ -223,6 +229,10 @@ export function CreateWorkspaceButton() {
|
||||
setParentId("");
|
||||
setBudgetLimit("");
|
||||
setError(null);
|
||||
setDisplayEnabled(false);
|
||||
setDisplayInstanceType("t3.xlarge");
|
||||
setDisplayRootGB("80");
|
||||
setDisplayResolution("1920x1080");
|
||||
setHermesProvider("anthropic");
|
||||
setExternalRuntime("external");
|
||||
setHermesApiKey("");
|
||||
@@ -264,6 +274,8 @@ export function CreateWorkspaceButton() {
|
||||
const parsedBudget = budgetLimit.trim()
|
||||
? parseFloat(budgetLimit)
|
||||
: null;
|
||||
const [displayWidth, displayHeight] = displayResolution.split("x").map((v) => parseInt(v, 10));
|
||||
const parsedRootGB = parseInt(displayRootGB, 10);
|
||||
|
||||
const createResp = await api.post<{
|
||||
id: string;
|
||||
@@ -280,6 +292,21 @@ export function CreateWorkspaceButton() {
|
||||
tier,
|
||||
parent_id: parentId || undefined,
|
||||
budget_limit: parsedBudget,
|
||||
...(!isExternal && !isHermes ? { model: DEFAULT_CREATE_MODEL } : {}),
|
||||
...(displayEnabled
|
||||
? {
|
||||
compute: {
|
||||
instance_type: displayInstanceType,
|
||||
volume: { root_gb: Number.isFinite(parsedRootGB) ? parsedRootGB : 80 },
|
||||
display: {
|
||||
mode: "desktop-control",
|
||||
protocol: "novnc",
|
||||
width: Number.isFinite(displayWidth) ? displayWidth : 1920,
|
||||
height: Number.isFinite(displayHeight) ? displayHeight : 1080,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
canvas: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 },
|
||||
// Runtime=external flips the backend into awaiting-agent mode:
|
||||
// no container provisioning, token minted, connection payload
|
||||
@@ -447,6 +474,73 @@ export function CreateWorkspaceButton() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isExternal && (
|
||||
<div className="rounded-lg border border-line/50 bg-surface-card/40 p-3">
|
||||
<div className="mb-2 text-[11px] font-medium text-ink-mid">
|
||||
Container Config
|
||||
</div>
|
||||
<label className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-medium text-ink">Display</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={displayEnabled}
|
||||
onChange={(e) => setDisplayEnabled(e.target.checked)}
|
||||
aria-label="Enable display"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</label>
|
||||
{displayEnabled && (
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label htmlFor="display-instance-type" className="mb-1 block text-[11px] text-ink-mid">
|
||||
Instance
|
||||
</label>
|
||||
<select
|
||||
id="display-instance-type"
|
||||
value={displayInstanceType}
|
||||
onChange={(e) => setDisplayInstanceType(e.target.value)}
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-2 py-2 text-xs text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
>
|
||||
<option value="t3.large">t3.large</option>
|
||||
<option value="t3.xlarge">t3.xlarge</option>
|
||||
<option value="m6i.xlarge">m6i.xlarge</option>
|
||||
<option value="c6i.xlarge">c6i.xlarge</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="display-root-gb" className="mb-1 block text-[11px] text-ink-mid">
|
||||
Disk GB
|
||||
</label>
|
||||
<input
|
||||
id="display-root-gb"
|
||||
type="number"
|
||||
min="30"
|
||||
max="500"
|
||||
value={displayRootGB}
|
||||
onChange={(e) => setDisplayRootGB(e.target.value)}
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-2 py-2 text-xs text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label htmlFor="display-resolution" className="mb-1 block text-[11px] text-ink-mid">
|
||||
Resolution
|
||||
</label>
|
||||
<select
|
||||
id="display-resolution"
|
||||
value={displayResolution}
|
||||
onChange={(e) => setDisplayResolution(e.target.value)}
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-2 py-2 text-xs text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
>
|
||||
<option value="1920x1080">1920 x 1080</option>
|
||||
<option value="1600x900">1600 x 900</option>
|
||||
<option value="1280x720">1280 x 720</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-[11px] text-ink-mid block mb-1">
|
||||
Parent Workspace
|
||||
|
||||
@@ -24,9 +24,10 @@
|
||||
* "no memories yet".
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
import { useSocketEvent } from '@/hooks/useSocketEvent';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -246,6 +247,60 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
loadEntries();
|
||||
}, [loadEntries]);
|
||||
|
||||
// Live-refresh on ACTIVITY_LOGGED events that look like memory writes
|
||||
// for this workspace (#1734). Without this, the user sees a stale
|
||||
// empty state after an agent commits — agent says "wrote memory",
|
||||
// panel keeps showing nothing until they hit Refresh.
|
||||
//
|
||||
// What actually broadcasts ACTIVITY_LOGGED on the server today
|
||||
// (workspace-server/internal/handlers/activity.go LogActivity /
|
||||
// LogActivityTx — those are the only emitters):
|
||||
//
|
||||
// - `memory_write_global` — `POST /workspaces/:id/memories` for GLOBAL scope
|
||||
// - `memory_edit_global` — `PATCH /workspaces/:id/memories/:id` for GLOBAL scope
|
||||
// - `memory_delete_global` — `DELETE /workspaces/:id/memories/:id` for GLOBAL scope
|
||||
// - `agent_log` — generic catch-all an agent emits via
|
||||
// `POST /workspaces/:id/activity`
|
||||
//
|
||||
// The MCP-tool path (`commit_memory`, `commit_memory_v2`,
|
||||
// `commit_summary`) does NOT broadcast on the wire today; it inserts
|
||||
// into agent_memories (pre-A1) or calls the v2 plugin (post-A1) and
|
||||
// never round-trips through LogActivity. Server-side follow-up is
|
||||
// tracked in **#1754** — once the MCP handlers emit `memory_write`
|
||||
// via LogActivity, the `agent_log` arm of the filter below can be
|
||||
// dropped. `memory_write` is included pre-emptively so this code
|
||||
// lights up the moment #1754 lands. Until then, `agent_log` catches
|
||||
// MCP commits over-inclusively; the 300ms debounce bounds the
|
||||
// refetch rate. Issue #1734 review finding.
|
||||
//
|
||||
// The 300ms debounce coalesces bursts so a chatty agent (e.g. an
|
||||
// agent in a long task emitting agent_log every few hundred ms)
|
||||
// doesn't hammer /v2/memories on every keystroke-equivalent.
|
||||
const refetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => () => {
|
||||
if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current);
|
||||
}, []);
|
||||
useSocketEvent((msg) => {
|
||||
if (msg.event !== 'ACTIVITY_LOGGED') return;
|
||||
if (msg.workspace_id !== workspaceId) return;
|
||||
const p = (msg.payload || {}) as Record<string, unknown>;
|
||||
const activityType = (p.activity_type as string) || '';
|
||||
switch (activityType) {
|
||||
case 'memory_write':
|
||||
case 'memory_write_global':
|
||||
case 'memory_edit_global':
|
||||
case 'memory_delete_global':
|
||||
case 'agent_log':
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current);
|
||||
refetchTimerRef.current = setTimeout(() => {
|
||||
loadEntries();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// ── Delete handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
const confirmDelete = useCallback(async () => {
|
||||
|
||||
@@ -123,6 +123,46 @@ describe("CreateWorkspaceDialog", () => {
|
||||
expect(body.parent_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits compute config by default", async () => {
|
||||
await openDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "Plain Agent" },
|
||||
});
|
||||
|
||||
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
|
||||
fireEvent.click(createBtn!);
|
||||
|
||||
await waitFor(() => expect(mockPost).toHaveBeenCalled());
|
||||
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body.compute).toBeUndefined();
|
||||
expect(body.model).toBe("anthropic:claude-opus-4-7");
|
||||
});
|
||||
|
||||
it("sends display compute profile when desktop display is enabled", async () => {
|
||||
await openDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "Desktop Agent" },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Enable display"));
|
||||
|
||||
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
|
||||
fireEvent.click(createBtn!);
|
||||
|
||||
await waitFor(() => expect(mockPost).toHaveBeenCalled());
|
||||
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body.model).toBe("anthropic:claude-opus-4-7");
|
||||
expect(body.compute).toEqual({
|
||||
instance_type: "t3.xlarge",
|
||||
volume: { root_gb: 80 },
|
||||
display: {
|
||||
mode: "desktop-control",
|
||||
protocol: "novnc",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("renders gracefully when GET /workspaces fails", async () => {
|
||||
mockGet.mockRejectedValueOnce(new Error("Network error"));
|
||||
await openDialog();
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* - handleDeployed fires after 500ms delay
|
||||
*
|
||||
* Uses vi.hoisted + vi.mock to fully isolate the api module, matching
|
||||
* the pattern established in ApprovalBanner, MemoryTab, and ScheduleTab tests.
|
||||
* the pattern established in ApprovalBanner and ScheduleTab tests.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Unit tests for pure helpers from MemoryInspectorPanel:
|
||||
* isPluginUnavailableError, formatRelativeTime, formatTTL
|
||||
*
|
||||
* These are the three exported non-component functions. The component
|
||||
* itself (MemoryInspectorPanel) requires full API + store mocking and
|
||||
* is exercised by the existing MemoryTab.test.tsx.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { isPluginUnavailableError, formatTTL } from "../MemoryInspectorPanel";
|
||||
|
||||
// formatRelativeTime is not exported — tested via the component in MemoryTab.test.tsx
|
||||
|
||||
describe("isPluginUnavailableError", () => {
|
||||
it("returns true when Error message contains MEMORY_PLUGIN_URL", () => {
|
||||
const err = new Error("memory: could not resolve MEMORY_PLUGIN_URL — plugin not configured");
|
||||
expect(isPluginUnavailableError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for Error containing MEMORY_PLUGIN_URL", () => {
|
||||
expect(isPluginUnavailableError(new Error("MEMORY_PLUGIN_URL is not set"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for unrelated error messages", () => {
|
||||
expect(isPluginUnavailableError(new Error("workspace not found"))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for null", () => {
|
||||
expect(isPluginUnavailableError(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for undefined", () => {
|
||||
expect(isPluginUnavailableError(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for plain objects without message", () => {
|
||||
expect(isPluginUnavailableError({ code: 503 })).toBe(false);
|
||||
});
|
||||
|
||||
it("is case-sensitive (MEMORY_PLUGIN_URL must match exactly)", () => {
|
||||
const lowerErr = new Error("memory_plugin_url missing");
|
||||
const upperErr = new Error("MEMORY_PLUGIN_URL missing");
|
||||
expect(isPluginUnavailableError(lowerErr)).toBe(false);
|
||||
expect(isPluginUnavailableError(upperErr)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTTL", () => {
|
||||
beforeEach(() => { vi.useFakeTimers(); });
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it("returns '' for null", () => {
|
||||
expect(formatTTL(null)).toBe("");
|
||||
});
|
||||
|
||||
it("returns '' for undefined", () => {
|
||||
expect(formatTTL(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it('returns "expired" when expiresAt is in the past', () => {
|
||||
const past = new Date(Date.now() - 60_000).toISOString();
|
||||
expect(formatTTL(past)).toBe("expired");
|
||||
});
|
||||
|
||||
it('returns "Xs" for less than a minute', () => {
|
||||
const soon = new Date(Date.now() + 30_000).toISOString();
|
||||
expect(formatTTL(soon)).toBe("30s");
|
||||
});
|
||||
|
||||
it('returns "Xm" for less than an hour', () => {
|
||||
const soon = new Date(Date.now() + 5 * 60_000).toISOString();
|
||||
expect(formatTTL(soon)).toBe("5m");
|
||||
});
|
||||
|
||||
it('returns "Xh" for less than a day', () => {
|
||||
const soon = new Date(Date.now() + 3 * 3_600_000).toISOString();
|
||||
expect(formatTTL(soon)).toBe("3h");
|
||||
});
|
||||
|
||||
it('returns "Xd" for more than a day', () => {
|
||||
const soon = new Date(Date.now() + 2 * 86_400_000).toISOString();
|
||||
expect(formatTTL(soon)).toBe("2d");
|
||||
});
|
||||
|
||||
it("returns '' for invalid date string", () => {
|
||||
expect(formatTTL("not-a-date")).toBe("");
|
||||
});
|
||||
|
||||
it("returns '' for empty string", () => {
|
||||
expect(formatTTL("")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,17 @@ vi.mock('@/lib/api', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Capture the socket-event handler the panel registers so individual
|
||||
// tests can replay an ACTIVITY_LOGGED message without spinning up a
|
||||
// real WebSocket. One handler at a time is fine — the panel mounts
|
||||
// exactly one useSocketEvent subscriber.
|
||||
let __socketHandler: ((msg: unknown) => void) | null = null;
|
||||
vi.mock('@/hooks/useSocketEvent', () => ({
|
||||
useSocketEvent: (handler: (msg: unknown) => void) => {
|
||||
__socketHandler = handler;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ConfirmDialog', () => ({
|
||||
ConfirmDialog: ({
|
||||
open,
|
||||
@@ -516,3 +527,156 @@ describe('MemoryInspectorPanel — refresh', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Live-refresh subscription wired in #1734 so the panel reacts to
|
||||
// ACTIVITY_LOGGED events for memory writes on this workspace without
|
||||
// the user clicking Refresh. The hook is mocked at the top of the
|
||||
// file to capture the registered handler in __socketHandler.
|
||||
describe('MemoryInspectorPanel — live refresh on activity', () => {
|
||||
it('refetches memories when ACTIVITY_LOGGED arrives with activity_type=memory_write for the same workspace', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
stubFetch([MEM_BASIC]);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByLabelText('Refresh memories'));
|
||||
expect(__socketHandler).toBeTruthy();
|
||||
|
||||
const before = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
|
||||
__socketHandler!({
|
||||
event: 'ACTIVITY_LOGGED',
|
||||
workspace_id: 'ws-1',
|
||||
payload: { activity_type: 'memory_write' },
|
||||
});
|
||||
|
||||
// 300ms debounce inside the panel — advance the fake timer so the
|
||||
// queued refetch fires.
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
|
||||
await waitFor(() => {
|
||||
const after = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
expect(after).toBe(before + 1);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('ignores ACTIVITY_LOGGED events from other workspaces', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
stubFetch([MEM_BASIC]);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByLabelText('Refresh memories'));
|
||||
|
||||
const before = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
|
||||
__socketHandler!({
|
||||
event: 'ACTIVITY_LOGGED',
|
||||
workspace_id: 'ws-OTHER',
|
||||
payload: { activity_type: 'memory_write' },
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
const after = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
expect(after).toBe(before);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('ignores activity types that are not memory-related', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
stubFetch([MEM_BASIC]);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByLabelText('Refresh memories'));
|
||||
|
||||
const before = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
|
||||
__socketHandler!({
|
||||
event: 'ACTIVITY_LOGGED',
|
||||
workspace_id: 'ws-1',
|
||||
payload: { activity_type: 'a2a_send' },
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
const after = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
expect(after).toBe(before);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// Server-side emitters confirmed via grep of workspace-server/internal/handlers
|
||||
// are `memory_write_global`, `memory_edit_global`, `memory_delete_global`
|
||||
// (memories.go `LogActivity` calls for GLOBAL-scope writes). Pin each
|
||||
// so a future filter narrow-down can't silently drop one and let the
|
||||
// panel go stale on its actual production trigger.
|
||||
it.each([
|
||||
'memory_write', // pre-emptive: not yet emitted by server, see component comment
|
||||
'memory_write_global', // memories.go:218 (Commit)
|
||||
'memory_edit_global', // memories.go:617 (Update)
|
||||
'memory_delete_global', // memories.go (Delete) — paired with the above two
|
||||
'agent_log', // generic catch-all
|
||||
])('refetches on activity_type=%s', async (activityType) => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
stubFetch([MEM_BASIC]);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByLabelText('Refresh memories'));
|
||||
|
||||
const before = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
|
||||
__socketHandler!({
|
||||
event: 'ACTIVITY_LOGGED',
|
||||
workspace_id: 'ws-1',
|
||||
payload: { activity_type: activityType },
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
|
||||
await waitFor(() => {
|
||||
const after = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
expect(after).toBe(before + 1);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('coalesces a burst of memory_write events into one refetch', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
stubFetch([MEM_BASIC]);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByLabelText('Refresh memories'));
|
||||
|
||||
const before = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
__socketHandler!({
|
||||
event: 'ACTIVITY_LOGGED',
|
||||
workspace_id: 'ws-1',
|
||||
payload: { activity_type: 'memory_write' },
|
||||
});
|
||||
}
|
||||
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
|
||||
await waitFor(() => {
|
||||
const after = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
expect(after).toBe(before + 1);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -369,7 +369,7 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
onClick={handleCreate}
|
||||
// Was bg-accent-strong hover:bg-accent — accent is the
|
||||
// LIGHTER variant; same AA contrast trap fixed in
|
||||
// ScheduleTab/MemoryTab/OnboardingWizard.
|
||||
// ScheduleTab/OnboardingWizard.
|
||||
className="w-full text-xs py-1.5 rounded bg-accent hover:bg-accent-strong text-white transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
|
||||
>
|
||||
Connect Channel
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import type RFB from "@novnc/novnc";
|
||||
|
||||
interface DisplayStatus {
|
||||
available: boolean;
|
||||
@@ -13,31 +14,116 @@ interface DisplayStatus {
|
||||
height?: number;
|
||||
}
|
||||
|
||||
interface DisplayControlStatus {
|
||||
controller: "none" | "user" | "agent";
|
||||
controlled_by?: string;
|
||||
expires_at?: string;
|
||||
session_url?: 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 [sessionUrl, setSessionUrl] = useState<string | null>(null);
|
||||
const requestGeneration = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const generation = requestGeneration.current + 1;
|
||||
requestGeneration.current = generation;
|
||||
let cancelled = false;
|
||||
setStatus(null);
|
||||
setControl(null);
|
||||
setSessionUrl(null);
|
||||
setError(null);
|
||||
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");
|
||||
});
|
||||
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();
|
||||
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);
|
||||
setSessionUrl(next.session_url || null);
|
||||
} 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);
|
||||
}
|
||||
};
|
||||
|
||||
const releaseControl = async () => {
|
||||
const generation = requestGeneration.current;
|
||||
const controlPath = `/workspaces/${workspaceId}/display/control`;
|
||||
setControlBusy(true);
|
||||
setControlError(null);
|
||||
try {
|
||||
const next = await api.post<DisplayControlStatus>(`${controlPath}/release`, {});
|
||||
if (requestGeneration.current !== generation) return;
|
||||
setControl(next);
|
||||
setSessionUrl(null);
|
||||
} catch (err) {
|
||||
if (requestGeneration.current !== generation) return;
|
||||
setControlError("Failed to release control");
|
||||
try {
|
||||
const latest = await api.get<DisplayControlStatus>(controlPath);
|
||||
if (requestGeneration.current !== generation) return;
|
||||
setControl(latest);
|
||||
} catch {
|
||||
if (requestGeneration.current !== generation) return;
|
||||
setControl(null);
|
||||
}
|
||||
} finally {
|
||||
if (requestGeneration.current === generation) setControlBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-5">
|
||||
@@ -81,16 +167,206 @@ 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>
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
return (
|
||||
<div className="flex h-full min-h-[360px] flex-col bg-surface-sunken/30">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-line/50 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium text-ink">Desktop</h3>
|
||||
<p className="mt-0.5 font-mono text-[10px] text-ink-mid">
|
||||
{status.mode || "desktop-control"} · {status.protocol || "display"}
|
||||
</p>
|
||||
</div>
|
||||
<DisplayControlBar
|
||||
control={control}
|
||||
controlBusy={controlBusy}
|
||||
controlError={controlError}
|
||||
hasSession={!!sessionUrl}
|
||||
onAcquire={acquireControl}
|
||||
onRelease={releaseControl}
|
||||
/>
|
||||
</div>
|
||||
{sessionUrl ? (
|
||||
<DesktopStream sessionUrl={sessionUrl} />
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center p-8 text-center">
|
||||
<div>
|
||||
<h3 className="mb-1.5 text-sm font-medium text-ink">Take control to open the desktop.</h3>
|
||||
<p className="max-w-xs text-[11px] leading-relaxed text-ink-mid">
|
||||
The display service is ready. Control access opens a short-lived desktop stream.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DisplayControlBar({
|
||||
control,
|
||||
controlBusy,
|
||||
controlError,
|
||||
hasSession,
|
||||
onAcquire,
|
||||
onRelease,
|
||||
}: {
|
||||
control: DisplayControlStatus | null;
|
||||
controlBusy: boolean;
|
||||
controlError: string | null;
|
||||
hasSession: boolean;
|
||||
onAcquire: () => void;
|
||||
onRelease: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{control && (
|
||||
<div className="min-w-0 text-right">
|
||||
<p className="truncate text-[11px] font-medium text-ink">
|
||||
{control.controller === "none"
|
||||
? "No active controller"
|
||||
: `Controlled by ${displayControlActorLabel(control)}`}
|
||||
</p>
|
||||
{control.expires_at && (
|
||||
<p className="mt-0.5 truncate font-mono text-[10px] text-ink-mid">
|
||||
Until {new Date(control.expires_at).toLocaleTimeString()}
|
||||
</p>
|
||||
)}
|
||||
{controlError && <p className="mt-0.5 text-[10px] text-red-200">{controlError}</p>}
|
||||
</div>
|
||||
)}
|
||||
{(control?.controller === "none" ||
|
||||
(control?.controller === "user" && control.controlled_by === "admin-token" && !hasSession)) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAcquire}
|
||||
disabled={controlBusy}
|
||||
className="h-8 shrink-0 rounded border border-line bg-surface px-3 text-[11px] font-medium text-ink hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Take control
|
||||
</button>
|
||||
)}
|
||||
{control?.controller === "user" && control.controlled_by === "admin-token" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRelease}
|
||||
disabled={controlBusy}
|
||||
className="h-8 shrink-0 rounded border border-line bg-surface px-3 text-[11px] font-medium text-ink hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Release
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopStream({ sessionUrl }: { sessionUrl: string }) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [streamError, setStreamError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let rfb: RFB | null = null;
|
||||
|
||||
async function connect() {
|
||||
setStreamError(null);
|
||||
try {
|
||||
const mod = await import("@novnc/novnc");
|
||||
if (cancelled || !containerRef.current) return;
|
||||
const stream = displayWebSocketConnection(sessionUrl);
|
||||
rfb = new mod.default(containerRef.current, stream.url, {
|
||||
wsProtocols: ["binary", `molecule-display-token.${stream.token}`],
|
||||
});
|
||||
rfb.scaleViewport = true;
|
||||
rfb.resizeSession = true;
|
||||
rfb.focusOnClick = true;
|
||||
rfb.addEventListener("disconnect", (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ clean?: boolean }>).detail;
|
||||
if (!cancelled && !detail?.clean) setStreamError("Desktop stream disconnected.");
|
||||
});
|
||||
} catch {
|
||||
if (!cancelled) setStreamError("Desktop stream could not be opened.");
|
||||
}
|
||||
}
|
||||
|
||||
connect();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
rfb?.disconnect();
|
||||
};
|
||||
}, [sessionUrl]);
|
||||
|
||||
return (
|
||||
<div className="relative min-h-0 flex-1 bg-black">
|
||||
<div ref={containerRef} title="Workspace desktop" className="h-full w-full overflow-hidden bg-black" />
|
||||
{streamError && (
|
||||
<div className="absolute inset-x-4 top-4 rounded border border-red-500/30 bg-red-950/80 px-3 py-2 text-[11px] text-red-100">
|
||||
{streamError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function displayWebSocketConnection(sessionUrl: string): { url: string; token: string } {
|
||||
const url = new URL(sessionUrl, window.location.href);
|
||||
const token = new URLSearchParams(url.hash.replace(/^#/, "")).get("token") ?? "";
|
||||
if (!token) throw new Error("display session token missing");
|
||||
url.hash = "";
|
||||
url.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return { url: url.toString(), token };
|
||||
}
|
||||
|
||||
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,471 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
interface MemoryEntry {
|
||||
key: string;
|
||||
value: unknown;
|
||||
version?: number;
|
||||
expires_at: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
const AWARENESS_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_AWARENESS_URL || "http://localhost:37800";
|
||||
|
||||
export function MemoryTab({ workspaceId }: Props) {
|
||||
const [entries, setEntries] = useState<MemoryEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAwareness, setShowAwareness] = useState(true);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [newKey, setNewKey] = useState("");
|
||||
const [newValue, setNewValue] = useState("");
|
||||
const [newTTL, setNewTTL] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
const [editTTL, setEditTTL] = useState("");
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
|
||||
const awarenessUrl = useMemo(() => {
|
||||
try {
|
||||
const url = new URL(AWARENESS_BASE_URL);
|
||||
url.searchParams.set("workspaceId", workspaceId);
|
||||
return url.toString();
|
||||
} catch {
|
||||
return AWARENESS_BASE_URL;
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
const awarenessStatus = useMemo(() => {
|
||||
try {
|
||||
const url = new URL(AWARENESS_BASE_URL);
|
||||
return url.origin.includes("localhost") ? "local" : url.hostname;
|
||||
} catch {
|
||||
return "unavailable";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadMemory = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.get<MemoryEntry[]>(`/workspaces/${workspaceId}/memory`);
|
||||
setEntries(data);
|
||||
} catch (e) {
|
||||
setEntries([]);
|
||||
setError(e instanceof Error ? e.message : "Failed to load memory");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMemory();
|
||||
}, [loadMemory]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
setError(null);
|
||||
if (!newKey.trim()) {
|
||||
setError("Key is required");
|
||||
return;
|
||||
}
|
||||
|
||||
let parsedValue: unknown;
|
||||
try {
|
||||
parsedValue = JSON.parse(newValue);
|
||||
} catch {
|
||||
parsedValue = newValue;
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = { key: newKey, value: parsedValue };
|
||||
if (newTTL) {
|
||||
const ttl = parseInt(newTTL);
|
||||
if (!Number.isNaN(ttl) && ttl > 0) body.ttl_seconds = ttl;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post(`/workspaces/${workspaceId}/memory`, body);
|
||||
setNewKey("");
|
||||
setNewValue("");
|
||||
setNewTTL("");
|
||||
setShowAdd(false);
|
||||
loadMemory();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to add");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (key: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
await api.del(`/workspaces/${workspaceId}/memory/${encodeURIComponent(key)}`);
|
||||
setEntries((prev) => prev.filter((e) => e.key !== key));
|
||||
if (expanded === key) setExpanded(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to delete entry");
|
||||
}
|
||||
};
|
||||
|
||||
const beginEdit = (entry: MemoryEntry) => {
|
||||
setEditError(null);
|
||||
setEditingKey(entry.key);
|
||||
// Stringify objects/arrays as pretty JSON; render plain strings raw so the
|
||||
// editor doesn't surprise users with surrounding quotes.
|
||||
setEditValue(
|
||||
typeof entry.value === "string"
|
||||
? entry.value
|
||||
: JSON.stringify(entry.value, null, 2),
|
||||
);
|
||||
if (entry.expires_at) {
|
||||
const remainingMs = new Date(entry.expires_at).getTime() - Date.now();
|
||||
const ttl = Math.max(0, Math.floor(remainingMs / 1000));
|
||||
setEditTTL(ttl > 0 ? String(ttl) : "");
|
||||
} else {
|
||||
setEditTTL("");
|
||||
}
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingKey(null);
|
||||
setEditValue("");
|
||||
setEditTTL("");
|
||||
setEditError(null);
|
||||
};
|
||||
|
||||
const handleEditSave = async (entry: MemoryEntry) => {
|
||||
setEditError(null);
|
||||
|
||||
let parsedValue: unknown;
|
||||
try {
|
||||
parsedValue = JSON.parse(editValue);
|
||||
} catch {
|
||||
parsedValue = editValue;
|
||||
}
|
||||
|
||||
// if_match_version closes the silent-overwrite hole when two writers
|
||||
// race. The handler returns 409 with the current version on mismatch
|
||||
// — surface that as a retry hint and reload to pick up the new state.
|
||||
const body: Record<string, unknown> = { key: entry.key, value: parsedValue };
|
||||
if (typeof entry.version === "number") {
|
||||
body.if_match_version = entry.version;
|
||||
}
|
||||
if (editTTL) {
|
||||
const ttl = parseInt(editTTL);
|
||||
if (!Number.isNaN(ttl) && ttl > 0) body.ttl_seconds = ttl;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post(`/workspaces/${workspaceId}/memory`, body);
|
||||
cancelEdit();
|
||||
loadMemory();
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "Failed to save";
|
||||
if (message.includes("409") || /if_match_version mismatch/i.test(message)) {
|
||||
setEditError("This entry changed since you opened it. Reloading.");
|
||||
loadMemory();
|
||||
} else {
|
||||
setEditError(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openAwareness = () => {
|
||||
window.open(awarenessUrl, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-4 text-xs text-ink-mid">Loading memory...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
{error && !showAdd && (
|
||||
<div role="alert" className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-ink">Awareness dashboard</div>
|
||||
<p className="text-[10px] text-ink-mid">
|
||||
Embedded view for the local Awareness memory UI. The current workspace id is appended to the URL for workspace-scoped routing or future filtering.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAwareness((prev) => !prev)}
|
||||
className="shrink-0 px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
{showAwareness ? "Collapse" : "Expand"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAwareness}
|
||||
className="shrink-0 px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAwareness ? (
|
||||
AWARENESS_BASE_URL ? (
|
||||
<div className="overflow-hidden rounded-xl border border-line bg-surface-sunken/70 shadow-[0_0_0_1px_rgba(255,255,255,0.02)]">
|
||||
<iframe
|
||||
title="Awareness dashboard"
|
||||
src={awarenessUrl}
|
||||
className="h-[520px] w-full border-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-line bg-surface-sunken/40 p-4 text-xs text-ink-mid">
|
||||
Set <code className="font-mono text-ink-mid">NEXT_PUBLIC_AWARENESS_URL</code> to embed the Awareness dashboard here.
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="rounded-xl border border-line bg-surface-sunken/50 px-4 py-3 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-ink">Awareness dashboard is collapsed</p>
|
||||
<p className="text-[10px] text-ink-mid truncate">
|
||||
Workspace context stays linked through <span className="font-mono text-ink-mid">{workspaceId}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAwareness(true)}
|
||||
className="shrink-0 px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Expand
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2 rounded-xl border border-line bg-surface/40 px-3 py-2 text-[10px] text-ink-mid sm:grid-cols-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="uppercase tracking-[0.18em] text-ink-mid">Status</span>
|
||||
<span className="font-medium text-good">Connected</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="uppercase tracking-[0.18em] text-ink-mid">Mode</span>
|
||||
<span className="font-medium text-ink">{awarenessStatus}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 min-w-0">
|
||||
<span className="uppercase tracking-[0.18em] text-ink-mid">Workspace</span>
|
||||
<span className="font-mono text-ink-mid truncate">{workspaceId}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 border-t border-line/60 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-ink">Workspace KV memory</div>
|
||||
<p className="text-[10px] text-ink-mid">
|
||||
Native platform key-value memory for workspace <span className="font-mono text-ink-mid">{workspaceId}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced((prev) => !prev)}
|
||||
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
{showAdvanced ? "Hide Advanced" : "Advanced"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadMemory}
|
||||
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowAdd(!showAdd); if (!showAdd) setShowAdvanced(true); }}
|
||||
className="px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAdvanced && showAdd && (
|
||||
<div className="bg-surface-card rounded p-3 space-y-2 border border-line">
|
||||
<input
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
placeholder="Key"
|
||||
aria-label="Memory key"
|
||||
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-xs text-ink focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<textarea
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
placeholder='Value (JSON or plain text)'
|
||||
rows={3}
|
||||
aria-label="Memory value (JSON or plain text)"
|
||||
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-xs font-mono text-ink focus:outline-none focus:border-accent resize-none"
|
||||
/>
|
||||
<input
|
||||
value={newTTL}
|
||||
onChange={(e) => setNewTTL(e.target.value)}
|
||||
placeholder="TTL in seconds (optional)"
|
||||
aria-label="TTL in seconds (optional)"
|
||||
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-xs text-ink focus:outline-none focus:border-accent"
|
||||
/>
|
||||
{error && <div role="alert" className="text-xs text-bad">{error}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
className="px-3 py-1 bg-accent hover:bg-accent-strong text-xs rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowAdd(false);
|
||||
setError(null);
|
||||
}}
|
||||
className="px-3 py-1 bg-surface-card hover:bg-surface-elevated text-xs rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdvanced ? (
|
||||
entries.length === 0 ? (
|
||||
<p className="text-xs text-ink-mid text-center py-4">No memory entries</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.key} className="bg-surface-card rounded border border-line">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(expanded === entry.key ? null : entry.key)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
aria-expanded={expanded === entry.key}
|
||||
>
|
||||
<span className="text-xs font-mono text-accent">{entry.key}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{entry.expires_at && (
|
||||
<span className="text-[9px] text-ink-mid">
|
||||
TTL {new Date(entry.expires_at).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-ink-mid">
|
||||
{expanded === entry.key ? "▼" : "▶"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded === entry.key && (
|
||||
<div className="px-3 pb-2 space-y-2">
|
||||
{editingKey === entry.key ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
rows={4}
|
||||
aria-label={`Edit value for ${entry.key}`}
|
||||
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-xs font-mono text-ink focus:outline-none focus:border-accent resize-none"
|
||||
/>
|
||||
<input
|
||||
value={editTTL}
|
||||
onChange={(e) => setEditTTL(e.target.value)}
|
||||
placeholder="TTL in seconds (blank = no expiry)"
|
||||
aria-label={`Edit TTL for ${entry.key}`}
|
||||
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-xs text-ink focus:outline-none focus:border-accent"
|
||||
/>
|
||||
{editError && (
|
||||
<div role="alert" className="text-[10px] text-bad">
|
||||
{editError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleEditSave(entry)}
|
||||
className="px-3 py-1 bg-accent hover:bg-accent-strong text-xs rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelEdit}
|
||||
className="px-3 py-1 bg-surface-card hover:bg-surface-elevated text-xs rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="text-[10px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-40">
|
||||
{JSON.stringify(entry.value, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[9px] text-ink-mid">
|
||||
Updated: {new Date(entry.updated_at).toLocaleString()}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{editingKey !== entry.key && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => beginEdit(entry)}
|
||||
className="text-[10px] text-ink-mid hover:bg-surface-elevated rounded px-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(entry.key)}
|
||||
className="text-[10px] text-bad hover:bg-red-950/40 rounded px-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="rounded-xl border border-line bg-surface/30 px-4 py-3 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-ink">Advanced workspace memory is hidden</p>
|
||||
<p className="text-[10px] text-ink-mid truncate">
|
||||
KV entries remain available if you need the raw platform store.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(true)}
|
||||
className="shrink-0 px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Show
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,36 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
const { mockGet } = vi.hoisted(() => ({ mockGet: vi.fn() }));
|
||||
const { mockGet, mockPost, mockRFBConstructor } = vi.hoisted(() => ({
|
||||
mockGet: vi.fn(),
|
||||
mockPost: vi.fn(),
|
||||
mockRFBConstructor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: mockGet,
|
||||
post: mockPost,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@novnc/novnc", () => ({
|
||||
default: class MockRFB extends EventTarget {
|
||||
scaleViewport = false;
|
||||
resizeSession = false;
|
||||
focusOnClick = false;
|
||||
target: HTMLElement;
|
||||
url: string;
|
||||
options?: { wsProtocols?: string[] };
|
||||
constructor(target: HTMLElement, url: string, options?: { wsProtocols?: string[] }) {
|
||||
super();
|
||||
this.target = target;
|
||||
this.url = url;
|
||||
this.options = options;
|
||||
mockRFBConstructor(target, url, options);
|
||||
}
|
||||
disconnect() {}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -14,7 +38,10 @@ import { DisplayTab } from "../DisplayTab";
|
||||
|
||||
describe("DisplayTab", () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
mockRFBConstructor.mockReset();
|
||||
});
|
||||
|
||||
it("renders unavailable state for non-display workspaces", async () => {
|
||||
@@ -29,5 +56,311 @@ 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("waits for takeover before opening a ready display stream", async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
available: true,
|
||||
mode: "desktop-control",
|
||||
protocol: "novnc",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
controller: "none",
|
||||
});
|
||||
|
||||
render(<DisplayTab workspaceId="ws-display" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Take control to open the desktop.")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens the trusted noVNC client after takeover returns a stream URL", async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
available: true,
|
||||
mode: "desktop-control",
|
||||
protocol: "novnc",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
controller: "none",
|
||||
});
|
||||
mockPost.mockResolvedValueOnce({
|
||||
controller: "user",
|
||||
controlled_by: "admin-token",
|
||||
expires_at: "2026-05-23T08:48:27Z",
|
||||
session_url: "/workspaces/ws-display/display/session/websockify#token=signed",
|
||||
});
|
||||
|
||||
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.getByTitle("Workspace desktop")).toBeTruthy();
|
||||
});
|
||||
expect(mockPost).toHaveBeenCalledWith("/workspaces/ws-display/display/control/acquire", {
|
||||
controller: "user",
|
||||
ttl_seconds: 300,
|
||||
});
|
||||
expect(mockRFBConstructor).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.stringContaining("/workspaces/ws-display/display/session/websockify"),
|
||||
{ wsProtocols: ["binary", "molecule-display-token.signed"] },
|
||||
);
|
||||
expect(mockRFBConstructor.mock.calls[0][1]).not.toContain("token=");
|
||||
});
|
||||
|
||||
it("releases user display control", async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
available: true,
|
||||
mode: "desktop-control",
|
||||
protocol: "novnc",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
controller: "user",
|
||||
controlled_by: "admin-token",
|
||||
expires_at: "2026-05-23T08:48:27Z",
|
||||
});
|
||||
mockPost.mockResolvedValueOnce({
|
||||
controller: "none",
|
||||
});
|
||||
|
||||
render(<DisplayTab workspaceId="ws-display" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Release" })).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Release" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
|
||||
});
|
||||
expect(mockPost).toHaveBeenCalledWith("/workspaces/ws-display/display/control/release", {});
|
||||
});
|
||||
|
||||
it("renders active display control locks as observe-only", async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -1,632 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for MemoryTab — awareness dashboard + workspace KV memory management.
|
||||
*
|
||||
* Coverage:
|
||||
* - Loading state
|
||||
* - Error state when GET /memory fails
|
||||
* - Empty state (no memory entries)
|
||||
* - Memory list rendering (single + multiple entries)
|
||||
* - Expand/collapse memory entries
|
||||
* - Add memory entry (key + value + TTL)
|
||||
* - Add validates required key
|
||||
* - Add parses JSON values
|
||||
* - Delete memory entry
|
||||
* - Edit memory entry (inline)
|
||||
* - Edit 409 conflict shows retry hint
|
||||
* - Advanced toggle shows/hides KV section
|
||||
* - Awareness dashboard expand/collapse
|
||||
* - Awareness URL includes workspaceId
|
||||
* - Refresh button reloads memory
|
||||
* - Error clears when appropriate actions are taken
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MemoryTab } from "../MemoryTab";
|
||||
|
||||
const mockGet = vi.hoisted(() => vi.fn<[], Promise<unknown[]>>());
|
||||
const mockPost = vi.hoisted(() => vi.fn<[], Promise<unknown>>());
|
||||
const mockDel = vi.hoisted(() => vi.fn<[], Promise<unknown>>());
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { get: mockGet, post: mockPost, del: mockDel },
|
||||
}));
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const MEMORY_ENTRY = {
|
||||
key: "user_context",
|
||||
value: { name: "Alice", role: "engineer" },
|
||||
version: 3,
|
||||
expires_at: null,
|
||||
updated_at: new Date(Date.now() - 60000).toISOString(),
|
||||
};
|
||||
|
||||
function entry(overrides: Partial<typeof MEMORY_ENTRY> = {}): typeof MEMORY_ENTRY {
|
||||
return { ...MEMORY_ENTRY, ...overrides };
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function flush() {
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
}
|
||||
|
||||
function typeIn(el: HTMLElement, value: string) {
|
||||
Object.defineProperty(el, "value", { value, writable: true, configurable: true });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fireEvent.change(el as any, { target: el });
|
||||
}
|
||||
|
||||
// ─── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MemoryTab", () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
mockDel.mockReset();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ── Loading / Error ──────────────────────────────────────────────────────────
|
||||
|
||||
it("shows loading state when memory is being fetched", async () => {
|
||||
mockGet.mockImplementation(() => new Promise(() => {}));
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await act(async () => { /* flush initial render */ });
|
||||
expect(screen.getByText("Loading memory...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows error banner when GET /memory rejects", async () => {
|
||||
mockGet.mockRejectedValue(new Error("network failure"));
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText(/network failure/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows 'Failed to load memory' when GET rejects with non-Error", async () => {
|
||||
mockGet.mockRejectedValue("unknown error");
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText(/Failed to load memory/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── Awareness Dashboard ─────────────────────────────────────────────────────
|
||||
|
||||
it("shows Awareness dashboard section", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText("Awareness dashboard")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders an iframe with workspaceId in URL", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-xyz" />);
|
||||
await flush();
|
||||
const iframe = screen.getByTitle("Awareness dashboard");
|
||||
expect(iframe.getAttribute("src")).toContain("workspaceId=ws-xyz");
|
||||
});
|
||||
|
||||
it("shows 'Connected' status", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText("Connected")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows workspace ID in the status grid", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-test-id" />);
|
||||
await flush();
|
||||
// workspaceId appears in two places (description + status grid).
|
||||
// Target the font-mono span in the status grid specifically.
|
||||
const spans = Array.from(document.querySelectorAll("span.font-mono"));
|
||||
expect(spans.some(s => s.textContent === "ws-test-id")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows 'Collapse' and 'Open' buttons for awareness (starts visible)", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByRole("button", { name: /collapse/i })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /open/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides awareness iframe when Collapse is clicked", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /collapse/i }));
|
||||
await flush();
|
||||
expect(screen.queryByTitle("Awareness dashboard")).toBeNull();
|
||||
expect(screen.getByText(/awareness dashboard is collapsed/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("re-shows awareness iframe when collapsed state Expand is clicked", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
// Start with awareness visible (default) — verify iframe is there
|
||||
expect(screen.getByTitle("Awareness dashboard")).toBeTruthy();
|
||||
// Click Collapse in the awareness header to hide the iframe
|
||||
fireEvent.click(screen.getByRole("button", { name: /collapse/i }));
|
||||
await flush();
|
||||
expect(screen.queryByTitle("Awareness dashboard")).toBeNull();
|
||||
// The collapsed awareness state has a different "Expand" button.
|
||||
// Directly click the button whose text is exactly "Expand".
|
||||
const allBtns = screen.getAllByRole("button");
|
||||
const expandInCollapsed = allBtns.find(b => b.textContent?.trim() === "Expand");
|
||||
expect(expandInCollapsed).toBeTruthy();
|
||||
act(() => { expandInCollapsed!.click(); });
|
||||
await flush();
|
||||
expect(screen.getByTitle("Awareness dashboard")).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── KV Memory: Empty / Advanced toggle ───────────────────────────────────────
|
||||
|
||||
it("shows 'Advanced workspace memory is hidden' when advanced is collapsed", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText(/advanced workspace memory is hidden/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows 'Show' button when advanced is collapsed", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByRole("button", { name: /show/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows 'Hide Advanced' after clicking Show", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
expect(screen.getByRole("button", { name: /hide advanced/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows empty state 'No memory entries' when advanced is shown and list is empty", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
expect(screen.getByText("No memory entries")).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── KV Memory: List rendering ───────────────────────────────────────────────
|
||||
|
||||
it("renders memory entries when advanced is open", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
expect(screen.getByText("user_context")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders multiple memory entries", async () => {
|
||||
mockGet.mockResolvedValue([
|
||||
entry({ key: "key1", value: "value1" }),
|
||||
entry({ key: "key2", value: "value2" }),
|
||||
]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
expect(screen.getByText("key1")).toBeTruthy();
|
||||
expect(screen.getByText("key2")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows chevron pointing right when entry is collapsed", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
expect(screen.getByText("▶")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows chevron pointing down when entry is expanded", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
expect(screen.getByText("▼")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows entry value when expanded", async () => {
|
||||
mockGet.mockResolvedValue([entry({ value: { foo: "bar" } })]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
expect(screen.getByText(/"foo": "bar"/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows updated_at timestamp when entry is expanded", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
expect(screen.getByText(/updated:/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows Edit and Delete buttons when entry is expanded", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
expect(screen.getByRole("button", { name: /edit/i })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /delete/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows TTL when entry has expires_at", async () => {
|
||||
const future = new Date(Date.now() + 3600000).toISOString();
|
||||
mockGet.mockResolvedValue([entry({ expires_at: future })]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
expect(screen.getByText(/ttl/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── Add Memory Entry ─────────────────────────────────────────────────────────
|
||||
|
||||
it("shows + Add button in KV section", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
expect(screen.getByRole("button", { name: /\+ add/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens add form when + Add is clicked", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
await flush();
|
||||
expect(screen.getByLabelText("Memory key")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Memory value (JSON or plain text)")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("requires key to be non-empty", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
|
||||
await flush();
|
||||
expect(screen.getByText(/key is required/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("POSTs correct payload when adding a string value", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
mockPost.mockResolvedValue({});
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
await flush();
|
||||
typeIn(screen.getByLabelText("Memory key") as HTMLElement, "my_key");
|
||||
typeIn(screen.getByLabelText("Memory value (JSON or plain text)") as HTMLElement, "plain text value");
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
|
||||
await flush();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText("Memory key")).not.toBeTruthy();
|
||||
});
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memory",
|
||||
expect.objectContaining({ key: "my_key", value: "plain text value" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("POSTs parsed JSON when value is valid JSON", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
mockPost.mockResolvedValue({});
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
await flush();
|
||||
typeIn(screen.getByLabelText("Memory key") as HTMLElement, "config");
|
||||
typeIn(screen.getByLabelText("Memory value (JSON or plain text)") as HTMLElement, '{"debug": true}');
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
|
||||
await flush();
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memory",
|
||||
expect.objectContaining({ key: "config", value: { debug: true } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("POSTs with ttl_seconds when TTL is provided", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
mockPost.mockResolvedValue({});
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
await flush();
|
||||
typeIn(screen.getByLabelText("Memory key") as HTMLElement, "temp_data");
|
||||
typeIn(screen.getByLabelText("Memory value (JSON or plain text)") as HTMLElement, "value");
|
||||
typeIn(screen.getByLabelText("TTL in seconds (optional)") as HTMLElement, "3600");
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
|
||||
await flush();
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memory",
|
||||
expect.objectContaining({ key: "temp_data", value: "value", ttl_seconds: 3600 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows error when add fails", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
mockPost.mockRejectedValue(new Error("add failed"));
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
await flush();
|
||||
typeIn(screen.getByLabelText("Memory key") as HTMLElement, "key");
|
||||
typeIn(screen.getByLabelText("Memory value (JSON or plain text)") as HTMLElement, "val");
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
|
||||
await flush();
|
||||
expect(screen.getByText(/add failed/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes add form and refreshes after successful add", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
mockPost.mockResolvedValue({});
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
await flush();
|
||||
typeIn(screen.getByLabelText("Memory key") as HTMLElement, "new_key");
|
||||
typeIn(screen.getByLabelText("Memory value (JSON or plain text)") as HTMLElement, "new_val");
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
|
||||
await flush();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText("Memory key")).not.toBeTruthy();
|
||||
});
|
||||
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/memory");
|
||||
});
|
||||
|
||||
it("closes add form when Cancel is clicked", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
await flush();
|
||||
expect(screen.getByLabelText("Memory key")).toBeTruthy();
|
||||
act(() => { screen.getByRole("button", { name: /cancel/i }).click(); });
|
||||
await flush();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText("Memory key")).not.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Delete Memory Entry ─────────────────────────────────────────────────────
|
||||
|
||||
it("calls DEL when Delete is clicked", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
mockDel.mockResolvedValue({});
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
|
||||
await flush();
|
||||
expect(mockDel).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memory/user_context",
|
||||
);
|
||||
});
|
||||
|
||||
it("removes entry from list after successful delete", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
mockDel.mockResolvedValue({});
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
expect(screen.getByText("user_context")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
|
||||
await flush();
|
||||
expect(screen.queryByText("user_context")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("collapses entry if it was expanded when deleted", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
mockDel.mockResolvedValue({});
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
// Expand the entry
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
expect(screen.getByText("▼")).toBeTruthy();
|
||||
// Delete
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
|
||||
await flush();
|
||||
expect(screen.queryByText("user_context")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("shows error when delete fails", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
mockDel.mockRejectedValue(new Error("delete failed"));
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
|
||||
await flush();
|
||||
expect(screen.getByText(/delete failed/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── Edit Memory Entry ────────────────────────────────────────────────────────
|
||||
|
||||
it("shows edit form when Edit is clicked", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
await flush();
|
||||
expect(screen.getByLabelText(/edit value for user_context/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("pre-fills edit form with existing value", async () => {
|
||||
mockGet.mockResolvedValue([entry({ value: { name: "Alice" } })]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
await flush();
|
||||
const textarea = screen.getByLabelText(/edit value for user_context/i);
|
||||
expect((textarea as HTMLTextAreaElement).value).toContain("Alice");
|
||||
});
|
||||
|
||||
it("POSTs updated value when Save is clicked", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
mockPost.mockResolvedValue({});
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
await flush();
|
||||
typeIn(screen.getByLabelText(/edit value for user_context/i) as HTMLElement, "updated_value");
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
|
||||
await flush();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText(/edit value for user_context/i)).not.toBeTruthy();
|
||||
});
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memory",
|
||||
expect.objectContaining({ key: "user_context", value: "updated_value", if_match_version: 3 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows retry hint on 409 conflict during edit", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
mockPost.mockRejectedValue(new Error("409 Conflict: if_match_version mismatch"));
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
await flush();
|
||||
typeIn(screen.getByLabelText(/edit value for user_context/i) as HTMLElement, "new_val");
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
|
||||
await flush();
|
||||
expect(screen.getByText(/this entry changed since you opened it/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows generic error when edit save fails", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
mockPost.mockRejectedValue(new Error("save failed"));
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
await flush();
|
||||
typeIn(screen.getByLabelText(/edit value for user_context/i) as HTMLElement, "x");
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
|
||||
await flush();
|
||||
expect(screen.getByText(/save failed/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes edit form when Cancel is clicked", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
await flush();
|
||||
expect(screen.getByLabelText(/edit value for user_context/i)).toBeTruthy();
|
||||
act(() => { screen.getByRole("button", { name: /cancel/i }).click(); });
|
||||
await flush();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText(/edit value for/i)).not.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Refresh ────────────────────────────────────────────────────────────────
|
||||
|
||||
it("Refresh button calls loadMemory", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
mockGet.mockClear();
|
||||
fireEvent.click(screen.getByRole("button", { name: /refresh/i }));
|
||||
await flush();
|
||||
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/memory");
|
||||
});
|
||||
|
||||
});
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
declare module "@novnc/novnc" {
|
||||
export default class RFB extends EventTarget {
|
||||
scaleViewport: boolean;
|
||||
resizeSession: boolean;
|
||||
focusOnClick: boolean;
|
||||
constructor(target: HTMLElement, url: string, options?: { wsProtocols?: string[]; [key: string]: unknown });
|
||||
disconnect(): void;
|
||||
}
|
||||
}
|
||||
+1
-3
@@ -28,9 +28,7 @@
|
||||
{"name": "claude-code-default", "repo": "molecule-ai/molecule-ai-workspace-template-claude-code", "ref": "main"},
|
||||
{"name": "hermes", "repo": "molecule-ai/molecule-ai-workspace-template-hermes", "ref": "main"},
|
||||
{"name": "openclaw", "repo": "molecule-ai/molecule-ai-workspace-template-openclaw", "ref": "main"},
|
||||
{"name": "codex", "repo": "molecule-ai/molecule-ai-workspace-template-codex", "ref": "main"},
|
||||
{"name": "langgraph", "repo": "molecule-ai/molecule-ai-workspace-template-langgraph", "ref": "main"},
|
||||
{"name": "autogen", "repo": "molecule-ai/molecule-ai-workspace-template-autogen", "ref": "main"}
|
||||
{"name": "codex", "repo": "molecule-ai/molecule-ai-workspace-template-codex", "ref": "main"}
|
||||
],
|
||||
"org_templates": [
|
||||
{"name": "molecule-dev", "repo": "molecule-ai/molecule-ai-org-template-molecule-dev", "ref": "main"},
|
||||
|
||||
@@ -50,13 +50,16 @@ 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","tier":3}' \
|
||||
-d '{"name":"Root Agent","role":"Company coordinator","runtime":"claude-code","model":"sonnet","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\",\"tier\":2,\"parent_id\":\"$ROOT\"}" \
|
||||
-d "{\"name\":\"Child Agent\",\"role\":\"Sub-team member\",\"runtime\":\"claude-code\",\"model\":\"sonnet\",\"tier\":2,\"parent_id\":\"$ROOT\"}" \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
check_contains "Create child workspace" "-" "$CHILD"
|
||||
|
||||
|
||||
@@ -92,8 +92,12 @@ 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}')
|
||||
-d '{"name":"Notify E2E","tier":1,"model":"anthropic:claude-opus-4-7"}')
|
||||
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,14 +24,12 @@
|
||||
#
|
||||
# Only PROVISIONING differs from staging:
|
||||
# - staging: POST /cp/admin/orgs (cold EC2 tenant) + per-tenant admin
|
||||
# token + each workspace's MCP bearer from create response or an admin
|
||||
# token-mint fallback.
|
||||
# token + each workspace's MCP bearer from the POST /workspaces
|
||||
# create response.
|
||||
# - local: POST /workspaces directly against the local stack
|
||||
# (BASE, default http://localhost:8080), MCP bearer minted via
|
||||
# GET /admin/workspaces/:id/test-token (e2e_mint_test_token —
|
||||
# deterministic, gated by MOLECULE_ENV != production). Same model
|
||||
# every other local E2E (test_priority_runtimes_e2e.sh,
|
||||
# test_api.sh) already uses; no new credential/provision flow.
|
||||
# (BASE, default http://localhost:8080), MCP bearer consumed inline
|
||||
# from the create response (auth_token field). Same model every
|
||||
# other local E2E uses; no new credential/provision flow.
|
||||
#
|
||||
# By default the local backend creates external-mode workspace rows and
|
||||
# drives the literal MCP path directly. That keeps the local peer-visibility
|
||||
@@ -81,6 +79,17 @@ NAME_PREFIX="PV-Local-$$-$(date +%H%M%S)"
|
||||
log() { echo "[$(date +%H:%M:%S)] $*"; }
|
||||
ok() { echo "[$(date +%H:%M:%S)] ✅ $*"; }
|
||||
|
||||
extract_auth_token() {
|
||||
python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
print(''); sys.exit(0)
|
||||
print(d.get('auth_token') or d.get('connection', {}).get('auth_token') or '')
|
||||
" 2>/dev/null
|
||||
}
|
||||
|
||||
CREATED_WSIDS=()
|
||||
ADMIN_BEARER="${MOLECULE_ADMIN_TOKEN:-${ADMIN_TOKEN:-}}"
|
||||
ADMIN_AUTH=()
|
||||
@@ -131,17 +140,6 @@ if ! curl -fsS "$BASE/health" -m 5 >/dev/null 2>&1; then
|
||||
echo "::error::Local stack not healthy at $BASE/health — bring it up (make up) before this gate. Infra, not a workspace bug (feedback_fix_root_not_symptom)." >&2
|
||||
exit 1
|
||||
fi
|
||||
# admin/test-token is the local MCP-bearer mint path; it 404s in
|
||||
# production. If it is off, this gate cannot drive the literal call.
|
||||
if ! curl -fsS "$BASE/admin/workspaces/preflight-probe/test-token" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} -m 5 >/dev/null 2>&1; then
|
||||
# A 404 here is EITHER "no such ws" (fine — endpoint is enabled) OR the
|
||||
# endpoint is disabled (MOLECULE_ENV=production). Distinguish by body.
|
||||
PROBE=$(curl -s "$BASE/admin/workspaces/preflight-probe/test-token" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} -m 5 2>/dev/null)
|
||||
if echo "$PROBE" | grep -qi 'production\|disabled\|not found.*endpoint'; then
|
||||
echo "::error::GET /admin/workspaces/:id/test-token disabled (MOLECULE_ENV=production?). Cannot mint a local MCP bearer." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
ok " local stack healthy"
|
||||
|
||||
# ─── Resolve per-runtime provisioning secrets ──────────────────────────
|
||||
@@ -241,9 +239,31 @@ 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\",\"tier\":3$PARENT_EXTRA,\"secrets\":$PARENT_SECRETS}")
|
||||
-d "{\"name\":\"${NAME_PREFIX}-parent\",\"runtime\":\"$PARENT_RUNTIME\",\"model\":\"$PARENT_MODEL\",\"tier\":3$PARENT_EXTRA,\"secrets\":$PARENT_SECRETS}")
|
||||
PARENT_ID=$(echo "$P_RESP" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))' 2>/dev/null)
|
||||
# PARENT_TOKEN captured for symmetry with the per-sibling auth-token
|
||||
# capture in the runtime loop below + reserved for follow-up steps
|
||||
# that need parent-side auth. Current downstream steps reach the parent
|
||||
# via admin token, so the variable isn't dereferenced — SC2034.
|
||||
# shellcheck disable=SC2034 # captured for downstream parent-auth use; see #1644 follow-up
|
||||
PARENT_TOKEN=$(echo "$P_RESP" | extract_auth_token)
|
||||
if [ -z "$PARENT_ID" ]; then
|
||||
echo "::error::parent create failed: $(echo "$P_RESP" | head -c 300)" >&2
|
||||
exit 1
|
||||
@@ -259,6 +279,8 @@ log " PARENT_ID=$PARENT_ID runtime=$PARENT_RUNTIME"
|
||||
WS_IDS_MAP=""
|
||||
# shellcheck disable=SC2034 # map values are updated through portable eval-based helpers.
|
||||
VERDICT_MAP=""
|
||||
# shellcheck disable=SC2034 # map values are updated through portable eval-based helpers.
|
||||
WS_TOKENS_MAP=""
|
||||
_map_set() { # _map_set <mapvarname> <key> <value>
|
||||
local __m="$1" __k="$2" __v="$3" __cur
|
||||
eval "__cur=\$$__m"
|
||||
@@ -291,14 +313,21 @@ 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\",\"tier\":2,\"parent_id\":\"$PARENT_ID\"$CREATE_EXTRA,\"secrets\":$SEC}")
|
||||
-d "{\"name\":\"${NAME_PREFIX}-$rt\",\"runtime\":\"$CREATE_RUNTIME\",\"model\":\"$CREATE_MODEL\",\"tier\":2,\"parent_id\":\"$PARENT_ID\"$CREATE_EXTRA,\"secrets\":$SEC}")
|
||||
WID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))' 2>/dev/null)
|
||||
WTOK=$(echo "$R" | extract_auth_token)
|
||||
if [ -z "$WID" ]; then
|
||||
echo "::error::$rt workspace create failed: $(echo "$R" | head -c 300)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$WTOK" ]; then
|
||||
echo "::error::$rt workspace create did not return an auth_token — cannot drive the literal MCP call" >&2
|
||||
exit 1
|
||||
fi
|
||||
_map_set WS_IDS_MAP "$rt" "$WID"
|
||||
_map_set WS_TOKENS_MAP "$rt" "$WTOK"
|
||||
CREATED_WSIDS+=("$WID")
|
||||
ALL_WS_IDS="$ALL_WS_IDS $WID"
|
||||
ACTIVE_RUNTIMES="$ACTIVE_RUNTIMES $rt"
|
||||
@@ -356,10 +385,10 @@ log "4/5 driving the LITERAL list_peers MCP call per online runtime..."
|
||||
echo ""
|
||||
for rt in $ONLINE_RUNTIMES; do
|
||||
wid="$(_map_get WS_IDS_MAP "$rt")"
|
||||
WTOK=$(e2e_mint_test_token "$wid" 2>/dev/null || true)
|
||||
WTOK="$(_map_get WS_TOKENS_MAP "$rt")"
|
||||
if [ -z "$WTOK" ]; then
|
||||
echo "--- $rt (ws=$wid) ---"
|
||||
echo " ✗ $rt: could not mint a local MCP bearer (admin/test-token) — cannot drive the literal call"
|
||||
echo " ✗ $rt: workspace create did not return an auth_token — cannot drive the literal call"
|
||||
_map_set VERDICT_MAP "$rt" "FAIL(no-bearer)"
|
||||
REGRESSED=1
|
||||
echo ""
|
||||
|
||||
@@ -40,10 +40,10 @@
|
||||
# drives: POST /cp/admin/orgs (provision), GET
|
||||
# /cp/admin/orgs/:slug/admin-token (per-tenant token), DELETE
|
||||
# /cp/admin/tenants/:slug (teardown). The per-tenant admin token drives
|
||||
# tenant workspace creation; each workspace's OWN auth_token drives its
|
||||
# MCP call. External-like runtimes may return the token in POST
|
||||
# /workspaces; managed container runtimes usually require the admin token
|
||||
# mint fallback below.
|
||||
# tenant workspace creation; each workspace's OWN auth_token is consumed
|
||||
# inline from the POST /workspaces 201 response to drive its MCP call.
|
||||
# No dev-only admin token-mint routes are used in this E2E
|
||||
# (feedback_no_dev_only_routes_in_e2e).
|
||||
#
|
||||
# Required env:
|
||||
# MOLECULE_ADMIN_TOKEN CP admin bearer — Railway staging CP_ADMIN_API_TOKEN
|
||||
@@ -265,44 +265,19 @@ log " PARENT_ID=$PARENT_ID"
|
||||
# WS_IDS[runtime]=id ; WS_TOKENS[runtime]=auth_token (the MCP bearer)
|
||||
declare -A WS_IDS WS_TOKENS
|
||||
ALL_WS_IDS="$PARENT_ID"
|
||||
TOKEN_ERRORS=0
|
||||
TOKEN_ERROR_SUMMARY=""
|
||||
for rt in $PV_RUNTIMES; do
|
||||
R=$(tenant_call POST /workspaces \
|
||||
-d "{\"name\":\"pv-$rt\",\"runtime\":\"$rt\",\"tier\":2,\"parent_id\":\"$PARENT_ID\",\"secrets\":$SECRETS_JSON}")
|
||||
WID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
|
||||
# External-like runtimes may return connection.auth_token on create.
|
||||
# Managed container runtimes usually return only id/status here, then
|
||||
# receive their bearer through registry/bootstrap; for this literal MCP
|
||||
# driver we mint through the production-safe admin token route below.
|
||||
WTOK=$(echo "$R" | extract_auth_token)
|
||||
[ -n "$WID" ] || fail "$rt workspace create failed: $(echo "$R" | head -c 300)"
|
||||
TOKEN_DIAG=""
|
||||
if [ -z "$WTOK" ]; then
|
||||
TTOK_FILE=$(mktemp)
|
||||
TTOK_CODE=$(tenant_call_capture POST "/admin/workspaces/$WID/tokens" "$TTOK_FILE" 2>/dev/null || echo "curl_error")
|
||||
TTOK_RESP=$(cat "$TTOK_FILE" 2>/dev/null || true)
|
||||
WTOK=$(echo "$TTOK_RESP" | extract_auth_token)
|
||||
TOKEN_DIAG="POST /admin/workspaces/$WID/tokens -> HTTP $TTOK_CODE body: $(echo "$TTOK_RESP" | redact_token_body)"
|
||||
rm -f "$TTOK_FILE"
|
||||
fi
|
||||
[ -n "$WID" ] || fail "$rt workspace create failed: $(echo \"$R\" | head -c 300)"
|
||||
[ -n "$WTOK" ] || fail "$rt workspace create did not return an auth_token — cannot drive its MCP call (workspace_id=$WID; create_resp: $(echo \"$R\" | redact_token_body))"
|
||||
WS_IDS[$rt]="$WID"
|
||||
if [ -z "$WTOK" ]; then
|
||||
TOKEN_ERRORS=$((TOKEN_ERRORS + 1))
|
||||
TOKEN_ERROR_SUMMARY="${TOKEN_ERROR_SUMMARY}
|
||||
[$rt] workspace did not return or mint an auth_token — cannot drive its MCP call (workspace_id=$WID; create_resp: $(echo "$R" | redact_token_body); token_fallbacks: $TOKEN_DIAG)"
|
||||
log " $rt → $WID (token acquisition failed; continuing to classify other runtimes)"
|
||||
continue
|
||||
fi
|
||||
WS_TOKENS[$rt]="$WTOK"
|
||||
ALL_WS_IDS="$ALL_WS_IDS $WID"
|
||||
log " $rt → $WID"
|
||||
done
|
||||
|
||||
if [ "$TOKEN_ERRORS" -gt 0 ]; then
|
||||
fail "token acquisition failed for $TOKEN_ERRORS runtime(s):$TOKEN_ERROR_SUMMARY"
|
||||
fi
|
||||
|
||||
if [ "${PV_TOKEN_DIAGNOSTIC_ONLY:-0}" = "1" ]; then
|
||||
ok "token diagnostic passed for runtimes: $PV_RUNTIMES"
|
||||
exit 0
|
||||
|
||||
@@ -188,8 +188,9 @@ 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\",\"tier\":1,\"secrets\":$secrets}")
|
||||
-d "{\"name\":\"Priority E2E (claude-code)\",\"runtime\":\"claude-code\",\"model\":\"sonnet\",\"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"
|
||||
@@ -380,8 +381,9 @@ 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\",\"tier\":1,\"secrets\":$secrets}")
|
||||
-d "{\"name\":\"Priority E2E (gemini-cli)\",\"runtime\":\"gemini-cli\",\"model\":\"gemini-2.0-flash\",\"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")
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestRefreshEnvFromCP_AppliesCPResponse(t *testing.T) {
|
||||
t.Errorf("org id header: got %q", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"MOLECULE_CP_SHARED_SECRET":"new-secret","MOLECULE_CP_URL":"https://api.moleculesai.app"}`)
|
||||
fmt.Fprint(w, `{"MOLECULE_CP_SHARED_SECRET":"new-secret","MOLECULE_CP_URL":"https://api.moleculesai.app","DISPLAY_SESSION_SIGNING_SECRET":"display-secret"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
@@ -45,6 +45,9 @@ func TestRefreshEnvFromCP_AppliesCPResponse(t *testing.T) {
|
||||
if got := os.Getenv("MOLECULE_CP_SHARED_SECRET"); got != "new-secret" {
|
||||
t.Errorf("SHARED_SECRET: want new-secret, got %q", got)
|
||||
}
|
||||
if got := os.Getenv("DISPLAY_SESSION_SIGNING_SECRET"); got != "display-secret" {
|
||||
t.Errorf("DISPLAY_SESSION_SIGNING_SECRET: want display-secret, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshEnvFromCP_CPUnreachableDoesNotFailBoot: network errors must
|
||||
|
||||
@@ -393,8 +393,9 @@ func main() {
|
||||
// See molecule-core#7.
|
||||
bindHost := resolveBindHost()
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%s", bindHost, port),
|
||||
Handler: r,
|
||||
Addr: fmt.Sprintf("%s:%s", bindHost, port),
|
||||
Handler: r,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
// Start server in goroutine
|
||||
|
||||
@@ -23,19 +23,28 @@ CANVAS_PID=$!
|
||||
# Memory v2 sidecar (built-in postgres plugin). See Dockerfile entrypoint
|
||||
# comment for rationale.
|
||||
#
|
||||
# Spawn-gating: only start the sidecar when the operator has indicated
|
||||
# they want it (MEMORY_V2_CUTOVER=true OR MEMORY_PLUGIN_URL set).
|
||||
# Without that signal, the sidecar adds zero value and risks aborting
|
||||
# tenant boot via the 30s health gate when the tenant Postgres lacks
|
||||
# Spawn-gating: start the sidecar when MEMORY_PLUGIN_URL is set.
|
||||
# Without it, the sidecar adds zero value and risks aborting tenant
|
||||
# boot via the 30s health gate when the tenant Postgres lacks
|
||||
# pgvector. Caught on staging redeploy 2026-05-05:
|
||||
# pq: extension "vector" is not available
|
||||
#
|
||||
# Defaults (when sidecar IS spawned): MEMORY_PLUGIN_DATABASE_URL
|
||||
# falls back to the tenant's DATABASE_URL.
|
||||
#
|
||||
# MEMORY_V2_CUTOVER is deprecated as of #1747 — the workspace-server
|
||||
# binary no longer reads it (v2 is unconditional now; the legacy SQL
|
||||
# fallback in mcp_tools.go is gone). The entrypoint still accepts it
|
||||
# as a synonym for "operator wants the sidecar" so old CP user-data
|
||||
# templates keep working through the rollout. When CP user-data drops
|
||||
# the var, this branch can go.
|
||||
MEMORY_PLUGIN_PID=""
|
||||
memory_plugin_wanted=""
|
||||
if [ "$MEMORY_V2_CUTOVER" = "true" ] || [ -n "$MEMORY_PLUGIN_URL" ]; then
|
||||
if [ -n "$MEMORY_PLUGIN_URL" ]; then
|
||||
memory_plugin_wanted=1
|
||||
elif [ "$MEMORY_V2_CUTOVER" = "true" ]; then
|
||||
memory_plugin_wanted=1
|
||||
echo "memory-plugin: ⚠️ MEMORY_V2_CUTOVER is deprecated (#1747) — set MEMORY_PLUGIN_URL instead. Spawning sidecar on the implied default this boot." >&2
|
||||
fi
|
||||
if [ -z "$MEMORY_PLUGIN_DISABLE" ] && [ -n "$memory_plugin_wanted" ] && [ -n "$DATABASE_URL" ]; then
|
||||
: "${MEMORY_PLUGIN_DATABASE_URL:=$DATABASE_URL}"
|
||||
|
||||
@@ -116,8 +116,11 @@ 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, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
body, readErr := 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,7 +119,10 @@ func (l *LarkAdapter) SendMessage(ctx context.Context, config map[string]interfa
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("lark: read response body: %w", readErr)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("lark: webhook returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
@@ -156,6 +156,9 @@ 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 {
|
||||
@@ -204,8 +207,16 @@ func (m *Manager) Reload(ctx context.Context) {
|
||||
log.Printf("Channels: reload scan error: %v", err)
|
||||
continue
|
||||
}
|
||||
_ = json.Unmarshal(configJSON, &ch.Config)
|
||||
_ = json.Unmarshal(allowedJSON, &ch.AllowedUsers)
|
||||
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
|
||||
}
|
||||
}
|
||||
// #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
|
||||
@@ -216,6 +227,9 @@ 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()
|
||||
@@ -473,6 +487,9 @@ 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
|
||||
@@ -555,8 +572,14 @@ func (m *Manager) loadChannel(ctx context.Context, channelID string) (ChannelRow
|
||||
if err != nil {
|
||||
return ch, fmt.Errorf("channel %s not found: %w", channelID, err)
|
||||
}
|
||||
json.Unmarshal(configJSON, &ch.Config)
|
||||
json.Unmarshal(allowedJSON, &ch.AllowedUsers)
|
||||
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)
|
||||
}
|
||||
}
|
||||
// #319: decrypt bot_token / webhook_secret — SendOutbound and adapter
|
||||
// methods downstream read them as plaintext strings.
|
||||
if err := DecryptSensitiveFields(ch.Config); err != nil {
|
||||
|
||||
@@ -171,8 +171,11 @@ func (s *SlackAdapter) sendBotMessage(ctx context.Context, config map[string]int
|
||||
if err != nil {
|
||||
return fmt.Errorf("slack: send: %w", err)
|
||||
}
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
respBody, readErr := 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"`
|
||||
@@ -208,9 +211,13 @@ 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, _ := io.ReadAll(resp.Body)
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("slack: webhook returned %d (read body failed: %v)", resp.StatusCode, readErr)
|
||||
}
|
||||
return fmt.Errorf("slack: webhook returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil
|
||||
@@ -524,8 +531,11 @@ func FetchChannelHistory(ctx context.Context, botToken, channelID string, limit
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 65536))
|
||||
body, readErr := 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"`
|
||||
|
||||
@@ -111,12 +111,13 @@ const maxProxyResponseBody = 10 << 20
|
||||
// a generic 502 page to canvas. 10s is well above realistic intra-region
|
||||
// latencies and well below CF's edge timeout.
|
||||
//
|
||||
// 3. Transport.ResponseHeaderTimeout — 180s default. From request-body-end
|
||||
// 3. Transport.ResponseHeaderTimeout — 5min default. From request-body-end
|
||||
// to response-headers-start. Configurable via
|
||||
// A2A_PROXY_RESPONSE_HEADER_TIMEOUT (envx.Duration). Covers cold-start
|
||||
// first-byte (30-60s OAuth flow above) with enough room for Opus agent
|
||||
// turns (big context + internal delegate_task round-trips routinely exceed
|
||||
// the old 60s ceiling). Body streaming after headers is governed by the
|
||||
// turns and Codex scheduled tasks (big context + internal delegate_task
|
||||
// round-trips routinely exceed the old 60s/180s ceilings). Body streaming
|
||||
// after headers is governed by the
|
||||
// per-request context deadline, NOT this timeout — so multi-minute agent
|
||||
// responses still work fine.
|
||||
//
|
||||
@@ -131,7 +132,7 @@ var a2aClient = &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
ResponseHeaderTimeout: envx.Duration("A2A_PROXY_RESPONSE_HEADER_TIMEOUT", 180*time.Second),
|
||||
ResponseHeaderTimeout: envx.Duration("A2A_PROXY_RESPONSE_HEADER_TIMEOUT", 5*time.Minute),
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
// MaxIdleConns / IdleConnTimeout: stdlib defaults are fine; agent
|
||||
// fan-in is bounded by the platform's broadcaster fan-out, not by
|
||||
@@ -228,7 +229,7 @@ func (e *proxyA2AError) Error() string {
|
||||
// cron scheduler and other internal callers that need to send A2A messages
|
||||
// to workspaces programmatically (not from an HTTP handler).
|
||||
func (h *WorkspaceHandler) ProxyA2ARequest(ctx context.Context, workspaceID string, body []byte, callerID string, logActivity bool) (int, []byte, error) {
|
||||
status, resp, proxyErr := h.proxyA2ARequest(ctx, workspaceID, body, callerID, logActivity)
|
||||
status, resp, proxyErr := h.proxyA2ARequest(ctx, workspaceID, body, callerID, logActivity, false)
|
||||
if proxyErr != nil {
|
||||
return status, resp, proxyErr
|
||||
}
|
||||
@@ -307,13 +308,21 @@ func (h *WorkspaceHandler) ProxyA2A(c *gin.Context) {
|
||||
// The bind is strict: the token must match `callerID`, not
|
||||
// `workspaceID` (the target). A compromised token from workspace A
|
||||
// must never authenticate calls from A pretending to be B.
|
||||
if callerID != "" && callerID != workspaceID {
|
||||
if err := validateCallerToken(ctx, c, callerID); err != nil {
|
||||
//
|
||||
// Post-RFC#637: canvas users now send X-Workspace-ID (their identity
|
||||
// workspace). validateCallerToken detects canvas/admin auth on a
|
||||
// tokenless workspace and returns isCanvasUser=true so the proxy can
|
||||
// bypass CanCommunicate (human users sit outside the hierarchy).
|
||||
isCanvasUser := false
|
||||
if callerID != "" && callerID != workspaceID && !isSystemCaller(callerID) {
|
||||
var err error
|
||||
isCanvasUser, err = validateCallerToken(ctx, c, callerID)
|
||||
if err != nil {
|
||||
return // response already written with 401
|
||||
}
|
||||
}
|
||||
|
||||
status, respBody, proxyErr := h.proxyA2ARequest(ctx, workspaceID, body, callerID, true)
|
||||
status, respBody, proxyErr := h.proxyA2ARequest(ctx, workspaceID, body, callerID, true, isCanvasUser)
|
||||
if proxyErr != nil {
|
||||
for k, v := range proxyErr.Headers {
|
||||
c.Header(k, v)
|
||||
@@ -352,11 +361,13 @@ func (h *WorkspaceHandler) checkWorkspaceBudget(ctx context.Context, workspaceID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *WorkspaceHandler) proxyA2ARequest(ctx context.Context, workspaceID string, body []byte, callerID string, logActivity bool) (int, []byte, *proxyA2AError) {
|
||||
func (h *WorkspaceHandler) proxyA2ARequest(ctx context.Context, workspaceID string, body []byte, callerID string, logActivity bool, isCanvasUser bool) (int, []byte, *proxyA2AError) {
|
||||
// Access control: workspace-to-workspace requests must pass CanCommunicate check.
|
||||
// Canvas requests (callerID == "") and system callers (webhook:*, system:*, test:*)
|
||||
// are trusted. Self-calls (callerID == workspaceID) are always allowed.
|
||||
if callerID != "" && callerID != workspaceID && !isSystemCaller(callerID) {
|
||||
// Post-RFC#637: canvas-user identity workspaces also bypass CanCommunicate
|
||||
// because human users sit outside the org hierarchy.
|
||||
if callerID != "" && callerID != workspaceID && !isSystemCaller(callerID) && !isCanvasUser {
|
||||
if !registry.CanCommunicate(callerID, workspaceID) {
|
||||
log.Printf("ProxyA2A: access denied %s → %s", callerID, workspaceID)
|
||||
return 0, nil, &proxyA2AError{
|
||||
|
||||
@@ -5,17 +5,21 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/middleware"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/orgtoken"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -28,8 +32,8 @@ type proxyDispatchBuildError struct{ err error }
|
||||
func (e *proxyDispatchBuildError) Error() string { return e.err.Error() }
|
||||
|
||||
// handleA2ADispatchError translates a forward-call failure into a proxyA2AError,
|
||||
// runs the reactive container-health check, and (when `logActivity` is true)
|
||||
// schedules a detached LogActivity goroutine for the failed attempt.
|
||||
// runs the reactive container-health check, and records the outcome. Busy
|
||||
// targets that are successfully queued are logged as queued, not failed.
|
||||
func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspaceID, callerID string, body []byte, a2aMethod string, err error, durationMs int, logActivity bool) (int, []byte, *proxyA2AError) {
|
||||
// Build-time failure (couldn't even create the http.Request) — return
|
||||
// a 500 without the reactive-health / busy-retry paths.
|
||||
@@ -45,10 +49,10 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
|
||||
|
||||
containerDead := h.maybeMarkContainerDead(ctx, workspaceID)
|
||||
|
||||
if logActivity {
|
||||
h.logA2AFailure(ctx, workspaceID, callerID, body, a2aMethod, err, durationMs)
|
||||
}
|
||||
if containerDead {
|
||||
if logActivity {
|
||||
h.logA2AFailure(ctx, workspaceID, callerID, body, a2aMethod, err, durationMs)
|
||||
}
|
||||
return 0, nil, &proxyA2AError{
|
||||
Status: http.StatusServiceUnavailable,
|
||||
Response: gin.H{"error": "workspace agent unreachable — container restart triggered", "restarting": true},
|
||||
@@ -108,6 +112,9 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
|
||||
ctx, workspaceID, callerID, PriorityTask, body, a2aMethod, idempotencyKey, expiresAt,
|
||||
); qerr == nil {
|
||||
log.Printf("ProxyA2A: target %s busy — enqueued as %s (depth=%d)", workspaceID, qid, depth)
|
||||
if logActivity {
|
||||
h.logA2ABusyQueued(ctx, workspaceID, callerID, body, a2aMethod, durationMs)
|
||||
}
|
||||
respBody, _ := json.Marshal(gin.H{
|
||||
"queued": true,
|
||||
"queue_id": qid,
|
||||
@@ -121,6 +128,9 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
|
||||
// make delegation silently disappear.
|
||||
log.Printf("ProxyA2A: enqueue for %s failed (%v) — falling back to 503", workspaceID, qerr)
|
||||
}
|
||||
if logActivity {
|
||||
h.logA2AFailure(ctx, workspaceID, callerID, body, a2aMethod, err, durationMs)
|
||||
}
|
||||
return 0, nil, &proxyA2AError{
|
||||
Status: http.StatusServiceUnavailable,
|
||||
Headers: map[string]string{"Retry-After": strconv.Itoa(busyRetryAfterSeconds)},
|
||||
@@ -131,6 +141,9 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
|
||||
},
|
||||
}
|
||||
}
|
||||
if logActivity {
|
||||
h.logA2AFailure(ctx, workspaceID, callerID, body, a2aMethod, err, durationMs)
|
||||
}
|
||||
return 0, nil, &proxyA2AError{
|
||||
Status: http.StatusBadGateway,
|
||||
Response: gin.H{"error": "failed to reach workspace agent"},
|
||||
@@ -311,6 +324,33 @@ func (h *WorkspaceHandler) logA2AFailure(ctx context.Context, workspaceID, calle
|
||||
})
|
||||
}
|
||||
|
||||
// logA2ABusyQueued records that a push attempt reached a live but busy
|
||||
// workspace and was durably queued for heartbeat drain.
|
||||
func (h *WorkspaceHandler) logA2ABusyQueued(ctx context.Context, workspaceID, callerID string, body []byte, a2aMethod string, durationMs int) {
|
||||
var wsName string
|
||||
db.DB.QueryRowContext(ctx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName)
|
||||
if wsName == "" {
|
||||
wsName = workspaceID
|
||||
}
|
||||
summary := a2aMethod + " → " + wsName + " (queued: target busy)"
|
||||
parent := ctx
|
||||
h.goAsync(func() {
|
||||
logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
|
||||
defer cancel()
|
||||
LogActivity(logCtx, h.broadcaster, ActivityParams{
|
||||
WorkspaceID: workspaceID,
|
||||
ActivityType: "a2a_receive",
|
||||
SourceID: nilIfEmpty(callerID),
|
||||
TargetID: &workspaceID,
|
||||
Method: &a2aMethod,
|
||||
Summary: &summary,
|
||||
RequestBody: json.RawMessage(body),
|
||||
DurationMs: &durationMs,
|
||||
Status: "ok",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// logA2ASuccess records a successful A2A round-trip and (for canvas-initiated
|
||||
// 2xx/3xx responses) broadcasts an A2A_RESPONSE event so the frontend can
|
||||
// receive the reply without polling.
|
||||
@@ -383,31 +423,53 @@ func nilIfEmpty(s string) *string {
|
||||
// (their next /registry/register will mint their first token, after
|
||||
// which this branch never fires again for them).
|
||||
//
|
||||
// Post-RFC#637 addition: when the tokenless workspace is accompanied by
|
||||
// canvas or admin auth (same-origin request, admin bearer, or org-level
|
||||
// token), the caller is identified as a canvas-user identity rather than
|
||||
// a legacy peer agent. The returned isCanvasUser flag lets the A2A proxy
|
||||
// bypass CanCommunicate for human users, who sit outside the workspace
|
||||
// hierarchy.
|
||||
//
|
||||
// On auth failure this writes the 401 via c and returns an error so the
|
||||
// handler aborts without running the proxy.
|
||||
func validateCallerToken(ctx context.Context, c *gin.Context, callerID string) error {
|
||||
hasLive, err := wsauth.HasAnyLiveToken(ctx, db.DB, callerID)
|
||||
if err != nil {
|
||||
func validateCallerToken(ctx context.Context, c *gin.Context, callerID string) (isCanvasUser bool, err error) {
|
||||
hasLive, dbErr := wsauth.HasAnyLiveToken(ctx, db.DB, callerID)
|
||||
if dbErr != nil {
|
||||
// Fail-open here matches the heartbeat path — A2A caller auth is
|
||||
// defense-in-depth on top of access-control hierarchy, not the
|
||||
// sole gate on the secret material. A DB hiccup shouldn't take
|
||||
// the whole A2A path down.
|
||||
log.Printf("wsauth: caller HasAnyLiveToken(%s) failed: %v — allowing A2A", callerID, err)
|
||||
return nil
|
||||
log.Printf("wsauth: caller HasAnyLiveToken(%s) failed: %v — allowing A2A", callerID, dbErr)
|
||||
return false, nil
|
||||
}
|
||||
if !hasLive {
|
||||
return nil // legacy / pre-upgrade caller
|
||||
// Tokenless workspace — could be legacy/pre-upgrade caller or
|
||||
// canvas-user identity. Distinguish by request auth signals.
|
||||
if middleware.IsSameOriginCanvas(c) {
|
||||
return true, nil
|
||||
}
|
||||
tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization"))
|
||||
if tok != "" {
|
||||
adminSecret := os.Getenv("ADMIN_TOKEN")
|
||||
if adminSecret != "" && subtle.ConstantTimeCompare([]byte(tok), []byte(adminSecret)) == 1 {
|
||||
return true, nil
|
||||
}
|
||||
if _, _, _, err := orgtoken.Validate(ctx, db.DB, tok); err == nil {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil // legacy / pre-upgrade caller
|
||||
}
|
||||
tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization"))
|
||||
if tok == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing caller auth token"})
|
||||
return errInvalidCallerToken
|
||||
return false, errInvalidCallerToken
|
||||
}
|
||||
if err := wsauth.ValidateToken(ctx, db.DB, callerID, tok); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid caller auth token"})
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// errInvalidCallerToken is a sentinel for validateCallerToken's "missing
|
||||
|
||||
@@ -1112,9 +1112,13 @@ func TestValidateCallerToken_LegacyCallerGrandfathered(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/x/a2a", bytes.NewBufferString("{}"))
|
||||
|
||||
if err := validateCallerToken(context.Background(), c, "ws-legacy"); err != nil {
|
||||
isCanvasUser, err := validateCallerToken(context.Background(), c, "ws-legacy")
|
||||
if err != nil {
|
||||
t.Errorf("legacy caller should grandfather through; got %v", err)
|
||||
}
|
||||
if isCanvasUser {
|
||||
t.Errorf("legacy caller should NOT be identified as canvas user")
|
||||
}
|
||||
if w.Code != 200 {
|
||||
// gin default before c.JSON is 200; we want no error response written
|
||||
if w.Body.Len() != 0 {
|
||||
@@ -1136,10 +1140,13 @@ func TestValidateCallerToken_MissingTokenWhenOnFile(t *testing.T) {
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/x/a2a", bytes.NewBufferString("{}"))
|
||||
// No Authorization header set
|
||||
|
||||
err := validateCallerToken(context.Background(), c, "ws-authed")
|
||||
isCanvasUser, err := validateCallerToken(context.Background(), c, "ws-authed")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing token")
|
||||
}
|
||||
if isCanvasUser {
|
||||
t.Errorf("authed workspace with missing token should NOT be canvas user")
|
||||
}
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
}
|
||||
@@ -1164,9 +1171,13 @@ func TestValidateCallerToken_InvalidToken(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer wrong")
|
||||
c.Request = req
|
||||
|
||||
if err := validateCallerToken(context.Background(), c, "ws-authed"); err == nil {
|
||||
isCanvasUser, err := validateCallerToken(context.Background(), c, "ws-authed")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bad token")
|
||||
}
|
||||
if isCanvasUser {
|
||||
t.Errorf("authed workspace with bad token should NOT be canvas user")
|
||||
}
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
}
|
||||
@@ -1192,9 +1203,13 @@ func TestValidateCallerToken_ValidToken(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer goodtok")
|
||||
c.Request = req
|
||||
|
||||
if err := validateCallerToken(context.Background(), c, "ws-authed"); err != nil {
|
||||
isCanvasUser, err := validateCallerToken(context.Background(), c, "ws-authed")
|
||||
if err != nil {
|
||||
t.Errorf("valid token should pass; got %v", err)
|
||||
}
|
||||
if isCanvasUser {
|
||||
t.Errorf("authed workspace with valid token should NOT be canvas user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCallerToken_WrongWorkspaceBindingRejected(t *testing.T) {
|
||||
@@ -1216,14 +1231,86 @@ func TestValidateCallerToken_WrongWorkspaceBindingRejected(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer tok-for-A")
|
||||
c.Request = req
|
||||
|
||||
if err := validateCallerToken(context.Background(), c, "ws-b-attacker"); err == nil {
|
||||
isCanvasUser, err := validateCallerToken(context.Background(), c, "ws-b-attacker")
|
||||
if err == nil {
|
||||
t.Fatal("token from A must not authenticate caller B")
|
||||
}
|
||||
if isCanvasUser {
|
||||
t.Errorf("cross-workspace token replay should NOT be identified as canvas user")
|
||||
}
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCallerToken_CanvasUser_AdminToken(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
// Tokenless workspace
|
||||
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
|
||||
WithArgs("ws-canvas-admin").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
|
||||
t.Setenv("ADMIN_TOKEN", "admin-secret-42")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
req := httptest.NewRequest("POST", "/workspaces/x/a2a", bytes.NewBufferString("{}"))
|
||||
req.Header.Set("Authorization", "Bearer admin-secret-42")
|
||||
c.Request = req
|
||||
|
||||
isCanvasUser, err := validateCallerToken(context.Background(), c, "ws-canvas-admin")
|
||||
if err != nil {
|
||||
t.Errorf("admin token should identify canvas user; got error: %v", err)
|
||||
}
|
||||
if !isCanvasUser {
|
||||
t.Errorf("admin token bearer should be identified as canvas user")
|
||||
}
|
||||
if w.Code != 200 || w.Body.Len() != 0 {
|
||||
t.Errorf("admin token path should not write a response body; got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCallerToken_CanvasUser_OrgToken(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
// Tokenless workspace
|
||||
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
|
||||
WithArgs("ws-canvas-org").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
|
||||
// orgtoken.Validate lookup
|
||||
mock.ExpectQuery(`SELECT id, prefix, org_id FROM org_api_tokens WHERE token_hash = .* AND revoked_at IS NULL`).
|
||||
WithArgs(sqlmock.AnyArg()).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix", "org_id"}).AddRow("orgtok-1", "pref1234", "org-1"))
|
||||
mock.ExpectExec(`UPDATE org_api_tokens SET last_used_at`).
|
||||
WithArgs("orgtok-1").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
req := httptest.NewRequest("POST", "/workspaces/x/a2a", bytes.NewBufferString("{}"))
|
||||
req.Header.Set("Authorization", "Bearer org-token-plaintext-xyz")
|
||||
c.Request = req
|
||||
|
||||
isCanvasUser, err := validateCallerToken(context.Background(), c, "ws-canvas-org")
|
||||
if err != nil {
|
||||
t.Errorf("org token should identify canvas user; got error: %v", err)
|
||||
}
|
||||
if !isCanvasUser {
|
||||
t.Errorf("org token bearer should be identified as canvas user")
|
||||
}
|
||||
if w.Code != 200 || w.Body.Len() != 0 {
|
||||
t.Errorf("org token path should not write a response body; got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Direct unit tests for normalizeA2APayload (extracted from proxyA2ARequest) ---
|
||||
|
||||
func TestNormalizeA2APayload_InvalidJSON(t *testing.T) {
|
||||
@@ -1779,6 +1866,58 @@ func TestHandleA2ADispatchError_ContextDeadline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleA2ADispatchError_BusyEnqueueLogsQueuedNotFailure(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
waitForHandlerAsyncBeforeDBCleanup(t, handler)
|
||||
|
||||
mock.ExpectQuery(`INSERT INTO a2a_queue`).
|
||||
WithArgs("ws-busy", nil, PriorityTask, "{}", "message/send", nil, nil).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("11111111-1111-1111-1111-111111111111"))
|
||||
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM a2a_queue`).
|
||||
WithArgs("ws-busy").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-busy").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Busy Target"))
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WithArgs(
|
||||
"ws-busy",
|
||||
"a2a_receive",
|
||||
nil,
|
||||
sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(),
|
||||
nil,
|
||||
nil,
|
||||
sqlmock.AnyArg(),
|
||||
"ok",
|
||||
nil,
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
status, body, perr := handler.handleA2ADispatchError(
|
||||
context.Background(), "ws-busy", "", []byte("{}"), "message/send",
|
||||
context.DeadlineExceeded, 180002, true,
|
||||
)
|
||||
if perr != nil {
|
||||
t.Fatalf("expected busy enqueue success, got proxy error: %+v", perr)
|
||||
}
|
||||
if status != http.StatusAccepted {
|
||||
t.Fatalf("got status %d, want 202", status)
|
||||
}
|
||||
if !bytes.Contains(body, []byte(`"queued":true`)) {
|
||||
t.Fatalf("expected queued response body, got %s", string(body))
|
||||
}
|
||||
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations; busy enqueue must log status=ok, not error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleA2ADispatchError_BuildError(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
@@ -2354,7 +2493,7 @@ func TestLookupDeliveryMode_ContextCanceled_FailsClosed(t *testing.T) {
|
||||
// ==================== a2aClient ResponseHeaderTimeout config ====================
|
||||
|
||||
func TestA2AClientResponseHeaderTimeout(t *testing.T) {
|
||||
const defaultTimeout = 180 * time.Second
|
||||
const defaultTimeout = 5 * time.Minute
|
||||
|
||||
// Default (unset env) — a2aClient was initialised at package load time.
|
||||
if a2aClient.Transport.(*http.Transport).ResponseHeaderTimeout != defaultTimeout {
|
||||
@@ -2378,7 +2517,7 @@ func TestA2AClientResponseHeaderTimeout(t *testing.T) {
|
||||
t.Run("invalid A2A_PROXY_RESPONSE_HEADER_TIMEOUT falls back to default", func(t *testing.T) {
|
||||
t.Setenv("A2A_PROXY_RESPONSE_HEADER_TIMEOUT", "not-a-duration")
|
||||
// Simulate what envx.Duration does with an invalid value.
|
||||
var fallback = 180 * time.Second
|
||||
var fallback = 5 * time.Minute
|
||||
override := fallback
|
||||
if v := os.Getenv("A2A_PROXY_RESPONSE_HEADER_TIMEOUT"); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||
|
||||
@@ -333,7 +333,7 @@ func (h *WorkspaceHandler) DrainQueueForWorkspace(ctx context.Context, workspace
|
||||
}
|
||||
// logActivity=false: the original EnqueueA2A callsite already logged
|
||||
// the dispatch attempt; re-logging here would double-count events.
|
||||
status, respBody, proxyErr := h.proxyA2ARequest(ctx, workspaceID, item.Body, callerID, false)
|
||||
status, respBody, proxyErr := h.proxyA2ARequest(ctx, workspaceID, item.Body, callerID, false, false)
|
||||
|
||||
// 202 Accepted = the dispatch was itself queued again (target still busy).
|
||||
// That's not a failure — the queued item just stays queued naturally on
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -16,19 +15,12 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// envMemoryV2Cutover gates whether admin export/import routes through
|
||||
// the v2 plugin (PR-8 / RFC #2728). When unset, the legacy direct-DB
|
||||
// path runs unchanged so operators who haven't enabled the plugin
|
||||
// keep working.
|
||||
const envMemoryV2Cutover = "MEMORY_V2_CUTOVER"
|
||||
|
||||
// AdminMemoriesHandler provides bulk export/import of agent memories for
|
||||
// backup and restore across Docker rebuilds (issue #1051).
|
||||
//
|
||||
// PR-8 (RFC #2728): when wired with the v2 plugin via WithMemoryV2 AND
|
||||
// MEMORY_V2_CUTOVER is true, export reads from the plugin's namespaces
|
||||
// and import writes through the plugin. Both paths preserve the
|
||||
// SAFE-T1201 redaction shipped in F1084 + F1085.
|
||||
// Issue #1733: the v2 plugin is the only supported backend. Export
|
||||
// reads from the plugin's namespaces; import writes through the plugin.
|
||||
// Both paths preserve the SAFE-T1201 redaction shipped in F1084 + F1085.
|
||||
type AdminMemoriesHandler struct {
|
||||
plugin adminMemoriesPlugin
|
||||
resolver adminMemoriesResolver
|
||||
@@ -69,12 +61,12 @@ func (h *AdminMemoriesHandler) withMemoryV2APIs(plugin adminMemoriesPlugin, reso
|
||||
return h
|
||||
}
|
||||
|
||||
// cutoverActive reports whether the export/import path should route
|
||||
// through the v2 plugin.
|
||||
func (h *AdminMemoriesHandler) cutoverActive() bool {
|
||||
if os.Getenv(envMemoryV2Cutover) != "true" {
|
||||
return false
|
||||
}
|
||||
// memoryV2Wired reports whether the v2 plugin + resolver are attached.
|
||||
// Issue #1733: v2 is now the only path; this replaces the prior
|
||||
// cutoverActive() gate (which also checked MEMORY_V2_CUTOVER=true) —
|
||||
// the env-flag double-check is gone since there's no legacy fallback
|
||||
// to choose against.
|
||||
func (h *AdminMemoriesHandler) memoryV2Wired() bool {
|
||||
return h.plugin != nil && h.resolver != nil
|
||||
}
|
||||
|
||||
@@ -97,48 +89,19 @@ type memoryExportEntry struct {
|
||||
// before returning so that any credentials stored before SAFE-T1201 (#838)
|
||||
// was applied do not leak out via the admin export endpoint.
|
||||
//
|
||||
// CUTOVER (PR-8 / RFC #2728): when MEMORY_V2_CUTOVER=true and the v2
|
||||
// plugin is wired, reads from the plugin instead of agent_memories.
|
||||
// Issue #1733: reads exclusively from the v2 plugin. The legacy direct
|
||||
// agent_memories scan is gone — operators without a configured plugin
|
||||
// get a 503 explaining the required setup.
|
||||
func (h *AdminMemoriesHandler) Export(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
if h.cutoverActive() {
|
||||
h.exportViaPlugin(c, ctx)
|
||||
if !h.memoryV2Wired() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "memory plugin is not configured (set MEMORY_PLUGIN_URL)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT am.id, am.content, am.scope, am.namespace, am.created_at,
|
||||
w.name AS workspace_name
|
||||
FROM agent_memories am
|
||||
JOIN workspaces w ON am.workspace_id = w.id
|
||||
ORDER BY am.created_at
|
||||
`)
|
||||
if err != nil {
|
||||
log.Printf("admin/memories/export: query error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "export query failed"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
memories := make([]memoryExportEntry, 0)
|
||||
for rows.Next() {
|
||||
var m memoryExportEntry
|
||||
if err := rows.Scan(&m.ID, &m.Content, &m.Scope, &m.Namespace, &m.CreatedAt, &m.WorkspaceName); err != nil {
|
||||
log.Printf("admin/memories/export: scan error: %v", err)
|
||||
continue
|
||||
}
|
||||
// F1084 / #1131: redact secrets before returning so pre-SAFE-T1201
|
||||
// memories (stored before redactSecrets was mandatory) don't leak.
|
||||
redacted, _ := redactSecrets(m.WorkspaceName, m.Content)
|
||||
m.Content = redacted
|
||||
memories = append(memories, m)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("admin/memories/export: rows error: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, memories)
|
||||
h.exportViaPlugin(c, ctx)
|
||||
}
|
||||
|
||||
// memoryImportEntry is the JSON shape accepted on import. Matches export format.
|
||||
@@ -160,8 +123,9 @@ type memoryImportEntry struct {
|
||||
// with embedded credentials cannot land unredacted in agent_memories (SAFE-T1201
|
||||
// parity with the commit_memory MCP bridge path).
|
||||
//
|
||||
// CUTOVER (PR-8 / RFC #2728): when MEMORY_V2_CUTOVER=true and the v2
|
||||
// plugin is wired, writes through the plugin instead of agent_memories.
|
||||
// Issue #1733: writes exclusively through the v2 plugin. The legacy
|
||||
// direct agent_memories insert path is gone — operators without a
|
||||
// configured plugin get a 503 explaining the required setup.
|
||||
func (h *AdminMemoriesHandler) Import(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
@@ -171,85 +135,13 @@ func (h *AdminMemoriesHandler) Import(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if h.cutoverActive() {
|
||||
h.importViaPlugin(c, ctx, entries)
|
||||
if !h.memoryV2Wired() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "memory plugin is not configured (set MEMORY_PLUGIN_URL)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
imported := 0
|
||||
skipped := 0
|
||||
errors := 0
|
||||
|
||||
for _, entry := range entries {
|
||||
// 1. Resolve workspace by name
|
||||
var workspaceID string
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT id FROM workspaces WHERE name = $1 LIMIT 1`,
|
||||
entry.WorkspaceName,
|
||||
).Scan(&workspaceID)
|
||||
if err != nil {
|
||||
log.Printf("admin/memories/import: workspace %q not found, skipping", entry.WorkspaceName)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// F1085 / #1132: scrub credential patterns before persistence so that
|
||||
// imported memories with secrets don't bypass SAFE-T1201 (#838).
|
||||
// Must run BEFORE the dedup check so the redacted content is what
|
||||
// gets stored — otherwise re-importing the same backup would produce
|
||||
// a duplicate with different (original, unredacted) content.
|
||||
content, _ := redactSecrets(workspaceID, entry.Content)
|
||||
|
||||
// 2. Check for duplicate (same workspace + content + scope) using
|
||||
// the redacted content so that two backups with the same original
|
||||
// secret (same placeholder output) are treated as duplicates.
|
||||
var exists bool
|
||||
|
||||
err = db.DB.QueryRowContext(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM agent_memories WHERE workspace_id = $1 AND content = $2 AND scope = $3)`,
|
||||
workspaceID, content, entry.Scope,
|
||||
).Scan(&exists)
|
||||
if err != nil {
|
||||
log.Printf("admin/memories/import: duplicate check error for workspace %q: %v", entry.WorkspaceName, err)
|
||||
errors++
|
||||
continue
|
||||
}
|
||||
if exists {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// 3. Insert the memory, preserving original created_at if provided
|
||||
namespace := entry.Namespace
|
||||
if namespace == "" {
|
||||
namespace = "general"
|
||||
}
|
||||
|
||||
if entry.CreatedAt != "" {
|
||||
_, err = db.DB.ExecContext(ctx,
|
||||
`INSERT INTO agent_memories (workspace_id, content, scope, namespace, created_at) VALUES ($1, $2, $3, $4, $5)`,
|
||||
workspaceID, content, entry.Scope, namespace, entry.CreatedAt,
|
||||
)
|
||||
} else {
|
||||
_, err = db.DB.ExecContext(ctx,
|
||||
`INSERT INTO agent_memories (workspace_id, content, scope, namespace) VALUES ($1, $2, $3, $4)`,
|
||||
workspaceID, content, entry.Scope, namespace,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("admin/memories/import: insert error for workspace %q: %v", entry.WorkspaceName, err)
|
||||
errors++
|
||||
continue
|
||||
}
|
||||
imported++
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"imported": imported,
|
||||
"skipped": skipped,
|
||||
"errors": errors,
|
||||
"total": len(entries),
|
||||
})
|
||||
h.importViaPlugin(c, ctx, entries)
|
||||
}
|
||||
|
||||
// exportViaPlugin reads memories from the v2 plugin and emits them in
|
||||
|
||||
@@ -101,26 +101,24 @@ func installMockDB(t *testing.T) sqlmock.Sqlmock {
|
||||
return mock
|
||||
}
|
||||
|
||||
// --- cutoverActive ---
|
||||
// --- memoryV2Wired ---
|
||||
|
||||
func TestCutoverActive(t *testing.T) {
|
||||
func TestMemoryV2Wired(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
envVal string
|
||||
plugin adminMemoriesPlugin
|
||||
resolver adminMemoriesResolver
|
||||
want bool
|
||||
}{
|
||||
{"env unset", "", &stubAdminPlugin{}, adminRootResolver(), false},
|
||||
{"env true but unwired", "true", nil, nil, false},
|
||||
{"env false", "false", &stubAdminPlugin{}, adminRootResolver(), false},
|
||||
{"env true wired", "true", &stubAdminPlugin{}, adminRootResolver(), true},
|
||||
{"both nil", nil, nil, false},
|
||||
{"plugin only", &stubAdminPlugin{}, nil, false},
|
||||
{"resolver only", nil, adminRootResolver(), false},
|
||||
{"both wired", &stubAdminPlugin{}, adminRootResolver(), true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv(envMemoryV2Cutover, tc.envVal)
|
||||
h := &AdminMemoriesHandler{plugin: tc.plugin, resolver: tc.resolver}
|
||||
if got := h.cutoverActive(); got != tc.want {
|
||||
if got := h.memoryV2Wired(); got != tc.want {
|
||||
t.Errorf("got %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
@@ -147,7 +145,6 @@ func TestWithMemoryV2APIs_AttachesDeps(t *testing.T) {
|
||||
// --- Export via plugin ---
|
||||
|
||||
func TestExport_RoutesThroughPluginWhenCutoverActive(t *testing.T) {
|
||||
t.Setenv(envMemoryV2Cutover, "true")
|
||||
mock := installMockDB(t)
|
||||
|
||||
mock.ExpectQuery("WITH RECURSIVE chain").
|
||||
@@ -191,7 +188,6 @@ func TestExport_RoutesThroughPluginWhenCutoverActive(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExport_DeduplicatesByMemoryID(t *testing.T) {
|
||||
t.Setenv(envMemoryV2Cutover, "true")
|
||||
mock := installMockDB(t)
|
||||
|
||||
// Two workspaces, both will see the same team-shared memory.
|
||||
@@ -222,7 +218,6 @@ func TestExport_DeduplicatesByMemoryID(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExport_SkipsWorkspaceWhenResolverFails(t *testing.T) {
|
||||
t.Setenv(envMemoryV2Cutover, "true")
|
||||
mock := installMockDB(t)
|
||||
mock.ExpectQuery("WITH RECURSIVE chain").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "root_id"}).
|
||||
@@ -244,7 +239,6 @@ func TestExport_SkipsWorkspaceWhenResolverFails(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExport_SkipsWorkspaceWhenPluginSearchFails(t *testing.T) {
|
||||
t.Setenv(envMemoryV2Cutover, "true")
|
||||
mock := installMockDB(t)
|
||||
mock.ExpectQuery("WITH RECURSIVE chain").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "root_id"}).
|
||||
@@ -268,7 +262,6 @@ func TestExport_SkipsWorkspaceWhenPluginSearchFails(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExport_WorkspacesQueryFails(t *testing.T) {
|
||||
t.Setenv(envMemoryV2Cutover, "true")
|
||||
mock := installMockDB(t)
|
||||
mock.ExpectQuery("WITH RECURSIVE chain").
|
||||
WillReturnError(errors.New("db dead"))
|
||||
@@ -287,7 +280,6 @@ func TestExport_WorkspacesQueryFails(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExport_EmptyReadable(t *testing.T) {
|
||||
t.Setenv(envMemoryV2Cutover, "true")
|
||||
mock := installMockDB(t)
|
||||
mock.ExpectQuery("WITH RECURSIVE chain").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "root_id"}).
|
||||
@@ -309,7 +301,6 @@ func TestExport_EmptyReadable(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExport_RedactsSecretsInPluginPath(t *testing.T) {
|
||||
t.Setenv(envMemoryV2Cutover, "true")
|
||||
mock := installMockDB(t)
|
||||
mock.ExpectQuery("WITH RECURSIVE chain").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "root_id"}).
|
||||
@@ -337,7 +328,6 @@ func TestExport_RedactsSecretsInPluginPath(t *testing.T) {
|
||||
// --- Import via plugin ---
|
||||
|
||||
func TestImport_RoutesThroughPluginWhenCutoverActive(t *testing.T) {
|
||||
t.Setenv(envMemoryV2Cutover, "true")
|
||||
mock := installMockDB(t)
|
||||
mock.ExpectQuery("SELECT id::text FROM workspaces").
|
||||
WithArgs("alpha").
|
||||
@@ -368,7 +358,6 @@ func TestImport_RoutesThroughPluginWhenCutoverActive(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestImport_SkipsUnknownWorkspace(t *testing.T) {
|
||||
t.Setenv(envMemoryV2Cutover, "true")
|
||||
mock := installMockDB(t)
|
||||
mock.ExpectQuery("SELECT id::text FROM workspaces").
|
||||
WithArgs("ghost").
|
||||
@@ -395,7 +384,6 @@ func TestImport_SkipsUnknownWorkspace(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestImport_PluginUpsertNamespaceError(t *testing.T) {
|
||||
t.Setenv(envMemoryV2Cutover, "true")
|
||||
mock := installMockDB(t)
|
||||
mock.ExpectQuery("SELECT id::text FROM workspaces").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("root-1"))
|
||||
@@ -425,7 +413,6 @@ func TestImport_PluginUpsertNamespaceError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestImport_PluginCommitError(t *testing.T) {
|
||||
t.Setenv(envMemoryV2Cutover, "true")
|
||||
mock := installMockDB(t)
|
||||
mock.ExpectQuery("SELECT id::text FROM workspaces").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("root-1"))
|
||||
@@ -455,7 +442,6 @@ func TestImport_PluginCommitError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestImport_RedactsBeforePluginSeesContent(t *testing.T) {
|
||||
t.Setenv(envMemoryV2Cutover, "true")
|
||||
mock := installMockDB(t)
|
||||
mock.ExpectQuery("SELECT id::text FROM workspaces").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("root-1"))
|
||||
@@ -482,7 +468,6 @@ func TestImport_RedactsBeforePluginSeesContent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestImport_SkipsUnknownScope(t *testing.T) {
|
||||
t.Setenv(envMemoryV2Cutover, "true")
|
||||
mock := installMockDB(t)
|
||||
mock.ExpectQuery("SELECT id::text FROM workspaces").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("root-1"))
|
||||
@@ -508,7 +493,6 @@ func TestImport_SkipsUnknownScope(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestImport_SkipsWhenResolverErrors(t *testing.T) {
|
||||
t.Setenv(envMemoryV2Cutover, "true")
|
||||
mock := installMockDB(t)
|
||||
mock.ExpectQuery("SELECT id::text FROM workspaces").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("root-1"))
|
||||
@@ -545,7 +529,6 @@ func TestImport_SkipsWhenResolverErrors(t *testing.T) {
|
||||
// + org:root-1. (Children's workspace:<id> namespaces must be
|
||||
// included or admin export silently drops their private memories.)
|
||||
func TestExport_BatchesPluginCallsByRoot(t *testing.T) {
|
||||
t.Setenv(envMemoryV2Cutover, "true")
|
||||
mock := installMockDB(t)
|
||||
|
||||
mock.ExpectQuery("WITH RECURSIVE chain").
|
||||
@@ -605,7 +588,6 @@ func (r perWorkspaceResolver) WritableNamespaces(_ context.Context, ws string) (
|
||||
// workspace:rootID + team:rootID + org:rootID — every child workspace's
|
||||
// private memories were silently dropped from admin export.
|
||||
func TestExport_IncludesEveryMembersPrivateNamespace(t *testing.T) {
|
||||
t.Setenv(envMemoryV2Cutover, "true")
|
||||
mock := installMockDB(t)
|
||||
|
||||
mock.ExpectQuery("WITH RECURSIVE chain").
|
||||
@@ -775,25 +757,43 @@ func TestSkipImport_ErrorMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Confirm legacy paths still work when env is unset ---
|
||||
// --- 503 when plugin is not wired (issue #1733) ---
|
||||
//
|
||||
// The legacy SQL-backed Export/Import path was removed; both endpoints
|
||||
// now respond 503 with a clear hint when v2 isn't configured.
|
||||
|
||||
func TestExport_LegacyPathWhenCutoverInactive(t *testing.T) {
|
||||
t.Setenv(envMemoryV2Cutover, "")
|
||||
mock := installMockDB(t)
|
||||
mock.ExpectQuery("SELECT am.id, am.content, am.scope, am.namespace").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "content", "scope", "namespace", "created_at", "workspace_name"}))
|
||||
|
||||
h := NewAdminMemoriesHandler().withMemoryV2APIs(&stubAdminPlugin{}, adminRootResolver())
|
||||
func TestExport_503WhenPluginNotWired(t *testing.T) {
|
||||
installMockDB(t)
|
||||
h := NewAdminMemoriesHandler() // no WithMemoryV2 → plugin nil
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/admin/memories/export", nil)
|
||||
h.Export(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("code = %d body=%s", w.Code, w.Body.String())
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("code = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("legacy SQL path not exercised: %v", err)
|
||||
if !strings.Contains(w.Body.String(), "MEMORY_PLUGIN_URL") {
|
||||
t.Errorf("body must hint at MEMORY_PLUGIN_URL: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestImport_503WhenPluginNotWired(t *testing.T) {
|
||||
installMockDB(t)
|
||||
h := NewAdminMemoriesHandler()
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/admin/memories/import",
|
||||
bytes.NewBufferString(`[]`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
h.Import(c)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("code = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "MEMORY_PLUGIN_URL") {
|
||||
t.Errorf("body must hint at MEMORY_PLUGIN_URL: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,220 +2,46 @@ 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"
|
||||
)
|
||||
|
||||
// newAdminMemoriesHandler is a test helper that returns an AdminMemoriesHandler.
|
||||
func newAdminMemoriesHandler() *AdminMemoriesHandler {
|
||||
return NewAdminMemoriesHandler()
|
||||
}
|
||||
|
||||
// adminPost builds a POST /admin/memories/import request.
|
||||
func adminPost(t *testing.T, h *AdminMemoriesHandler, body interface{}) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(body)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/admin/memories/import", bytes.NewReader(b))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
h.Import(c)
|
||||
return w
|
||||
}
|
||||
|
||||
// adminGet builds a GET /admin/memories/export request.
|
||||
func adminGet(t *testing.T, h *AdminMemoriesHandler) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/admin/memories/export", nil)
|
||||
h.Export(c)
|
||||
return w
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Export tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestAdminMemories_Export_Success(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newAdminMemoriesHandler()
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
rows := sqlmock.NewRows([]string{"id", "content", "scope", "namespace", "created_at", "workspace_name"}).
|
||||
AddRow("mem-1", "hello world", "LOCAL", "ws-1", now, "my-workspace").
|
||||
AddRow("mem-2", "another fact", "TEAM", "ws-1", now, "my-workspace")
|
||||
|
||||
mock.ExpectQuery("SELECT am.id, am.content, am.scope, am.namespace, am.created_at,").
|
||||
WillReturnRows(rows)
|
||||
|
||||
w := adminGet(t, h)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var memories []map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &memories); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if len(memories) != 2 {
|
||||
t.Errorf("expected 2 memories, got %d", len(memories))
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminMemories_Export_Empty(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newAdminMemoriesHandler()
|
||||
|
||||
rows := sqlmock.NewRows([]string{"id", "content", "scope", "namespace", "created_at", "workspace_name"})
|
||||
mock.ExpectQuery("SELECT am.id, am.content, am.scope, am.namespace, am.created_at,").
|
||||
WillReturnRows(rows)
|
||||
|
||||
w := adminGet(t, h)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var memories []interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &memories); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if len(memories) != 0 {
|
||||
t.Errorf("expected 0 memories, got %d", len(memories))
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminMemories_Export_QueryError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newAdminMemoriesHandler()
|
||||
|
||||
mock.ExpectQuery("SELECT am.id, am.content, am.scope, am.namespace, am.created_at,").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
w := adminGet(t, h)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminMemories_Export_RedactsSecrets(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newAdminMemoriesHandler()
|
||||
|
||||
// Content with a secret pattern. Export must call redactSecrets and return
|
||||
// the redacted form, not the raw credential.
|
||||
secretContent := "Remember to use OPENAI_API_KEY=sk-1234567890abcdefgh for the model"
|
||||
redacted, _ := redactSecrets("my-workspace", secretContent)
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
rows := sqlmock.NewRows([]string{"id", "content", "scope", "namespace", "created_at", "workspace_name"}).
|
||||
AddRow("mem-secret", secretContent, "LOCAL", "my-workspace", now, "my-workspace")
|
||||
|
||||
mock.ExpectQuery("SELECT am.id, am.content, am.scope, am.namespace, am.created_at,").
|
||||
WillReturnRows(rows)
|
||||
|
||||
w := adminGet(t, h)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var memories []map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &memories); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if len(memories) != 1 {
|
||||
t.Fatalf("expected 1 memory, got %d", len(memories))
|
||||
}
|
||||
// The exported content must be the REDACTED version, not the raw secret.
|
||||
if content, ok := memories[0]["content"].(string); ok {
|
||||
if content == secretContent {
|
||||
t.Errorf("Export returned raw secret %q — F1084 regression: redactSecrets not called", secretContent)
|
||||
}
|
||||
if content != redacted {
|
||||
t.Errorf("Export content = %q, want redacted %q", content, redacted)
|
||||
}
|
||||
// Confirm the redacted version doesn't contain the raw key fragment.
|
||||
if len(content) > 10 && content == "OPENAI_API_KEY=[REDACTED:" {
|
||||
t.Errorf("redaction appears incomplete: %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Import tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestAdminMemories_Import_Success(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newAdminMemoriesHandler()
|
||||
|
||||
// Workspace lookup returns one row.
|
||||
mock.ExpectQuery("SELECT id FROM workspaces WHERE name = \\$1").
|
||||
WithArgs("my-workspace").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-uuid-1"))
|
||||
|
||||
// Duplicate check returns false.
|
||||
mock.ExpectQuery("SELECT EXISTS").
|
||||
WithArgs("ws-uuid-1", sqlmock.AnyArg(), "LOCAL").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
|
||||
|
||||
// Insert succeeds. Handler uses 4-arg INSERT when created_at is absent.
|
||||
mock.ExpectExec("INSERT INTO agent_memories").
|
||||
WithArgs("ws-uuid-1", sqlmock.AnyArg(), "LOCAL", "general").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
w := adminPost(t, h, []map[string]interface{}{
|
||||
{
|
||||
"content": "important fact",
|
||||
"scope": "LOCAL",
|
||||
"workspace_name": "my-workspace",
|
||||
},
|
||||
})
|
||||
|
||||
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("failed to parse response: %v", err)
|
||||
}
|
||||
if resp["imported"].(float64) != 1 {
|
||||
t.Errorf("imported = %v, want 1", resp["imported"])
|
||||
}
|
||||
if resp["skipped"].(float64) != 0 {
|
||||
t.Errorf("skipped = %v, want 0", resp["skipped"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
// Issue #1733: every legacy SQL-path test in this file was removed when
|
||||
// the v1 fallback was deleted from AdminMemoriesHandler. The v2-plugin
|
||||
// coverage (the only path now) lives in admin_memories_cutover_test.go:
|
||||
//
|
||||
// - TestExport_RoutesThroughPluginWhenCutoverActive
|
||||
// - TestExport_DeduplicatesByMemoryID
|
||||
// - TestExport_SkipsWorkspaceWhenResolverFails
|
||||
// - TestExport_SkipsWorkspaceWhenPluginSearchFails
|
||||
// - TestExport_WorkspacesQueryFails
|
||||
// - TestExport_EmptyReadable
|
||||
// - TestExport_RedactsSecretsInPluginPath
|
||||
// - TestExport_BatchesPluginCallsByRoot
|
||||
// - TestExport_IncludesEveryMembersPrivateNamespace
|
||||
// - TestImport_RoutesThroughPluginWhenCutoverActive
|
||||
// - TestImport_SkipsUnknownWorkspace
|
||||
// - TestImport_PluginUpsertNamespaceError
|
||||
// - TestImport_PluginCommitError
|
||||
// - TestImport_RedactsBeforePluginSeesContent
|
||||
// - TestImport_SkipsUnknownScope
|
||||
// - TestImport_SkipsWhenResolverErrors
|
||||
// - TestExport_503WhenPluginNotWired (new in A1)
|
||||
// - TestImport_503WhenPluginNotWired (new in A1)
|
||||
//
|
||||
// Only the JSON-envelope rejection test stays here because it runs
|
||||
// before the plugin gate.
|
||||
|
||||
// TestAdminMemories_Import_InvalidJSON verifies that a malformed
|
||||
// payload is rejected with HTTP 400 before any plugin or DB call is
|
||||
// attempted. This guards the request-decode path independent of the
|
||||
// memory backend choice.
|
||||
func TestAdminMemories_Import_InvalidJSON(t *testing.T) {
|
||||
_ = setupTestDB(t)
|
||||
h := newAdminMemoriesHandler()
|
||||
h := NewAdminMemoriesHandler()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -227,175 +53,3 @@ func TestAdminMemories_Import_InvalidJSON(t *testing.T) {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminMemories_Import_WorkspaceNotFound_SkipsEntry(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newAdminMemoriesHandler()
|
||||
|
||||
// Workspace lookup returns no rows.
|
||||
mock.ExpectQuery("SELECT id FROM workspaces WHERE name = \\$1").
|
||||
WithArgs("ghost-workspace").
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
w := adminPost(t, h, []map[string]interface{}{
|
||||
{
|
||||
"content": "some fact",
|
||||
"scope": "LOCAL",
|
||||
"workspace_name": "ghost-workspace",
|
||||
},
|
||||
})
|
||||
|
||||
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("failed to parse response: %v", err)
|
||||
}
|
||||
if resp["skipped"].(float64) != 1 {
|
||||
t.Errorf("skipped = %v, want 1 (workspace not found)", resp["skipped"])
|
||||
}
|
||||
if resp["imported"].(float64) != 0 {
|
||||
t.Errorf("imported = %v, want 0", resp["imported"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminMemories_Import_DuplicateSkipped(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newAdminMemoriesHandler()
|
||||
|
||||
// Workspace lookup succeeds.
|
||||
mock.ExpectQuery("SELECT id FROM workspaces WHERE name = \\$1").
|
||||
WithArgs("my-workspace").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-uuid-1"))
|
||||
|
||||
// Duplicate check returns true → entry is skipped.
|
||||
mock.ExpectQuery("SELECT EXISTS").
|
||||
WithArgs("ws-uuid-1", sqlmock.AnyArg(), "LOCAL").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
|
||||
w := adminPost(t, h, []map[string]interface{}{
|
||||
{
|
||||
"content": "already stored fact",
|
||||
"scope": "LOCAL",
|
||||
"workspace_name": "my-workspace",
|
||||
},
|
||||
})
|
||||
|
||||
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("failed to parse response: %v", err)
|
||||
}
|
||||
if resp["skipped"].(float64) != 1 {
|
||||
t.Errorf("skipped = %v, want 1 (duplicate)", resp["skipped"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminMemories_Import_RedactsSecretsBeforeDedup verifies F1085 (#1132):
|
||||
// redactSecrets is called BEFORE the deduplication check so that two backups
|
||||
// with the same original secret each get the same placeholder and dedup works.
|
||||
// The DB dedup query must receive the REDACTED content, not the raw credential.
|
||||
func TestAdminMemories_Import_RedactsSecretsBeforeDedup(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newAdminMemoriesHandler()
|
||||
|
||||
rawContent := "the key is OPENAI_API_KEY=sk-1234567890abcdefgh"
|
||||
redacted, changed := redactSecrets("my-workspace", rawContent)
|
||||
if !changed {
|
||||
t.Fatalf("precondition: redactSecrets must change the test content")
|
||||
}
|
||||
|
||||
// Workspace lookup.
|
||||
mock.ExpectQuery("SELECT id FROM workspaces WHERE name = \\$1").
|
||||
WithArgs("my-workspace").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-uuid-1"))
|
||||
|
||||
// Dedup check — the sqlmock must be set up for the REDACTED content,
|
||||
// because Import calls redactSecrets before running the dedup query.
|
||||
// If redactSecrets is not called, the mock would match on rawContent instead.
|
||||
mock.ExpectQuery("SELECT EXISTS").
|
||||
WithArgs("ws-uuid-1", redacted, "LOCAL").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
|
||||
|
||||
// Insert — receives the redacted content (not raw). Handler uses the
|
||||
// 4-arg INSERT when created_at is absent from the payload.
|
||||
mock.ExpectExec("INSERT INTO agent_memories").
|
||||
WithArgs("ws-uuid-1", redacted, "LOCAL", "general").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
w := adminPost(t, h, []map[string]interface{}{
|
||||
{
|
||||
"content": rawContent,
|
||||
"scope": "LOCAL",
|
||||
"workspace_name": "my-workspace",
|
||||
},
|
||||
})
|
||||
|
||||
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("failed to parse response: %v", err)
|
||||
}
|
||||
if resp["imported"].(float64) != 1 {
|
||||
t.Errorf("imported = %v, want 1", resp["imported"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v (F1085 regression: redactSecrets not called before dedup)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminMemories_Import_PreservesCreatedAt(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newAdminMemoriesHandler()
|
||||
|
||||
origTime := "2026-01-15T10:30:00Z"
|
||||
|
||||
// Workspace lookup.
|
||||
mock.ExpectQuery("SELECT id FROM workspaces WHERE name = \\$1").
|
||||
WithArgs("my-workspace").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-uuid-1"))
|
||||
|
||||
// Dedup check.
|
||||
mock.ExpectQuery("SELECT EXISTS").
|
||||
WithArgs("ws-uuid-1", sqlmock.AnyArg(), "LOCAL").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
|
||||
|
||||
// Insert with created_at — must use the 5-arg INSERT.
|
||||
mock.ExpectExec("INSERT INTO agent_memories").
|
||||
WithArgs("ws-uuid-1", sqlmock.AnyArg(), "LOCAL", "general", origTime).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
w := adminPost(t, h, []map[string]interface{}{
|
||||
{
|
||||
"content": "a fact",
|
||||
"scope": "LOCAL",
|
||||
"workspace_name": "my-workspace",
|
||||
"created_at": origTime,
|
||||
},
|
||||
})
|
||||
|
||||
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("failed to parse response: %v", err)
|
||||
}
|
||||
if resp["imported"].(float64) != 1 {
|
||||
t.Errorf("imported = %v, want 1", resp["imported"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ func TestAdminTestToken_EnabledViaFlagEvenInProd(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
t.Setenv("MOLECULE_ENV", "production")
|
||||
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1")
|
||||
t.Setenv("ADMIN_TOKEN", "")
|
||||
|
||||
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
|
||||
WithArgs("ws-1").
|
||||
@@ -58,6 +59,7 @@ func TestAdminTestToken_EnabledViaFlagEvenInProd(t *testing.T) {
|
||||
func TestAdminTestToken_WorkspaceNotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
t.Setenv("MOLECULE_ENV", "development")
|
||||
t.Setenv("ADMIN_TOKEN", "")
|
||||
|
||||
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
|
||||
WithArgs("missing").
|
||||
@@ -75,6 +77,7 @@ func TestAdminTestToken_WorkspaceNotFound(t *testing.T) {
|
||||
func TestAdminTestToken_HappyPath_TokenValidates(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
t.Setenv("MOLECULE_ENV", "development")
|
||||
t.Setenv("ADMIN_TOKEN", "")
|
||||
|
||||
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
|
||||
WithArgs("ws-1").
|
||||
|
||||
@@ -44,8 +44,7 @@ func NewWorkspaceImageService(docker *dockerclient.Client) *WorkspaceImageServic
|
||||
// AllRuntimes is the canonical list mirroring docs/workspace-runtime-package.md.
|
||||
// Update both when a new template is added.
|
||||
var AllRuntimes = []string{
|
||||
"claude-code", "langgraph", "autogen",
|
||||
"hermes", "openclaw",
|
||||
"claude-code", "codex", "hermes", "openclaw",
|
||||
}
|
||||
|
||||
// RefreshResult is the per-call outcome surfaced to HTTP callers AND logged
|
||||
|
||||
@@ -104,6 +104,9 @@ 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)
|
||||
}
|
||||
@@ -514,6 +517,9 @@ 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)
|
||||
|
||||
@@ -389,7 +389,7 @@ func (h *DelegationHandler) executeDelegation(ctx context.Context, sourceID, tar
|
||||
})
|
||||
log.Printf("Delegation %s: step=proxying_a2a_request", delegationID)
|
||||
|
||||
status, respBody, proxyErr := h.workspace.proxyA2ARequest(ctx, targetID, a2aBody, sourceID, true)
|
||||
status, respBody, proxyErr := h.workspace.proxyA2ARequest(ctx, targetID, a2aBody, sourceID, true, false)
|
||||
log.Printf("Delegation %s: step=proxy_done status=%d bodyLen=%d err=%v", delegationID, status, len(respBody), proxyErr)
|
||||
|
||||
// When proxyA2ARequest returns an error but we have a non-empty response body
|
||||
@@ -418,7 +418,7 @@ func (h *DelegationHandler) executeDelegation(ctx context.Context, sourceID, tar
|
||||
case <-ctx.Done():
|
||||
// outer timeout hit before retry window elapsed
|
||||
case <-time.After(delegationRetryDelay):
|
||||
status, respBody, proxyErr = h.workspace.proxyA2ARequest(ctx, targetID, a2aBody, sourceID, true)
|
||||
status, respBody, proxyErr = h.workspace.proxyA2ARequest(ctx, targetID, a2aBody, sourceID, true, false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -393,6 +393,9 @@ 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,6 +49,9 @@ 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)
|
||||
}
|
||||
|
||||
@@ -87,5 +90,8 @@ 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)
|
||||
}
|
||||
|
||||
@@ -159,7 +159,8 @@ func generateAppInstallationToken() (string, time.Time, error) {
|
||||
req, _ := http.NewRequest("POST", fmt.Sprintf("https://api.github.com/app/installations/%d/access_tokens", installID), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+signed)
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func TestWorkspaceCreate_WithParentID(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
body := `{"name":"Child Agent","parent_id":"parent-ws-123"}`
|
||||
body := `{"name":"Child Agent","model":"anthropic:claude-opus-4-7","parent_id":"parent-ws-123"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -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","canvas":{"x":10,"y":20}}`
|
||||
body := `{"name":"CC Agent","tier":2,"runtime":"claude-code","model":"sonnet","canvas":{"x":10,"y":20}}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -301,7 +301,7 @@ func TestWorkspaceCreate_MaxConcurrentTasksOverride(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
body := `{"name":"Leader Agent","runtime":"claude-code","max_concurrent_tasks":3}`
|
||||
body := `{"name":"Leader Agent","runtime":"claude-code","model":"sonnet","max_concurrent_tasks":3}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
|
||||
@@ -777,6 +777,103 @@ 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)
|
||||
|
||||
@@ -386,7 +386,13 @@ func TestWorkspaceCreate(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
body := `{"name":"Test Agent","canvas":{"x":100,"y":200}}`
|
||||
// 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}}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/contract"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -485,65 +486,45 @@ func TestMCPHandler_ListPeers_ReturnsSiblings(t *testing.T) {
|
||||
// tools/call — commit_memory
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestMCPHandler_CommitMemory_LocalScope_Success(t *testing.T) {
|
||||
// Issue #1733: the legacy SQL success-path tests for commit_memory and
|
||||
// recall_memory have been removed — the v2 plugin is the only backend
|
||||
// and its success paths are covered by:
|
||||
// - TestToolCommitMemory_RoutesThroughV2WhenWired (legacy-shim test)
|
||||
// - TestToolRecallMemory_RoutesThroughV2WhenWired (legacy-shim test)
|
||||
// - Every test in mcp_tools_memory_v2_test.go
|
||||
// The unwired-path tests live in mcp_tools_memory_legacy_shim_test.go
|
||||
// (TestToolCommitMemory_ErrorsWhenV2Unwired and its recall sibling).
|
||||
//
|
||||
// The two scope-blocked tests below remain because they validate the
|
||||
// OFFSEC-001 JSON-RPC scrub layer (mcp.go dispatchRPC), which is
|
||||
// orthogonal to the memory backend. After A1 the underlying error
|
||||
// shifts from "GLOBAL scope is not permitted" to "memory plugin is
|
||||
// not configured" — but the client-visible message stays "tool call
|
||||
// failed", which is what the scrub assertion actually proves.
|
||||
|
||||
// TestMCPHandler_CommitMemory_GlobalScope_ScrubsInternalError verifies the
|
||||
// OFFSEC-001 / #259 scrub contract on the commit_memory tool: the GLOBAL
|
||||
// scope block at scopeToWritableNamespace produces an internal error
|
||||
// containing the tokens "GLOBAL", "scope", "permitted", "bridge",
|
||||
// "LOCAL", "TEAM" — every one of those MUST be scrubbed to the constant
|
||||
// "tool call failed" + code -32000 before reaching the JSON-RPC wire.
|
||||
//
|
||||
// Issue #1747 review fixed the test setup: the handler is now wired
|
||||
// with a v2 plugin + resolver stub so the request actually reaches
|
||||
// the GLOBAL-block path in commitMemoryLegacyShim →
|
||||
// scopeToWritableNamespace. Without that wiring, the handler errors
|
||||
// earlier in `memoryV2Available()` with "memory plugin is not
|
||||
// configured", and the leaked-tokens assertion below becomes
|
||||
// vacuously true — passes even if the entire scrub layer in
|
||||
// mcp.go:dispatchRPC is deleted. The wired path is the only one
|
||||
// that actually pins the OFFSEC-001 contract.
|
||||
func TestMCPHandler_CommitMemory_GlobalScope_ScrubsInternalError(t *testing.T) {
|
||||
h, mock := newMCPHandler(t)
|
||||
|
||||
mock.ExpectExec("INSERT INTO agent_memories").
|
||||
WithArgs(sqlmock.AnyArg(), "ws-1", "important fact", "LOCAL", "ws-1").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
w := mcpPost(t, h, "ws-1", map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 9,
|
||||
"method": "tools/call",
|
||||
"params": map[string]interface{}{
|
||||
"name": "commit_memory",
|
||||
"arguments": map[string]interface{}{
|
||||
"content": "important fact",
|
||||
"scope": "LOCAL",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp mcpResponse
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("unexpected error: %+v", resp.Error)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPHandler_CommitMemory_GlobalScope_Blocked_ScrubsInternalError verifies
|
||||
// two contracts at once on the GLOBAL-scope-blocked path:
|
||||
//
|
||||
// 1. C3 invariant (commit_memory with scope=GLOBAL aborts on the MCP bridge
|
||||
// before touching the DB), AND
|
||||
// 2. OFFSEC-001 / #259 scrub contract (commit 7d1a189f): the JSON-RPC error
|
||||
// returned to the client is a CONSTANT — code=-32000, message="tool call
|
||||
// failed" — with the production-internal err.Error() text logged
|
||||
// server-side, never reflected back to the caller.
|
||||
//
|
||||
// Prior to this rename the test asserted that the client-visible message
|
||||
// CONTAINED the substring "GLOBAL", which was the human-readable internal
|
||||
// error from toolCommitMemory. mc#664 Class 2 flipped that assertion the
|
||||
// right way around: now the test FAILS if the scrub regresses (i.e. if the
|
||||
// internal string is ever reflected back to the wire), and PASSES iff the
|
||||
// scrubbed constant reaches the client.
|
||||
//
|
||||
// Coupling note: the constant string "tool call failed" and the code -32000
|
||||
// are the same values asserted by
|
||||
// TestMCPHandler_dispatchRPC_UnknownTool_ReturnsConstantMessage — both are
|
||||
// the OFFSEC-001 contract for the dispatch-failure branch in mcp.go (the
|
||||
// third err.Error() leak that 7d1a189f scrubbed). If those constants ever
|
||||
// change, both tests must move together.
|
||||
func TestMCPHandler_CommitMemory_GlobalScope_Blocked_ScrubsInternalError(t *testing.T) {
|
||||
h, mock := newMCPHandler(t)
|
||||
// No DB expectations — handler must abort before touching the DB (C3).
|
||||
// Wire v2 stubs so toolCommitMemory → commitMemoryLegacyShim
|
||||
// actually runs (without v2, it short-circuits with the
|
||||
// "plugin not configured" error that doesn't contain the
|
||||
// leaked-token strings we're asserting on).
|
||||
h.withMemoryV2APIs(&stubMemoryPlugin{}, rootNamespaceResolver())
|
||||
|
||||
w := mcpPost(t, h, "ws-1", map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
@@ -585,7 +566,7 @@ func TestMCPHandler_CommitMemory_GlobalScope_Blocked_ScrubsInternalError(t *test
|
||||
}
|
||||
|
||||
// (3) OFFSEC-001 negative assertions — the internal err.Error() text
|
||||
// from toolCommitMemory ("GLOBAL scope is not permitted via the MCP
|
||||
// from scopeToWritableNamespace ("GLOBAL scope is not permitted via the MCP
|
||||
// bridge — use LOCAL or TEAM") must NOT appear in the client-visible
|
||||
// message. Each token below is a distinct substring of that internal
|
||||
// string; if ANY leaks through, the scrub in mcp.go dispatchRPC has
|
||||
@@ -610,41 +591,43 @@ func TestMCPHandler_CommitMemory_GlobalScope_Blocked_ScrubsInternalError(t *test
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPHandler_CommitMemory_SecretInContent_IsRedactedBeforeInsert verifies
|
||||
// the SAFE-T1201 (#838) fix on the MCP bridge path. PR #881 closed the HTTP
|
||||
// handler but missed this one — an agent tool-call carrying plain-text
|
||||
// credentials must have them scrubbed before the INSERT reaches the DB.
|
||||
//
|
||||
// The test asserts via the sqlmock `WithArgs` matcher that the content column
|
||||
// binds the REDACTED form, not the raw input. sqlmock verifies the exact arg
|
||||
// values, so a regression (removing the redactSecrets call) would fail with
|
||||
// "argument mismatch" rather than silently persisting the secret.
|
||||
func TestMCPHandler_CommitMemory_SecretInContent_IsRedactedBeforeInsert(t *testing.T) {
|
||||
h, mock := newMCPHandler(t)
|
||||
// Issue #1733: the legacy SQL-path redaction tests for commit_memory
|
||||
// (SecretInContent_IsRedactedBeforeInsert, CleanContent_PassesThrough)
|
||||
// have been removed. The v2 plugin path performs the same redaction
|
||||
// (mcp_tools_memory_v2.go:122 + :242); its coverage lives in
|
||||
// mcp_tools_memory_v2_test.go.
|
||||
|
||||
// TestMCPHandler_CommitMemory_LegacyName_RedactionAtPlugin verifies that
|
||||
// the LEGACY MCP tool name `commit_memory` (the one most agents
|
||||
// actually call — `commit_memory_v2` is the underlying handler the
|
||||
// shim delegates to) still redacts secret-shaped content before the
|
||||
// payload reaches the v2 plugin. The deleted SQL-path version of this
|
||||
// test pinned the same contract against `agent_memories` INSERT
|
||||
// arguments; #1747 review (finding N6) noted the legacy-name path
|
||||
// had no direct equivalent post-A1. This test fills that gap by
|
||||
// capturing the MemoryWrite the stub plugin receives.
|
||||
func TestMCPHandler_CommitMemory_LegacyName_RedactionAtPlugin(t *testing.T) {
|
||||
h, _ := newMCPHandler(t)
|
||||
|
||||
var captured contract.MemoryWrite
|
||||
plugin := &stubMemoryPlugin{
|
||||
commitFn: func(_ context.Context, _ string, body contract.MemoryWrite) (*contract.MemoryWriteResponse, error) {
|
||||
captured = body
|
||||
return &contract.MemoryWriteResponse{ID: "mem-x", Namespace: "workspace:root-1"}, nil
|
||||
},
|
||||
}
|
||||
h.withMemoryV2APIs(plugin, rootNamespaceResolver())
|
||||
|
||||
// Content with three distinct secret patterns covered by redactSecrets:
|
||||
// - env-var assignment (ANTHROPIC_API_KEY=)
|
||||
// - Bearer token
|
||||
// - sk-… prefixed key
|
||||
rawContent := "key=ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxx auth=Bearer ghp_yyyyyyyyyyyyy note=sk-proj-zzzzzzzzzzzzzzzzzzzz"
|
||||
|
||||
// Derive what redactSecrets will produce so the sqlmock arg match is
|
||||
// exact. This keeps the test brittle-on-purpose: if redactSecrets's
|
||||
// output shape changes, this test must be re-derived, which surfaces
|
||||
// the change during review.
|
||||
expected, changed := redactSecrets("ws-1", rawContent)
|
||||
wantRedacted, changed := redactSecrets("root-1", rawContent)
|
||||
if !changed {
|
||||
t.Fatalf("precondition failed — redactSecrets must change the test content; got unchanged %q", expected)
|
||||
t.Fatalf("precondition failed — redactSecrets must change the test content; got %q", wantRedacted)
|
||||
}
|
||||
if bytes.Contains([]byte(expected), []byte("sk-ant-xxxxxxxxxxxxxxxx")) {
|
||||
t.Fatalf("precondition failed — redacted content still contains raw secret: %s", expected)
|
||||
if bytes.Contains([]byte(wantRedacted), []byte("sk-ant-xxxxxxxxxxxxxxxx")) {
|
||||
t.Fatalf("precondition failed — redacted content still contains raw secret: %s", wantRedacted)
|
||||
}
|
||||
|
||||
mock.ExpectExec("INSERT INTO agent_memories").
|
||||
WithArgs(sqlmock.AnyArg(), "ws-1", expected, "LOCAL", "ws-1").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
w := mcpPost(t, h, "ws-1", map[string]interface{}{
|
||||
w := mcpPost(t, h, "root-1", map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 99,
|
||||
"method": "tools/call",
|
||||
@@ -656,52 +639,32 @@ func TestMCPHandler_CommitMemory_SecretInContent_IsRedactedBeforeInsert(t *testi
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp mcpResponse
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("response is not valid JSON: %v", err)
|
||||
}
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("unexpected JSON-RPC error: %+v", resp.Error)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock mismatch — content was NOT redacted before insert: %v", err)
|
||||
|
||||
// The plugin must have seen the REDACTED content, not the raw
|
||||
// secret. If this trips, redaction in the legacy-shim → v2 path
|
||||
// has regressed and credentials are flowing through to the
|
||||
// plugin's memory_records table.
|
||||
if captured.Content == "" {
|
||||
t.Fatal("plugin.CommitMemory was not called — the shim short-circuited before reaching v2")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPHandler_CommitMemory_CleanContent_PassesThrough confirms that the
|
||||
// redactor is a no-op on content with no credentials — a regression where
|
||||
// redactSecrets corrupted benign content would be a user-visible bug.
|
||||
func TestMCPHandler_CommitMemory_CleanContent_PassesThrough(t *testing.T) {
|
||||
h, mock := newMCPHandler(t)
|
||||
|
||||
cleanContent := "the quick brown fox jumps over the lazy dog — no secrets here"
|
||||
|
||||
// Bind the exact string — no wildcards — so that any transformation
|
||||
// (whitespace, case, truncation) would fail the arg match.
|
||||
mock.ExpectExec("INSERT INTO agent_memories").
|
||||
WithArgs(sqlmock.AnyArg(), "ws-1", cleanContent, "TEAM", "ws-1").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
w := mcpPost(t, h, "ws-1", map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 100,
|
||||
"method": "tools/call",
|
||||
"params": map[string]interface{}{
|
||||
"name": "commit_memory",
|
||||
"arguments": map[string]interface{}{
|
||||
"content": cleanContent,
|
||||
"scope": "TEAM",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
if captured.Content == rawContent {
|
||||
t.Errorf("legacy commit_memory leaked raw secret to plugin: %q", captured.Content)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("clean content should pass through unchanged: %v", err)
|
||||
if captured.Content != wantRedacted {
|
||||
t.Errorf("captured.Content = %q, want redacted %q", captured.Content, wantRedacted)
|
||||
}
|
||||
if bytes.Contains([]byte(captured.Content), []byte("sk-ant-xxxxxxxxxxxxxxxx")) {
|
||||
t.Errorf("captured.Content still contains raw API key fragment: %s", captured.Content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -709,14 +672,17 @@ func TestMCPHandler_CommitMemory_CleanContent_PassesThrough(t *testing.T) {
|
||||
// tools/call — recall_memory
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestMCPHandler_RecallMemory_GlobalScope_Blocked_ScrubsInternalError verifies
|
||||
// C3 (GLOBAL scope blocked on MCP bridge) is enforced and that the OFFSEC-001
|
||||
// scrub contract applies: the client-visible error.message is the constant
|
||||
// "tool call failed", NOT the descriptive internal reason. The internal reason
|
||||
// ("GLOBAL scope is not permitted via the MCP bridge") is logged server-side
|
||||
// but must never reach the wire.
|
||||
func TestMCPHandler_RecallMemory_GlobalScope_Blocked_ScrubsInternalError(t *testing.T) {
|
||||
// TestMCPHandler_RecallMemory_GlobalScope_ScrubsInternalError mirrors the
|
||||
// commit_memory scrub test on the recall_memory path. Same #1747 review
|
||||
// fix applied: wire v2 stubs so the request reaches the GLOBAL-block
|
||||
// path in scopeToReadableNamespaces (which produces the same "GLOBAL
|
||||
// scope is not permitted via the MCP bridge" internal error that the
|
||||
// leaked-tokens loop below tests for). Without v2 stubs the handler
|
||||
// short-circuits on `memoryV2Available()` and the leaked-tokens loop
|
||||
// becomes vacuously true.
|
||||
func TestMCPHandler_RecallMemory_GlobalScope_ScrubsInternalError(t *testing.T) {
|
||||
h, mock := newMCPHandler(t)
|
||||
h.withMemoryV2APIs(&stubMemoryPlugin{}, rootNamespaceResolver())
|
||||
// No DB expectations — handler must abort before touching the DB.
|
||||
|
||||
w := mcpPost(t, h, "ws-1", map[string]interface{}{
|
||||
@@ -770,42 +736,11 @@ func TestMCPHandler_RecallMemory_GlobalScope_Blocked_ScrubsInternalError(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPHandler_RecallMemory_LocalScope_Empty(t *testing.T) {
|
||||
h, mock := newMCPHandler(t)
|
||||
|
||||
mock.ExpectQuery("SELECT id, content, scope, created_at").
|
||||
WithArgs("ws-1", "").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "content", "scope", "created_at"}))
|
||||
|
||||
w := mcpPost(t, h, "ws-1", map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 12,
|
||||
"method": "tools/call",
|
||||
"params": map[string]interface{}{
|
||||
"name": "recall_memory",
|
||||
"arguments": map[string]interface{}{
|
||||
"query": "",
|
||||
"scope": "LOCAL",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
var resp mcpResponse
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("unexpected error: %+v", resp.Error)
|
||||
}
|
||||
result, _ := resp.Result.(map[string]interface{})
|
||||
content, _ := result["content"].([]interface{})
|
||||
item, _ := content[0].(map[string]interface{})
|
||||
text, _ := item["text"].(string)
|
||||
if text != "No memories found." {
|
||||
t.Errorf("expected 'No memories found.', got %q", text)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
// Issue #1733: TestMCPHandler_RecallMemory_LocalScope_Empty removed —
|
||||
// it asserted on the legacy SQL SELECT path. The v2 empty-result
|
||||
// rendering is covered by TestToolRecallMemory_RoutesThroughV2WhenWired
|
||||
// (mcp_tools_memory_legacy_shim_test.go) which uses a stub plugin that
|
||||
// returns an empty SearchResponse.
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// tools/call — send_message_to_user
|
||||
@@ -1141,6 +1076,8 @@ func TestIsSafeURL_Blocks169_254_Metadata(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsSafeURL_Blocks10xPrivate(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
|
||||
err := isSafeURL("http://10.0.0.1/agent")
|
||||
if err == nil {
|
||||
t.Errorf("isSafeURL: expected 10.x.x.x to be blocked, got nil")
|
||||
@@ -1148,6 +1085,8 @@ func TestIsSafeURL_Blocks10xPrivate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsSafeURL_Blocks172Private(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
|
||||
err := isSafeURL("http://172.16.0.1/agent")
|
||||
if err == nil {
|
||||
t.Errorf("isSafeURL: expected 172.16.0.0/12 to be blocked, got nil")
|
||||
@@ -1155,6 +1094,8 @@ func TestIsSafeURL_Blocks172Private(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsSafeURL_Blocks192_168Private(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
|
||||
err := isSafeURL("http://192.168.1.100/agent")
|
||||
if err == nil {
|
||||
t.Errorf("isSafeURL: expected 192.168.x.x to be blocked, got nil")
|
||||
@@ -1178,6 +1119,8 @@ func TestIsSafeURL_BlocksInvalidURL(t *testing.T) {
|
||||
// ==================== SSRF Defence — isPrivateOrMetadataIP ====================
|
||||
|
||||
func TestIsPrivateOrMetadataIP_10Range(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
|
||||
tests := []string{"10.0.0.0", "10.255.255.255", "10.1.2.3"}
|
||||
for _, ip := range tests {
|
||||
if !isPrivateOrMetadataIP(net.ParseIP(ip)) {
|
||||
@@ -1187,6 +1130,8 @@ func TestIsPrivateOrMetadataIP_10Range(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsPrivateOrMetadataIP_172Range(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
|
||||
tests := []string{"172.16.0.0", "172.31.255.255", "172.20.1.1"}
|
||||
for _, ip := range tests {
|
||||
if !isPrivateOrMetadataIP(net.ParseIP(ip)) {
|
||||
@@ -1196,6 +1141,8 @@ func TestIsPrivateOrMetadataIP_172Range(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsPrivateOrMetadataIP_192_168Range(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
|
||||
tests := []string{"192.168.0.0", "192.168.255.255", "192.168.1.1"}
|
||||
for _, ip := range tests {
|
||||
if !isPrivateOrMetadataIP(net.ParseIP(ip)) {
|
||||
|
||||
@@ -95,14 +95,18 @@ 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 {
|
||||
_ = scanPeers(rows)
|
||||
if scanErr := scanPeers(rows); scanErr != nil {
|
||||
log.Printf("MCP toolListPeers: sibling scan error: %v", scanErr)
|
||||
}
|
||||
}
|
||||
} 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 {
|
||||
_ = scanPeers(rows)
|
||||
if scanErr := scanPeers(rows); scanErr != nil {
|
||||
log.Printf("MCP toolListPeers: sibling scan error: %v", scanErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +116,9 @@ 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 {
|
||||
_ = scanPeers(rows)
|
||||
if scanErr := scanPeers(rows); scanErr != nil {
|
||||
log.Printf("MCP toolListPeers: children scan error: %v", scanErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +128,9 @@ 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 {
|
||||
_ = scanPeers(rows)
|
||||
if scanErr := scanPeers(rows); scanErr != nil {
|
||||
log.Printf("MCP toolListPeers: parent scan error: %v", scanErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,127 +363,24 @@ func (h *MCPHandler) toolSendMessageToUser(ctx context.Context, workspaceID stri
|
||||
}
|
||||
|
||||
func (h *MCPHandler) toolCommitMemory(ctx context.Context, workspaceID string, args map[string]interface{}) (string, error) {
|
||||
// PR-6 (RFC #2728) compat shim: when the v2 plugin is wired
|
||||
// (MEMORY_PLUGIN_URL set), translate legacy scope→namespace and
|
||||
// delegate. Otherwise fall through to the legacy DB path so
|
||||
// operators who haven't enabled the plugin yet keep working.
|
||||
if h.memoryV2Available() == nil {
|
||||
return h.commitMemoryLegacyShim(ctx, workspaceID, args)
|
||||
// Issue #1733 — v2 memory plugin is now the only path. The legacy
|
||||
// SQL fallback on `agent_memories` is gone; an unconfigured plugin
|
||||
// returns a clear error to the agent rather than silently writing
|
||||
// into a stale table no one reads.
|
||||
if err := h.memoryV2Available(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
content, _ := args["content"].(string)
|
||||
scope, _ := args["scope"].(string)
|
||||
if content == "" {
|
||||
return "", fmt.Errorf("content is required")
|
||||
}
|
||||
if scope == "" {
|
||||
scope = "LOCAL"
|
||||
}
|
||||
|
||||
// C3: GLOBAL scope is blocked on the MCP bridge.
|
||||
if scope == "GLOBAL" {
|
||||
return "", fmt.Errorf("GLOBAL scope is not permitted via the MCP bridge — use LOCAL or TEAM")
|
||||
}
|
||||
if scope != "LOCAL" && scope != "TEAM" {
|
||||
return "", fmt.Errorf("scope must be LOCAL or TEAM")
|
||||
}
|
||||
|
||||
memoryID := uuid.New().String()
|
||||
// SAFE-T1201 (#838): scrub known credential patterns before persistence so
|
||||
// plain-text API keys pulled in via tool responses can't land in the
|
||||
// memories table (and leak into shared TEAM scope). Reuses redactSecrets
|
||||
// already shipped for the HTTP path in PR #881 — this was the MCP-bridge
|
||||
// sibling the original fix missed. Runs on every write regardless of scope.
|
||||
content, _ = redactSecrets(workspaceID, content)
|
||||
_, err := h.database.ExecContext(ctx, `
|
||||
INSERT INTO agent_memories (id, workspace_id, content, scope, namespace)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, memoryID, workspaceID, content, scope, workspaceID)
|
||||
if err != nil {
|
||||
log.Printf("MCPHandler.commit_memory workspace=%s: %v", workspaceID, err)
|
||||
return "", fmt.Errorf("failed to save memory")
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`{"id":%q,"scope":%q}`, memoryID, scope), nil
|
||||
return h.commitMemoryLegacyShim(ctx, workspaceID, args)
|
||||
}
|
||||
|
||||
func (h *MCPHandler) toolRecallMemory(ctx context.Context, workspaceID string, args map[string]interface{}) (string, error) {
|
||||
// PR-6 (RFC #2728) compat shim: when the v2 plugin is wired,
|
||||
// route through it. Otherwise fall through to legacy DB path.
|
||||
if h.memoryV2Available() == nil {
|
||||
return h.recallMemoryLegacyShim(ctx, workspaceID, args)
|
||||
// Issue #1733 — v2 memory plugin is now the only path. Same shape
|
||||
// as toolCommitMemory: an unconfigured plugin is an error, not a
|
||||
// quiet read from a frozen v1 table.
|
||||
if err := h.memoryV2Available(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
query, _ := args["query"].(string)
|
||||
scope, _ := args["scope"].(string)
|
||||
|
||||
// C3: GLOBAL scope is blocked on the MCP bridge.
|
||||
if scope == "GLOBAL" {
|
||||
return "", fmt.Errorf("GLOBAL scope is not permitted via the MCP bridge — use LOCAL, TEAM, or empty")
|
||||
}
|
||||
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
|
||||
switch scope {
|
||||
case "LOCAL":
|
||||
rows, err = h.database.QueryContext(ctx, `
|
||||
SELECT id, content, scope, created_at
|
||||
FROM agent_memories
|
||||
WHERE workspace_id = $1 AND scope = 'LOCAL'
|
||||
AND ($2 = '' OR content ILIKE '%' || $2 || '%')
|
||||
ORDER BY created_at DESC LIMIT 50
|
||||
`, workspaceID, query)
|
||||
case "TEAM":
|
||||
// Team scope: parent + all siblings.
|
||||
rows, err = h.database.QueryContext(ctx, `
|
||||
SELECT m.id, m.content, m.scope, m.created_at
|
||||
FROM agent_memories m
|
||||
JOIN workspaces w ON w.id = m.workspace_id
|
||||
WHERE m.scope = 'TEAM'
|
||||
AND w.status != 'removed'
|
||||
AND (w.id = $1 OR w.parent_id = (SELECT parent_id FROM workspaces WHERE id = $1 AND parent_id IS NOT NULL))
|
||||
AND ($2 = '' OR m.content ILIKE '%' || $2 || '%')
|
||||
ORDER BY m.created_at DESC LIMIT 50
|
||||
`, workspaceID, query)
|
||||
default:
|
||||
// Empty scope → LOCAL only for the MCP bridge (GLOBAL excluded per C3).
|
||||
rows, err = h.database.QueryContext(ctx, `
|
||||
SELECT id, content, scope, created_at
|
||||
FROM agent_memories
|
||||
WHERE workspace_id = $1 AND scope IN ('LOCAL', 'TEAM')
|
||||
AND ($2 = '' OR content ILIKE '%' || $2 || '%')
|
||||
ORDER BY created_at DESC LIMIT 50
|
||||
`, workspaceID, query)
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("memory search failed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type memEntry struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
Scope string `json:"scope"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
var results []memEntry
|
||||
for rows.Next() {
|
||||
var e memEntry
|
||||
if err := rows.Scan(&e.ID, &e.Content, &e.Scope, &e.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return "", fmt.Errorf("memory scan error: %w", err)
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return "No memories found.", nil
|
||||
}
|
||||
b, _ := json.MarshalIndent(results, "", " ")
|
||||
return string(b), nil
|
||||
return h.recallMemoryLegacyShim(ctx, workspaceID, args)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -2,14 +2,13 @@ package handlers
|
||||
|
||||
// mcp_tools_memory_legacy_shim.go — translates legacy commit_memory /
|
||||
// recall_memory calls (scope-based) into the v2 plugin path
|
||||
// (namespace-based) when the v2 plugin is wired.
|
||||
// (namespace-based).
|
||||
//
|
||||
// Behavior:
|
||||
// - If h.memv2 is wired (MEMORY_PLUGIN_URL set + plugin reachable),
|
||||
// legacy tools translate scope→namespace and delegate to v2.
|
||||
// - If h.memv2 is NOT wired, legacy tools fall through to the
|
||||
// original DB-backed path in mcp_tools.go (zero behavior change
|
||||
// for operators who haven't enabled the plugin yet).
|
||||
// Issue #1733: v2 is now the only memory backend. Callers in
|
||||
// mcp_tools.go MUST verify h.memv2 is wired before invoking these
|
||||
// helpers (toolCommitMemory / toolRecallMemory both check
|
||||
// memoryV2Available and short-circuit with an error when not wired).
|
||||
// The previous "fall through to direct SQL" branch is gone.
|
||||
//
|
||||
// Translation:
|
||||
// commit: LOCAL → workspace:<self>
|
||||
|
||||
@@ -512,41 +512,38 @@ func TestToolRecallMemory_RoutesThroughV2WhenWired(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCommitMemory_FallsThroughToLegacyWhenV2Unwired(t *testing.T) {
|
||||
// V2 NOT wired (no withMemoryV2APIs call). Should hit the legacy
|
||||
// SQL path and write to agent_memories directly.
|
||||
db, mock, _ := sqlmock.New()
|
||||
// Issue #1733: v2 is the only path; commit/recall return a clear error
|
||||
// (not a silent SQL fallback) when MEMORY_PLUGIN_URL is unset.
|
||||
|
||||
func TestToolCommitMemory_ErrorsWhenV2Unwired(t *testing.T) {
|
||||
db, _, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
mock.ExpectExec("INSERT INTO agent_memories").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
h := &MCPHandler{database: db}
|
||||
h := &MCPHandler{database: db} // no withMemoryV2APIs → memv2 nil
|
||||
|
||||
_, err := h.toolCommitMemory(context.Background(), "root-1", map[string]interface{}{
|
||||
"content": "x",
|
||||
"scope": "LOCAL",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when v2 unwired, got nil")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("legacy SQL path not exercised: %v", err)
|
||||
if !strings.Contains(err.Error(), "MEMORY_PLUGIN_URL") {
|
||||
t.Errorf("error must hint at MEMORY_PLUGIN_URL: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolRecallMemory_FallsThroughToLegacyWhenV2Unwired(t *testing.T) {
|
||||
db, mock, _ := sqlmock.New()
|
||||
func TestToolRecallMemory_ErrorsWhenV2Unwired(t *testing.T) {
|
||||
db, _, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
mock.ExpectQuery("SELECT id, content, scope, created_at").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "content", "scope", "created_at"}))
|
||||
h := &MCPHandler{database: db}
|
||||
|
||||
_, err := h.toolRecallMemory(context.Background(), "root-1", map[string]interface{}{
|
||||
"scope": "LOCAL",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when v2 unwired, got nil")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("legacy SQL path not exercised: %v", err)
|
||||
if !strings.Contains(err.Error(), "MEMORY_PLUGIN_URL") {
|
||||
t.Errorf("error must hint at MEMORY_PLUGIN_URL: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,11 @@ func (h *MemoryHandler) List(c *gin.Context) {
|
||||
entry.Value = json.RawMessage(value)
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("Memory list iteration error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query iteration failed"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, entries)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -74,6 +75,34 @@ func TestMemoryList_DBError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemoryList_RowsErr_Returns500 verifies that a rows.Err() set during
|
||||
// iteration causes the handler to return 500 rather than partial results.
|
||||
func TestMemoryList_RowsErr_Returns500(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewMemoryHandler()
|
||||
|
||||
cols := []string{"key", "value", "version", "expires_at", "updated_at"}
|
||||
mock.ExpectQuery("SELECT key, value, version, expires_at, updated_at").
|
||||
WithArgs("ws-rowerr").
|
||||
WillReturnRows(sqlmock.NewRows(cols).
|
||||
AddRow("ok-key", []byte(`"val"`), int64(1), nil, time.Now()).
|
||||
RowError(0, errors.New("storage engine fault")))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-rowerr"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-rowerr/memory", nil)
|
||||
handler.List(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("rows.Err() must yield 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== GET /workspaces/:id/memory/:key (Get) ====================
|
||||
|
||||
func TestMemoryGet_Success(t *testing.T) {
|
||||
|
||||
@@ -69,10 +69,15 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
|
||||
model = defaults.Model
|
||||
}
|
||||
if model == "" {
|
||||
// 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)
|
||||
// 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)
|
||||
}
|
||||
tier := ws.Tier
|
||||
if tier == 0 {
|
||||
|
||||
@@ -712,6 +712,8 @@ func TestHeartbeat_SkipsRemovedRows(t *testing.T) {
|
||||
// ------------------------------------------------------------
|
||||
|
||||
func TestValidateAgentURL(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
|
||||
@@ -133,24 +133,30 @@ 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{}{}
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("loadRestartContextData: global_secrets rows.Err: %v", err)
|
||||
}
|
||||
}
|
||||
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{}{}
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("loadRestartContextData: workspace_secrets rows.Err: %v", err)
|
||||
}
|
||||
}
|
||||
for k := range keySet {
|
||||
d.EnvKeys = append(d.EnvKeys, k)
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestLoadRuntimesFromManifest_StripsDefaultSuffix(t *testing.T) {
|
||||
err := os.WriteFile(path, []byte(`{
|
||||
"workspace_templates": [
|
||||
{"name": "claude-code-default", "repo": "org/t-cc"},
|
||||
{"name": "langgraph", "repo": "org/t-lg"},
|
||||
{"name": "codex", "repo": "org/t-codex"},
|
||||
{"name": "hermes", "repo": "org/t-hermes"}
|
||||
]
|
||||
}`), 0600)
|
||||
@@ -33,7 +33,7 @@ func TestLoadRuntimesFromManifest_StripsDefaultSuffix(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
want := []string{"claude-code", "langgraph", "hermes", "external", "kimi", "kimi-cli"}
|
||||
want := []string{"claude-code", "codex", "hermes", "external", "kimi", "kimi-cli"}
|
||||
for _, w := range want {
|
||||
if _, ok := got[w]; !ok {
|
||||
t.Errorf("want runtime %q in set, missing. got=%v", w, keys(got))
|
||||
@@ -53,7 +53,7 @@ func TestLoadRuntimesFromManifest_ExternalAlwaysInjected(t *testing.T) {
|
||||
// in the set, because it's the BYO-compute meta-runtime.
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "manifest.json")
|
||||
_ = os.WriteFile(path, []byte(`{"workspace_templates":[{"name":"langgraph","repo":"org/t"}]}`), 0600)
|
||||
_ = os.WriteFile(path, []byte(`{"workspace_templates":[{"name":"codex","repo":"org/t"}]}`), 0600)
|
||||
|
||||
got, err := loadRuntimesFromManifest(path)
|
||||
if err != nil {
|
||||
@@ -97,11 +97,16 @@ func TestRealManifestParses(t *testing.T) {
|
||||
t.Fatalf("real manifest load: %v", err)
|
||||
}
|
||||
// Core runtimes we always expect to ship.
|
||||
for _, must := range []string{"langgraph", "hermes", "claude-code", "external", "kimi", "kimi-cli"} {
|
||||
for _, must := range []string{"codex", "hermes", "openclaw", "claude-code", "external", "kimi", "kimi-cli"} {
|
||||
if _, ok := got[must]; !ok {
|
||||
t.Errorf("real manifest missing runtime %q — got=%v", must, keys(got))
|
||||
}
|
||||
}
|
||||
for _, removed := range []string{"autogen", "langgraph"} {
|
||||
if _, ok := got[removed]; ok {
|
||||
t.Errorf("real manifest should not expose unsupported runtime %q — got=%v", removed, keys(got))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func keys(m map[string]struct{}) []string {
|
||||
|
||||
@@ -417,6 +417,9 @@ 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)
|
||||
}
|
||||
@@ -467,14 +470,19 @@ func (h *ScheduleHandler) Health(c *gin.Context) {
|
||||
|
||||
// Validate the caller's own bearer token (Phase 30.5 contract).
|
||||
// Skip for system callers and self-calls, same as the A2A proxy.
|
||||
// Post-RFC#637: canvas users may read schedule health too.
|
||||
isCanvasUser := false
|
||||
if !isSystemCaller(callerID) && callerID != workspaceID {
|
||||
if err := validateCallerToken(ctx, c, callerID); err != nil {
|
||||
var err error
|
||||
isCanvasUser, err = validateCallerToken(ctx, c, callerID)
|
||||
if err != nil {
|
||||
return // response already written with 401
|
||||
}
|
||||
}
|
||||
|
||||
// CanCommunicate gate — only peers in the org hierarchy may read health.
|
||||
if callerID != workspaceID && !isSystemCaller(callerID) {
|
||||
// Canvas users (human operators) bypass this gate.
|
||||
if callerID != workspaceID && !isSystemCaller(callerID) && !isCanvasUser {
|
||||
if !registry.CanCommunicate(callerID, workspaceID) {
|
||||
log.Printf("ScheduleHealth: access denied %s → %s", callerID, workspaceID)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
|
||||
|
||||
@@ -95,6 +95,7 @@ func TestSecurity_GetTemplates_NoAuth_Returns401(t *testing.T) {
|
||||
func TestSecurity_GetTemplates_FreshInstall_FailsOpen(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
t.Setenv("ADMIN_TOKEN", "")
|
||||
authDB, authMock := newFreshInstallAuthDB(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
@@ -152,6 +153,7 @@ func TestSecurity_GetOrgTemplates_NoAuth_Returns401(t *testing.T) {
|
||||
func TestSecurity_GetOrgTemplates_FreshInstall_FailsOpen(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
t.Setenv("ADMIN_TOKEN", "")
|
||||
authDB, authMock := newFreshInstallAuthDB(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
@@ -203,6 +203,11 @@ func (h *TemplatesHandler) List(c *gin.Context) {
|
||||
log.Printf("templates list: skip %s: yaml.Unmarshal: %v", id, err)
|
||||
return
|
||||
}
|
||||
runtime := strings.TrimSuffix(strings.TrimSpace(raw.Runtime), "-default")
|
||||
if _, ok := knownRuntimes[runtime]; !ok {
|
||||
log.Printf("templates list: skip %s: unsupported runtime %q", id, raw.Runtime)
|
||||
return
|
||||
}
|
||||
|
||||
// Model comes from either top-level (legacy) or runtime_config.model (current).
|
||||
model := raw.Model
|
||||
|
||||
@@ -51,6 +51,10 @@ func (h *TracesHandler) List(c *gin.Context) {
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read response body"})
|
||||
return
|
||||
}
|
||||
c.Data(resp.StatusCode, "application/json", body)
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ func (h *WebhookHandler) GitHub(c *gin.Context) {
|
||||
forwardBody,
|
||||
"webhook:github",
|
||||
true,
|
||||
false,
|
||||
)
|
||||
if proxyErr != nil {
|
||||
c.JSON(proxyErr.Status, proxyErr.Response)
|
||||
|
||||
@@ -321,6 +321,51 @@ 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
|
||||
|
||||
@@ -170,7 +170,7 @@ func TestWorkspaceBudget_Create_WithLimit(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
body := `{"name":"Budgeted Agent","budget_limit":1000}`
|
||||
body := `{"name":"Budgeted Agent","model":"anthropic:claude-opus-4-7","budget_limit":1000}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
handler.Create(c)
|
||||
|
||||
@@ -15,6 +15,10 @@ import (
|
||||
const (
|
||||
workspaceComputeDiskFloorGB = 30
|
||||
workspaceComputeDiskCeilingGB = 500
|
||||
workspaceDisplayMinWidth = 800
|
||||
workspaceDisplayMaxWidth = 3840
|
||||
workspaceDisplayMinHeight = 600
|
||||
workspaceDisplayMaxHeight = 2160
|
||||
)
|
||||
|
||||
type workspaceDisplayResponse struct {
|
||||
@@ -54,12 +58,12 @@ func validateWorkspaceCompute(compute models.WorkspaceCompute) error {
|
||||
return fmt.Errorf("unsupported compute.display.mode")
|
||||
}
|
||||
switch compute.Display.Protocol {
|
||||
case "", "dcv":
|
||||
case "", "dcv", "novnc":
|
||||
default:
|
||||
return fmt.Errorf("unsupported compute.display.protocol")
|
||||
}
|
||||
if compute.Display.Width < 0 || compute.Display.Height < 0 {
|
||||
return fmt.Errorf("compute.display width/height must be non-negative")
|
||||
if err := validateWorkspaceDisplayDimensions(compute.Display.Width, compute.Display.Height); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -71,13 +75,26 @@ func validateWorkspaceDisplayConfig(display models.WorkspaceComputeDisplay) erro
|
||||
return fmt.Errorf("unsupported compute.display.mode")
|
||||
}
|
||||
switch display.Protocol {
|
||||
case "", "dcv":
|
||||
case "", "dcv", "novnc":
|
||||
default:
|
||||
return fmt.Errorf("unsupported compute.display.protocol")
|
||||
}
|
||||
if display.Width < 0 || display.Height < 0 {
|
||||
if err := validateWorkspaceDisplayDimensions(display.Width, display.Height); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateWorkspaceDisplayDimensions(width, height int) error {
|
||||
if width < 0 || height < 0 {
|
||||
return fmt.Errorf("compute.display width/height must be non-negative")
|
||||
}
|
||||
if width != 0 && (width < workspaceDisplayMinWidth || width > workspaceDisplayMaxWidth) {
|
||||
return fmt.Errorf("compute.display.width must be between %d and %d", workspaceDisplayMinWidth, workspaceDisplayMaxWidth)
|
||||
}
|
||||
if height != 0 && (height < workspaceDisplayMinHeight || height > workspaceDisplayMaxHeight) {
|
||||
return fmt.Errorf("compute.display.height must be between %d and %d", workspaceDisplayMinHeight, workspaceDisplayMaxHeight)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -156,17 +173,13 @@ func withStoredCompute(ctx context.Context, workspaceID string, payload models.C
|
||||
}
|
||||
|
||||
// Display handles GET /workspaces/:id/display.
|
||||
//
|
||||
// Phase 1 only exposes the product contract and the non-display unavailable
|
||||
// state. Future desktop-control work will replace the display-enabled branch
|
||||
// with short-lived proxied DCV session details.
|
||||
func (h *WorkspaceHandler) Display(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
var raw string
|
||||
var raw, instanceID string
|
||||
err := db.DB.QueryRowContext(c.Request.Context(),
|
||||
`SELECT COALESCE(compute, '{}'::jsonb) FROM workspaces WHERE id = $1`,
|
||||
`SELECT COALESCE(compute, '{}'::jsonb), COALESCE(instance_id, '') FROM workspaces WHERE id = $1`,
|
||||
workspaceID,
|
||||
).Scan(&raw)
|
||||
).Scan(&raw, &instanceID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(404, gin.H{"error": "workspace not found"})
|
||||
@@ -196,6 +209,17 @@ func (h *WorkspaceHandler) Display(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if instanceID != "" {
|
||||
c.JSON(200, workspaceDisplayResponse{
|
||||
Available: true,
|
||||
Mode: compute.Display.Mode,
|
||||
Protocol: compute.Display.Protocol,
|
||||
Width: compute.Display.Width,
|
||||
Height: compute.Display.Height,
|
||||
Status: "ready",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(200, workspaceDisplayResponse{
|
||||
Available: false,
|
||||
Reason: "display_session_unavailable",
|
||||
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
||||
@@ -43,6 +45,20 @@ func TestValidateWorkspaceCompute_RejectsOutOfRangeRootVolume(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceCompute_RejectsOutOfRangeDisplayDimensions(t *testing.T) {
|
||||
for _, display := range []models.WorkspaceComputeDisplay{
|
||||
{Mode: "desktop-control", Protocol: "novnc", Width: 799, Height: 1080},
|
||||
{Mode: "desktop-control", Protocol: "novnc", Width: 3841, Height: 1080},
|
||||
{Mode: "desktop-control", Protocol: "novnc", Width: 1920, Height: 599},
|
||||
{Mode: "desktop-control", Protocol: "novnc", Width: 1920, Height: 2161},
|
||||
} {
|
||||
compute := models.WorkspaceCompute{Display: display}
|
||||
if err := validateWorkspaceCompute(compute); err == nil {
|
||||
t.Fatalf("validateWorkspaceCompute accepted display size %dx%d", display.Width, display.Height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceComputeJSON_OmitsEmptyNestedSections(t *testing.T) {
|
||||
got, err := workspaceComputeJSON(models.WorkspaceCompute{
|
||||
InstanceType: "m6i.xlarge",
|
||||
@@ -110,6 +126,7 @@ func TestWorkspaceCreate_WithInvalidCompute_ReturnsBadRequest(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
body := `{
|
||||
"name":"Oversized Agent",
|
||||
"model":"gpt-4",
|
||||
"compute":{"instance_type":"p4d.24xlarge"}
|
||||
}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
@@ -140,6 +157,7 @@ func TestBuildProvisionerConfig_CopiesComputeSizingFromPayload(t *testing.T) {
|
||||
Compute: models.WorkspaceCompute{
|
||||
InstanceType: "m6i.xlarge",
|
||||
Volume: models.WorkspaceComputeVolume{RootGB: 100},
|
||||
Display: models.WorkspaceComputeDisplay{Mode: "desktop-control", Protocol: "novnc", Width: 1920, Height: 1080},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
@@ -153,6 +171,12 @@ func TestBuildProvisionerConfig_CopiesComputeSizingFromPayload(t *testing.T) {
|
||||
if cfg.DiskGB != 100 {
|
||||
t.Errorf("cfg.DiskGB = %d, want 100", cfg.DiskGB)
|
||||
}
|
||||
if cfg.Display.Mode != "desktop-control" || cfg.Display.Protocol != "novnc" {
|
||||
t.Errorf("cfg.Display mode/protocol = %q/%q, want desktop-control/novnc", cfg.Display.Mode, cfg.Display.Protocol)
|
||||
}
|
||||
if cfg.Display.Width != 1920 || cfg.Display.Height != 1080 {
|
||||
t.Errorf("cfg.Display size = %dx%d, want 1920x1080", cfg.Display.Width, cfg.Display.Height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithStoredCompute_LoadsComputeForRestartPayloads(t *testing.T) {
|
||||
@@ -180,9 +204,9 @@ func TestWorkspaceDisplay_NonDisplayWorkspaceReturnsUnavailable(t *testing.T) {
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\), COALESCE\(instance_id, ''\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-no-display").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{}`))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute", "instance_id"}).AddRow(`{}`, ""))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -214,9 +238,9 @@ func TestWorkspaceDisplay_DisplayConfiguredReturnsSessionUnavailableContract(t *
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\), COALESCE\(instance_id, ''\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-display").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute", "instance_id"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"novnc","width":1920,"height":1080}}`, ""))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -241,8 +265,8 @@ func TestWorkspaceDisplay_DisplayConfiguredReturnsSessionUnavailableContract(t *
|
||||
if resp["status"] != "not_configured" {
|
||||
t.Fatalf("status = %v, want not_configured", resp["status"])
|
||||
}
|
||||
if resp["mode"] != "desktop-control" || resp["protocol"] != "dcv" {
|
||||
t.Fatalf("mode/protocol = %v/%v, want desktop-control/dcv", resp["mode"], resp["protocol"])
|
||||
if resp["mode"] != "desktop-control" || resp["protocol"] != "novnc" {
|
||||
t.Fatalf("mode/protocol = %v/%v, want desktop-control/novnc", resp["mode"], resp["protocol"])
|
||||
}
|
||||
if resp["width"] != float64(1920) || resp["height"] != float64(1080) {
|
||||
t.Fatalf("width/height = %v/%v, want 1920/1080", resp["width"], resp["height"])
|
||||
@@ -255,14 +279,89 @@ func TestWorkspaceDisplay_DisplayConfiguredReturnsSessionUnavailableContract(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplay_DisplayConfiguredWithInstanceReturnsAvailableSession(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\), COALESCE\(instance_id, ''\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-display").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute", "instance_id"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"novnc","width":1920,"height":1080}}`, "i-display123"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-display/display", nil)
|
||||
|
||||
handler.Display(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse display response: %v", err)
|
||||
}
|
||||
if resp["available"] != true {
|
||||
t.Fatalf("available = %v, want true", resp["available"])
|
||||
}
|
||||
if resp["viewer_url"] != nil {
|
||||
t.Fatalf("viewer_url = %v, want omitted; stream URL is minted by Take control", resp["viewer_url"])
|
||||
}
|
||||
if resp["reason"] != nil {
|
||||
t.Fatalf("reason = %v, want omitted", resp["reason"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplay_DisplayConfiguredWithoutInstanceReturnsUnavailable(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
workspaceID := "ws-display"
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\), COALESCE\(instance_id, ''\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(workspaceID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute", "instance_id"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"novnc","width":1920,"height":1080}}`, ""))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: workspaceID}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/"+workspaceID+"/display", nil)
|
||||
|
||||
handler.Display(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse display response: %v", err)
|
||||
}
|
||||
if resp["available"] != false {
|
||||
t.Fatalf("available = %v, want false", resp["available"])
|
||||
}
|
||||
if resp["viewer_url"] != nil {
|
||||
t.Fatalf("viewer_url = %v, want omitted for invalid viewer base", resp["viewer_url"])
|
||||
}
|
||||
if resp["reason"] != "display_session_unavailable" {
|
||||
t.Fatalf("reason = %v, want display_session_unavailable", resp["reason"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplay_IgnoresUnrelatedStoredComputeSizingDrift(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\), COALESCE\(instance_id, ''\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-display-sizing-drift").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"instance_type":"old.large","display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute", "instance_id"}).AddRow(`{"instance_type":"old.large","display":{"mode":"desktop-control","protocol":"novnc","width":1920,"height":1080}}`, ""))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -291,9 +390,9 @@ func TestWorkspaceDisplay_InvalidStoredDisplayConfigReturnsServerError(t *testin
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\), COALESCE\(instance_id, ''\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-invalid-display").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"vnc"}}`))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute", "instance_id"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"vnc"}}`, ""))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -316,3 +415,113 @@ func TestWorkspaceDisplay_InvalidStoredDisplayConfigReturnsServerError(t *testin
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplaySession_ProxiesThroughDisplayForward(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
t.Setenv("DISPLAY_SESSION_SIGNING_SECRET", "display-session-test-secret")
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
var upstreamAuth, upstreamCookie, upstreamProtocol, gotInstanceID string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/websockify" {
|
||||
t.Errorf("upstream path = %q, want /websockify", r.URL.Path)
|
||||
}
|
||||
if r.URL.RawQuery != "" {
|
||||
t.Errorf("upstream raw query = %q, want stripped", r.URL.RawQuery)
|
||||
}
|
||||
upstreamAuth = r.Header.Get("Authorization")
|
||||
upstreamCookie = r.Header.Get("Cookie")
|
||||
upstreamProtocol = r.Header.Get("Sec-WebSocket-Protocol")
|
||||
_, _ = w.Write([]byte("websockify"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
upstreamURL, err := url.Parse(upstream.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse upstream URL: %v", err)
|
||||
}
|
||||
prevForward := displayForward
|
||||
displayForward = func(_ context.Context, instanceID string, fn func(target *url.URL) error) error {
|
||||
gotInstanceID = instanceID
|
||||
return fn(upstreamURL)
|
||||
}
|
||||
t.Cleanup(func() { displayForward = prevForward })
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\), COALESCE\(instance_id, ''\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-display").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute", "instance_id"}).AddRow(
|
||||
`{"display":{"mode":"desktop-control","protocol":"novnc","width":1920,"height":1080}}`,
|
||||
"i-display123",
|
||||
))
|
||||
expiresAt := time.Now().Add(5 * time.Minute).UTC()
|
||||
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", "admin-token", expiresAt))
|
||||
token := signDisplaySessionToken("ws-display", "admin-token", expiresAt)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{
|
||||
{Key: "id", Value: "ws-display"},
|
||||
{Key: "proxyPath", Value: "/websockify"},
|
||||
}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-display/display/session/websockify", nil)
|
||||
c.Request.Header.Set("Authorization", "Bearer should-not-reach-upstream")
|
||||
c.Request.Header.Set("Cookie", "session=should-not-reach-upstream")
|
||||
c.Request.Header.Set("Sec-WebSocket-Protocol", "binary, molecule-display-token."+token)
|
||||
|
||||
handler.DisplaySession(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if gotInstanceID != "i-display123" {
|
||||
t.Fatalf("displayForward instanceID = %q, want i-display123", gotInstanceID)
|
||||
}
|
||||
if w.Body.String() != "websockify" {
|
||||
t.Fatalf("body = %q, want websockify", w.Body.String())
|
||||
}
|
||||
if upstreamAuth != "" || upstreamCookie != "" {
|
||||
t.Fatalf("proxied credentials leaked upstream: auth=%q cookie=%q", upstreamAuth, upstreamCookie)
|
||||
}
|
||||
if upstreamProtocol != "binary" {
|
||||
t.Fatalf("upstream websocket protocol = %q, want binary without display token", upstreamProtocol)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplaySession_NonDisplayWorkspaceDoesNotProxy(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
prevForward := displayForward
|
||||
displayForward = func(_ context.Context, _ string, _ func(target *url.URL) error) error {
|
||||
t.Fatal("displayForward must not run for non-display workspaces")
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { displayForward = prevForward })
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\), COALESCE\(instance_id, ''\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-no-display").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute", "instance_id"}).AddRow(`{}`, "i-display123"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{
|
||||
{Key: "id", Value: "ws-no-display"},
|
||||
{Key: "proxyPath", Value: "/websockify"},
|
||||
}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-no-display/display/session/websockify", nil)
|
||||
|
||||
handler.DisplaySession(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected status 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,13 +435,16 @@ func (h *WorkspaceHandler) CascadeDelete(ctx context.Context, id string) ([]stri
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("descendant query: %w", err)
|
||||
}
|
||||
defer descRows.Close()
|
||||
for descRows.Next() {
|
||||
var descID string
|
||||
if descRows.Scan(&descID) == nil {
|
||||
descendantIDs = append(descendantIDs, descID)
|
||||
}
|
||||
}
|
||||
descRows.Close()
|
||||
if err := descRows.Err(); err != nil {
|
||||
return nil, nil, fmt.Errorf("CascadeDelete: failed iterating descendants: %w", err)
|
||||
}
|
||||
|
||||
allIDs := append([]string{id}, descendantIDs...)
|
||||
|
||||
|
||||
@@ -503,6 +503,32 @@ func TestCascadeDelete_DescendantQueryError(t *testing.T) {
|
||||
// sqlmock verifies all expected queries were executed
|
||||
}
|
||||
|
||||
func TestCascadeDelete_DescendantRowsError(t *testing.T) {
|
||||
mock, _ := setupWorkspaceCrudTest(t)
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
|
||||
// RowError(0, ...) requires a real row at index 0 to be reachable —
|
||||
// sqlmock only invokes nextErr[N] when r.pos-1 == N and the row exists.
|
||||
// AddRow ensures Next() attempts the first row, triggers the error,
|
||||
// and rows.Err() returns the injected error.
|
||||
h := &WorkspaceHandler{}
|
||||
rows := sqlmock.NewRows([]string{"id"}).AddRow("desc-1").RowError(0, sql.ErrConnDone)
|
||||
mock.ExpectQuery(`WITH RECURSIVE descendants AS`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(rows)
|
||||
|
||||
deleted, stopErrs, err := h.CascadeDelete(context.Background(), wsID)
|
||||
if err == nil {
|
||||
t.Fatal("CascadeDelete returned nil error; want descendant rows error")
|
||||
}
|
||||
if deleted != nil {
|
||||
t.Errorf("deleted = %v; want nil", deleted)
|
||||
}
|
||||
if stopErrs != nil {
|
||||
t.Errorf("stopErrs = %v; want nil", stopErrs)
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Full CascadeDelete testing requires mocking StopWorkspace, RemoveVolume,
|
||||
// and provisioner calls — covered in integration tests. Unit tests here focus on
|
||||
// the validation and pre-condition paths.
|
||||
|
||||
@@ -2,13 +2,19 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
@@ -27,6 +33,7 @@ type workspaceDisplayControlResponse struct {
|
||||
Controller string `json:"controller"`
|
||||
ControlledBy string `json:"controlled_by,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
SessionURL string `json:"session_url,omitempty"`
|
||||
}
|
||||
|
||||
type workspaceDisplayControlNoneResponse struct {
|
||||
@@ -89,6 +96,10 @@ func (h *WorkspaceHandler) AcquireDisplayControl(c *gin.Context) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "display control requires admin-token or org-token auth"})
|
||||
return
|
||||
}
|
||||
if displaySessionSigningSecret() == "" {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "display session signing secret is not configured"})
|
||||
return
|
||||
}
|
||||
workspaceID := c.Param("id")
|
||||
startedAt := time.Now()
|
||||
emitDisplayControlEvent(c.Request.Context(), "display.control.acquire.started", workspaceID, map[string]any{
|
||||
@@ -113,6 +124,7 @@ RETURNING controller, controlled_by, expires_at`,
|
||||
workspaceID, req.Controller, controlledBy, req.TTLSeconds,
|
||||
).Scan(&lock.Controller, &lock.ControlledBy, &lock.ExpiresAt)
|
||||
if err == nil {
|
||||
lock.SessionURL = signedDisplaySessionURL(workspaceID, lock.ControlledBy, lock.ExpiresAt)
|
||||
emitDisplayControlEvent(c.Request.Context(), "display.control.acquire.completed", workspaceID, map[string]any{
|
||||
"controller": lock.Controller,
|
||||
"controlled_by": lock.ControlledBy,
|
||||
@@ -358,3 +370,50 @@ func emitDisplayControlEvent(ctx context.Context, eventType string, workspaceID
|
||||
log.Printf("emitDisplayControlEvent: insert %s failed: %v", eventType, err)
|
||||
}
|
||||
}
|
||||
|
||||
func signedDisplaySessionURL(workspaceID, controlledBy string, expiresAt time.Time) string {
|
||||
token := signDisplaySessionToken(workspaceID, controlledBy, expiresAt)
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("/workspaces/%s/display/session/websockify#token=%s", url.PathEscape(workspaceID), token)
|
||||
}
|
||||
|
||||
func signDisplaySessionToken(workspaceID, controlledBy string, expiresAt time.Time) string {
|
||||
secret := displaySessionSigningSecret()
|
||||
if secret == "" || workspaceID == "" || controlledBy == "" || expiresAt.IsZero() {
|
||||
return ""
|
||||
}
|
||||
payload := strings.Join([]string{workspaceID, controlledBy, strconv.FormatInt(expiresAt.Unix(), 10)}, "|")
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(payload))
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func validateDisplaySessionToken(token, workspaceID, controlledBy string, expiresAt time.Time) bool {
|
||||
secret := displaySessionSigningSecret()
|
||||
parts := strings.Split(token, ".")
|
||||
if secret == "" || len(parts) != 2 || workspaceID == "" || controlledBy == "" || expiresAt.IsZero() || time.Now().After(expiresAt) {
|
||||
return false
|
||||
}
|
||||
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
payload := string(payloadBytes)
|
||||
wantPayload := strings.Join([]string{workspaceID, controlledBy, strconv.FormatInt(expiresAt.Unix(), 10)}, "|")
|
||||
if subtle.ConstantTimeCompare([]byte(payload), []byte(wantPayload)) != 1 {
|
||||
return false
|
||||
}
|
||||
sig, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(payload))
|
||||
return hmac.Equal(sig, mac.Sum(nil))
|
||||
}
|
||||
|
||||
func displaySessionSigningSecret() string {
|
||||
return os.Getenv("DISPLAY_SESSION_SIGNING_SECRET")
|
||||
}
|
||||
|
||||
@@ -2,10 +2,15 @@ package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -54,6 +59,7 @@ func TestWorkspaceDisplayControl_NoActiveLockReturnsNone(t *testing.T) {
|
||||
|
||||
func TestWorkspaceDisplayControlAcquire_ClaimsUnlockedDisplay(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
t.Setenv("DISPLAY_SESSION_SIGNING_SECRET", "display-session-test-secret")
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
expiresAt := time.Date(2026, 5, 23, 18, 30, 0, 0, time.UTC)
|
||||
|
||||
@@ -87,13 +93,39 @@ func TestWorkspaceDisplayControlAcquire_ClaimsUnlockedDisplay(t *testing.T) {
|
||||
if resp["expires_at"] == "" {
|
||||
t.Fatalf("expires_at missing in response: %#v", resp)
|
||||
}
|
||||
sessionURL, ok := resp["session_url"].(string)
|
||||
if !ok || !strings.HasPrefix(sessionURL, "/workspaces/ws-display/display/session/websockify#token=") {
|
||||
t.Fatalf("session_url = %#v, want signed websockify URL fragment", resp["session_url"])
|
||||
}
|
||||
if strings.Contains(sessionURL, "?token=") {
|
||||
t.Fatalf("session_url must not put display token in logged query string: %q", sessionURL)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplaySessionToken_RequiresDedicatedSigningSecret(t *testing.T) {
|
||||
t.Setenv("ADMIN_TOKEN", "client-exposed-admin-token")
|
||||
t.Setenv("DISPLAY_SESSION_SIGNING_SECRET", "")
|
||||
expiresAt := time.Now().Add(5 * time.Minute)
|
||||
|
||||
if token := signDisplaySessionToken("ws-display", "admin-token", expiresAt); token != "" {
|
||||
t.Fatalf("signDisplaySessionToken minted token with no dedicated signing secret: %q", token)
|
||||
}
|
||||
|
||||
payload := "ws-display|admin-token|" + strconv.FormatInt(expiresAt.Unix(), 10)
|
||||
mac := hmac.New(sha256.New, []byte(""))
|
||||
_, _ = mac.Write([]byte(payload))
|
||||
forged := base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
if validateDisplaySessionToken(forged, "ws-display", "admin-token", expiresAt) {
|
||||
t.Fatal("validateDisplaySessionToken accepted empty-secret forged token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplayControlAcquire_ActiveLockReturnsConflict(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
t.Setenv("DISPLAY_SESSION_SIGNING_SECRET", "display-session-test-secret")
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
expiresAt := time.Date(2026, 5, 23, 18, 30, 0, 0, time.UTC)
|
||||
|
||||
@@ -136,6 +168,32 @@ func TestWorkspaceDisplayControlAcquire_ActiveLockReturnsConflict(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplayControlAcquire_RejectsMissingSessionSigningSecret(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")
|
||||
attachDisplayControlAdminToken(t, c)
|
||||
t.Setenv("DISPLAY_SESSION_SIGNING_SECRET", "")
|
||||
|
||||
handler.AcquireDisplayControl(c)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected status 503, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
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())
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const workspaceDisplaySessionTimeout = 12 * time.Hour
|
||||
const displaySessionTokenProtocolPrefix = "molecule-display-token."
|
||||
|
||||
var displayForward = realDisplayForward
|
||||
|
||||
// DisplaySession proxies noVNC/websockify requests for a display-enabled EC2
|
||||
// workspace through the existing EIC SSH path. The EC2 :6080 listener stays
|
||||
// private to the VPC; the browser only sees this same-origin route.
|
||||
func (h *WorkspaceHandler) DisplaySession(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
display, instanceID, err := loadWorkspaceDisplaySessionTarget(c.Request.Context(), workspaceID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||
return
|
||||
}
|
||||
log.Printf("DisplaySession: load target for %s failed: %v", workspaceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load display session"})
|
||||
return
|
||||
}
|
||||
if display.Mode == "" || display.Mode == "none" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "display not enabled"})
|
||||
return
|
||||
}
|
||||
if instanceID == "" {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "display session unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
proxyPath := c.Param("proxyPath")
|
||||
if proxyPath != "/websockify" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "display session path not found"})
|
||||
return
|
||||
}
|
||||
lock, found, err := h.loadActiveDisplayControl(c, workspaceID)
|
||||
if err != nil {
|
||||
log.Printf("DisplaySession: load active lock for %s failed: %v", workspaceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load display control"})
|
||||
return
|
||||
}
|
||||
if !found || !validateDisplaySessionToken(displaySessionTokenFromRequest(c.Request), workspaceID, lock.ControlledBy, lock.ExpiresAt) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "display control required"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), workspaceDisplaySessionTimeout)
|
||||
defer cancel()
|
||||
err = displayForward(ctx, instanceID, func(target *url.URL) error {
|
||||
proxy := newDisplaySessionReverseProxy(target)
|
||||
proxy.ServeHTTP(c.Writer, c.Request.WithContext(ctx))
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("DisplaySession: proxy for %s instance=%s failed: %v", workspaceID, instanceID, err)
|
||||
if !c.Writer.Written() {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "display session proxy failed"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadWorkspaceDisplaySessionTarget(ctx context.Context, workspaceID string) (models.WorkspaceComputeDisplay, string, error) {
|
||||
var raw, instanceID string
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(compute, '{}'::jsonb), COALESCE(instance_id, '') FROM workspaces WHERE id = $1`,
|
||||
workspaceID,
|
||||
).Scan(&raw, &instanceID)
|
||||
if err != nil {
|
||||
return models.WorkspaceComputeDisplay{}, "", err
|
||||
}
|
||||
var compute models.WorkspaceCompute
|
||||
if raw != "" && raw != "{}" {
|
||||
if err := json.Unmarshal([]byte(raw), &compute); err != nil {
|
||||
return models.WorkspaceComputeDisplay{}, "", fmt.Errorf("invalid compute JSON: %w", err)
|
||||
}
|
||||
if err := validateWorkspaceDisplayConfig(compute.Display); err != nil {
|
||||
return models.WorkspaceComputeDisplay{}, "", err
|
||||
}
|
||||
}
|
||||
return compute.Display, instanceID, nil
|
||||
}
|
||||
|
||||
func newDisplaySessionReverseProxy(target *url.URL) *httputil.ReverseProxy {
|
||||
return &httputil.ReverseProxy{
|
||||
Director: func(req *http.Request) {
|
||||
req.URL.Scheme = target.Scheme
|
||||
req.URL.Host = target.Host
|
||||
req.URL.Path = "/websockify"
|
||||
req.URL.RawPath = ""
|
||||
req.URL.RawQuery = ""
|
||||
req.Host = target.Host
|
||||
req.Header.Del("Authorization")
|
||||
req.Header.Del("Cookie")
|
||||
req.Header.Set("Sec-WebSocket-Protocol", "binary")
|
||||
},
|
||||
ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
|
||||
log.Printf("DisplaySession: upstream proxy error: %v", err)
|
||||
http.Error(w, "display session proxy failed", http.StatusBadGateway)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func displaySessionTokenFromRequest(r *http.Request) string {
|
||||
for _, part := range strings.Split(r.Header.Get("Sec-WebSocket-Protocol"), ",") {
|
||||
protocol := strings.TrimSpace(part)
|
||||
if strings.HasPrefix(protocol, displaySessionTokenProtocolPrefix) {
|
||||
return strings.TrimPrefix(protocol, displaySessionTokenProtocolPrefix)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func realDisplayForward(ctx context.Context, instanceID string, fn func(target *url.URL) error) error {
|
||||
if instanceID == "" {
|
||||
return fmt.Errorf("workspace has no instance_id")
|
||||
}
|
||||
return withEICTunnel(ctx, instanceID, func(s eicSSHSession) error {
|
||||
localPort, err := pickFreePort()
|
||||
if err != nil {
|
||||
return fmt.Errorf("pick display forward port: %w", err)
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "ssh",
|
||||
"-i", s.keyPath,
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"-o", "LogLevel=ERROR",
|
||||
"-o", "ExitOnForwardFailure=yes",
|
||||
"-N",
|
||||
"-L", fmt.Sprintf("%d:127.0.0.1:6080", localPort),
|
||||
"-p", fmt.Sprintf("%d", s.localPort),
|
||||
fmt.Sprintf("%s@127.0.0.1", s.osUser),
|
||||
)
|
||||
cmd.Env = os.Environ()
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("display forward start: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
_ = cmd.Wait()
|
||||
}()
|
||||
if err := waitForPort(ctx, "127.0.0.1", localPort, 10*time.Second); err != nil {
|
||||
return fmt.Errorf("display forward never listened: %w", err)
|
||||
}
|
||||
return fn(&url.URL{Scheme: "http", Host: fmt.Sprintf("127.0.0.1:%d", localPort)})
|
||||
})
|
||||
}
|
||||
@@ -288,16 +288,22 @@ func (h *WorkspaceHandler) buildProvisionerConfig(
|
||||
}
|
||||
|
||||
return provisioner.WorkspaceConfig{
|
||||
WorkspaceID: workspaceID,
|
||||
TemplatePath: templatePath,
|
||||
ConfigFiles: configFiles,
|
||||
PluginsPath: pluginsPath,
|
||||
WorkspacePath: workspacePath,
|
||||
WorkspaceAccess: workspaceAccess,
|
||||
Tier: payload.Tier,
|
||||
Runtime: payload.Runtime,
|
||||
InstanceType: payload.Compute.InstanceType,
|
||||
DiskGB: int32(payload.Compute.Volume.RootGB),
|
||||
WorkspaceID: workspaceID,
|
||||
TemplatePath: templatePath,
|
||||
ConfigFiles: configFiles,
|
||||
PluginsPath: pluginsPath,
|
||||
WorkspacePath: workspacePath,
|
||||
WorkspaceAccess: workspaceAccess,
|
||||
Tier: payload.Tier,
|
||||
Runtime: payload.Runtime,
|
||||
InstanceType: payload.Compute.InstanceType,
|
||||
DiskGB: int32(payload.Compute.Volume.RootGB),
|
||||
Display: provisioner.WorkspaceDisplayConfig{
|
||||
Mode: payload.Compute.Display.Mode,
|
||||
Width: payload.Compute.Display.Width,
|
||||
Height: payload.Compute.Display.Height,
|
||||
Protocol: payload.Compute.Display.Protocol,
|
||||
},
|
||||
EnvVars: envVars,
|
||||
PlatformURL: h.platformURL,
|
||||
AwarenessURL: os.Getenv("AWARENESS_URL"),
|
||||
@@ -550,13 +556,22 @@ func (h *WorkspaceHandler) ensureDefaultConfig(workspaceID string, payload model
|
||||
// via a crafted runtime string (#241).
|
||||
runtime := sanitizeRuntime(payload.Runtime)
|
||||
|
||||
// Generate a minimal config.yaml
|
||||
// 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).
|
||||
model := payload.Model
|
||||
if model == "" {
|
||||
// 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)
|
||||
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)
|
||||
}
|
||||
if runtime == "claude-code" {
|
||||
model = normalizeClaudeCodeModel(model)
|
||||
|
||||
@@ -756,47 +756,55 @@ func TestWorkspaceCreate_FirstDeploy_PersistsModelAndProvider(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) {
|
||||
// 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) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
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))
|
||||
// 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.
|
||||
|
||||
// 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","external":true}`
|
||||
body := `{"name":"No Model Agent","runtime":"hermes"}`
|
||||
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 status 201, got %d: %s", w.Code, w.Body.String())
|
||||
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 err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met — empty payload.Model should NOT trigger workspace_secrets writes: %v", err)
|
||||
t.Errorf("sqlmock saw an unexpected DB write — the MODEL_REQUIRED gate fired too late: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -193,10 +193,17 @@ 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)
|
||||
@@ -219,7 +226,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 use default non-claude model, got:\n%s", content)
|
||||
t.Errorf("config.yaml should render the supplied model, got:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,10 +234,14 @@ 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)
|
||||
@@ -407,9 +418,16 @@ 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,
|
||||
Name: "Default Agent",
|
||||
Tier: 1,
|
||||
Model: "sonnet",
|
||||
}
|
||||
|
||||
files := handler.ensureDefaultConfig("ws-empty-rt", payload)
|
||||
@@ -418,7 +436,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 default model should be sonnet (quoted), got:\n%s", configYAML)
|
||||
t.Errorf("claude-code workspace should render the supplied model (quoted), got:\n%s", configYAML)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -788,6 +806,8 @@ func TestIssueAndInjectToken_HappyPath(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
|
||||
|
||||
// RevokeAllForWorkspace UPDATE (0 rows — no prior tokens, still succeeds)
|
||||
mock.ExpectExec(`UPDATE workspace_auth_tokens SET revoked_at`).
|
||||
@@ -825,6 +845,8 @@ func TestIssueAndInjectToken_RotatesExistingToken(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
|
||||
|
||||
// RevokeAllForWorkspace: 1 existing token revoked
|
||||
mock.ExpectExec(`UPDATE workspace_auth_tokens SET revoked_at`).
|
||||
@@ -891,6 +913,8 @@ func TestIssueAndInjectToken_IssueFailSkipsInjection(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
|
||||
|
||||
mock.ExpectExec(`UPDATE workspace_auth_tokens SET revoked_at`).
|
||||
WithArgs("ws-418-issue-fail").
|
||||
@@ -917,6 +941,8 @@ func TestIssueAndInjectToken_NilConfigFilesAllocated(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
|
||||
|
||||
mock.ExpectExec(`UPDATE workspace_auth_tokens SET revoked_at`).
|
||||
WithArgs("ws-418-nil-cfg").
|
||||
|
||||
@@ -858,6 +858,9 @@ 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
|
||||
@@ -939,6 +942,9 @@ 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
|
||||
|
||||
@@ -349,7 +349,7 @@ func TestWorkspaceCreate_DBInsertError(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
body := `{"name":"Failing Agent"}`
|
||||
body := `{"name":"Failing Agent","model":"anthropic:claude-opus-4-7"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -391,7 +391,7 @@ func TestWorkspaceCreate_DefaultsApplied(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
body := `{"name":"Default Agent"}`
|
||||
body := `{"name":"Default Agent","model":"anthropic:claude-opus-4-7"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -438,7 +438,7 @@ func TestWorkspaceCreate_SaaSHardForcesTier4(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
body := `{"name":"SaaS External Agent","runtime":"external","external":true,"url":"https://example.com/agent","tier":2}`
|
||||
body := `{"name":"SaaS External Agent","runtime":"external","model":"external:custom","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")
|
||||
|
||||
@@ -479,7 +479,7 @@ func TestWorkspaceCreate_WithSecrets_Persists(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
body := `{"name":"Hermes Agent","runtime":"hermes","external":true,"secrets":{"HERMES_API_KEY":"sk-test-123"}}`
|
||||
body := `{"name":"Hermes Agent","runtime":"hermes","model":"anthropic:claude-opus-4-7","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","secrets":{"OPENAI_API_KEY":"sk-fail"}}`
|
||||
body := `{"name":"Rollback Agent","model":"anthropic:claude-opus-4-7","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","external":true,"secrets":{}}`
|
||||
body := `{"name":"No Secrets Agent","model":"anthropic:claude-opus-4-7","external":true,"secrets":{}}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -587,7 +587,7 @@ func TestWorkspaceCreate_ExternalURL_SSRFSafe(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
body := `{"name":"Ext Agent","runtime":"external","external":true,"url":"http://localhost:8000"}`
|
||||
body := `{"name":"Ext Agent","runtime":"external","model":"external:custom","external":true,"url":"http://localhost:8000"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -629,7 +629,7 @@ func TestWorkspaceCreate_KimiRuntime_PreservesLabel(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
body := `{"name":"Kimi Agent","runtime":"kimi","tier":3,"canvas":{"x":100,"y":100}}`
|
||||
body := `{"name":"Kimi Agent","runtime":"kimi","model":"kimi-coding/kimi-k2-coding-6","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","external":true,"url":"http://169.254.169.254/latest/meta-data/"}`
|
||||
body := `{"name":"Bad Agent","runtime":"external","model":"external:custom","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","external":true,"url":"http://127.0.0.1:9000/a2a"}`
|
||||
body := `{"name":"Bad Loopback","runtime":"external","model":"external:custom","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")
|
||||
|
||||
@@ -1844,39 +1844,43 @@ func TestWorkspaceCreate_188_TemplateConfigNoRuntimeKey_FailsClosed(t *testing.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)
|
||||
// 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)
|
||||
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 w.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("bare-body create: expected 422 MODEL_REQUIRED, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1901,7 +1905,7 @@ func TestWorkspaceCreate_188_ExplicitRuntimeNoTemplate_OK(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
body := `{"name":"Explicit Codex","runtime":"codex"}`
|
||||
body := `{"name":"Explicit Codex","runtime":"codex","model":"gpt-5.5"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
|
||||
@@ -329,7 +329,13 @@ func (c *Client) doJSON(ctx context.Context, method, path string, reqBody interf
|
||||
|
||||
func decodeError(resp *http.Response) error {
|
||||
var e contract.Error
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
return &contract.Error{
|
||||
Code: httpStatusToCode(resp.StatusCode),
|
||||
Message: fmt.Sprintf("status %d (read body failed: %v)", resp.StatusCode, readErr),
|
||||
}
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return &contract.Error{
|
||||
Code: httpStatusToCode(resp.StatusCode),
|
||||
|
||||
@@ -3,6 +3,7 @@ package pgplugin
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -246,7 +247,9 @@ 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)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||||
log.Printf("pgplugin: JSON encode error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, code contract.ErrorCode, message string, details map[string]interface{}) {
|
||||
|
||||
@@ -39,41 +39,30 @@ type Bundle struct {
|
||||
// Build returns a wired Bundle if MEMORY_PLUGIN_URL is set, else nil.
|
||||
//
|
||||
// It probes /v1/health at boot — when the plugin is unreachable, we
|
||||
// log a warning but STILL return the bundle. The MCP layer's
|
||||
// circuit breaker handles ongoing unavailability; we don't want to
|
||||
// block workspace-server boot just because the memory plugin is
|
||||
// briefly down.
|
||||
// log a warning but STILL return the bundle. The MCP layer's circuit
|
||||
// breaker handles ongoing unavailability.
|
||||
//
|
||||
// Silent-misconfig guard: if MEMORY_V2_CUTOVER=true is set without
|
||||
// MEMORY_PLUGIN_URL, the cutoverActive() check in handlers silently
|
||||
// returns false and the legacy SQL path serves every request. The
|
||||
// operator sees no errors, no warnings, and assumes the cutover is
|
||||
// live. Log a LOUD WARN at boot when the env is half-configured so
|
||||
// the misconfig is visible in the boot log, not detectable only by
|
||||
// observing that the legacy table is still being written to.
|
||||
// Issue #1733: when MEMORY_PLUGIN_URL is unset the bundle is nil and
|
||||
// every memory MCP tool returns a clear "plugin not configured" error
|
||||
// (mcp_tools.go). There is no longer a silent SQL fallback to
|
||||
// agent_memories, so the previous half-configured-cutover guard is
|
||||
// gone — a missing URL fails loudly on first memory call instead of
|
||||
// quietly serving stale legacy data.
|
||||
//
|
||||
// MEMORY_V2_CUTOVER is left intact as a deployment marker (CP user-data
|
||||
// reads it before spawning the sidecar in entrypoint-tenant.sh); we no
|
||||
// longer branch on it inside the platform binary.
|
||||
func Build(db *sql.DB) *Bundle {
|
||||
cutover := os.Getenv("MEMORY_V2_CUTOVER") == "true"
|
||||
pluginURL := os.Getenv("MEMORY_PLUGIN_URL")
|
||||
|
||||
if pluginURL == "" {
|
||||
if cutover {
|
||||
log.Printf("memory-plugin: ⚠️ MEMORY_V2_CUTOVER=true but MEMORY_PLUGIN_URL is unset — cutover is INACTIVE, legacy SQL path is serving every request. Either unset MEMORY_V2_CUTOVER or point MEMORY_PLUGIN_URL at a reachable plugin server.")
|
||||
}
|
||||
log.Printf("memory-plugin: MEMORY_PLUGIN_URL is unset — v2 memory MCP tools (commit_memory, recall_memory, admin export/import) will return 'plugin not configured' to callers. Set MEMORY_PLUGIN_URL to activate.")
|
||||
return nil
|
||||
}
|
||||
plugin := mclient.New(mclient.Config{})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if hr, err := plugin.Boot(ctx); err != nil {
|
||||
// Log even louder when cutover is on — an unreachable plugin
|
||||
// during cutover means writes that the operator THINKS are
|
||||
// going to v2 will silently fall back to legacy via the
|
||||
// circuit breaker on each request. Make it impossible to miss.
|
||||
if cutover {
|
||||
log.Printf("memory-plugin: ⚠️ MEMORY_V2_CUTOVER=true and MEMORY_PLUGIN_URL=%s but /v1/health probe failed (%v). Cutover writes will fall back to legacy via circuit breaker. Verify the plugin server is reachable.", pluginURL, err)
|
||||
} else {
|
||||
log.Printf("memory-plugin: /v1/health probe failed (will retry per-request): %v", err)
|
||||
}
|
||||
log.Printf("memory-plugin: ⚠️ /v1/health probe failed at boot (%v). MCP memory calls will error until the plugin becomes reachable. Verify MEMORY_PLUGIN_URL=%s.", err, pluginURL)
|
||||
} else {
|
||||
log.Printf("memory-plugin: ok, capabilities=%v", hr.Capabilities)
|
||||
}
|
||||
|
||||
@@ -166,69 +166,43 @@ func captureLogs(t *testing.T, fn func()) string {
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// TestBuild_WarnsWhenCutoverWithoutPluginURL pins the silent-misconfig
|
||||
// guard: an operator who flips MEMORY_V2_CUTOVER=true without also
|
||||
// pointing MEMORY_PLUGIN_URL at a plugin server has just disabled the
|
||||
// cutover with no error visible. Without this WARN, the only signal
|
||||
// is "the legacy table is still being written to" — invisible to
|
||||
// every operator who doesn't explicitly check.
|
||||
func TestBuild_WarnsWhenCutoverWithoutPluginURL(t *testing.T) {
|
||||
t.Setenv("MEMORY_V2_CUTOVER", "true")
|
||||
// Issue #1733: the old "cutover-without-URL" and "cutover-with-failing-
|
||||
// probe" loud-warning tests are gone — workspace-server no longer
|
||||
// branches on MEMORY_V2_CUTOVER (v2 is unconditional now), so the
|
||||
// half-configured-cutover failure mode they guarded against can no
|
||||
// longer occur. The two surviving tests below pin the new shape:
|
||||
// one log line when URL is unset, one when the boot-time probe fails.
|
||||
|
||||
// TestBuild_LogsWhenURLUnset confirms the operator-visible boot log
|
||||
// line that fires when MEMORY_PLUGIN_URL is unset — every memory MCP
|
||||
// tool will then return "plugin not configured" to callers.
|
||||
func TestBuild_LogsWhenURLUnset(t *testing.T) {
|
||||
t.Setenv("MEMORY_PLUGIN_URL", "")
|
||||
out := captureLogs(t, func() {
|
||||
if got := Build(nil); got != nil {
|
||||
t.Errorf("expected nil bundle, got %+v", got)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "MEMORY_V2_CUTOVER=true") || !strings.Contains(out, "MEMORY_PLUGIN_URL is unset") {
|
||||
t.Errorf("expected loud WARN about half-configured cutover; got log:\n%s", out)
|
||||
if !strings.Contains(out, "MEMORY_PLUGIN_URL is unset") {
|
||||
t.Errorf("expected boot log to mention MEMORY_PLUGIN_URL is unset; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuild_NoWarnWhenNeitherSet pins the happy default: an operator
|
||||
// running without the v2 plugin should not see scary warnings.
|
||||
func TestBuild_NoWarnWhenNeitherSet(t *testing.T) {
|
||||
t.Setenv("MEMORY_V2_CUTOVER", "")
|
||||
t.Setenv("MEMORY_PLUGIN_URL", "")
|
||||
out := captureLogs(t, func() { _ = Build(nil) })
|
||||
if strings.Contains(out, "MEMORY_V2_CUTOVER") {
|
||||
t.Errorf("expected no MEMORY_V2_CUTOVER warning when env is unset; got log:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuild_LoudWarnWhenCutoverAndProbeFails pins the second
|
||||
// half-config case: cutover is on AND plugin URL is set, but the
|
||||
// /v1/health probe fails (server down or wrong URL). Without this
|
||||
// loud WARN, the operator sees only the generic "probe failed" line
|
||||
// that gets emitted even when cutover is OFF — hiding the fact that
|
||||
// real cutover writes will quietly fall back via circuit breaker.
|
||||
func TestBuild_LoudWarnWhenCutoverAndProbeFails(t *testing.T) {
|
||||
t.Setenv("MEMORY_V2_CUTOVER", "true")
|
||||
// TestBuild_LogsWhenProbeFails confirms the boot log line that fires
|
||||
// when MEMORY_PLUGIN_URL is set but the plugin is unreachable. The
|
||||
// bundle is still returned (per the comment on Build) so the platform
|
||||
// keeps booting — MCP calls error per-request until the plugin recovers.
|
||||
func TestBuild_LogsWhenProbeFails(t *testing.T) {
|
||||
t.Setenv("MEMORY_PLUGIN_URL", "http://127.0.0.1:1") // bogus port
|
||||
db, _, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
out := captureLogs(t, func() { _ = Build(db) })
|
||||
if !strings.Contains(out, "MEMORY_V2_CUTOVER=true") || !strings.Contains(out, "probe failed") {
|
||||
t.Errorf("expected loud WARN about cutover-with-failing-probe; got log:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuild_QuietProbeFailWhenCutoverOff: the operator is in PRE-cutover
|
||||
// mode (plugin URL set, cutover off — they're warming up the plugin).
|
||||
// A failing probe in this state is not a misconfig — it should log the
|
||||
// generic message, NOT the loud cutover-specific one (so log noise
|
||||
// doesn't drown out real cutover misconfigs in dashboards).
|
||||
func TestBuild_QuietProbeFailWhenCutoverOff(t *testing.T) {
|
||||
t.Setenv("MEMORY_V2_CUTOVER", "")
|
||||
t.Setenv("MEMORY_PLUGIN_URL", "http://127.0.0.1:1")
|
||||
db, _, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
out := captureLogs(t, func() { _ = Build(db) })
|
||||
if strings.Contains(out, "MEMORY_V2_CUTOVER=true") {
|
||||
t.Errorf("expected no cutover-specific warning when cutover is off; got log:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "probe failed") {
|
||||
t.Errorf("expected generic probe-failed log; got log:\n%s", out)
|
||||
out := captureLogs(t, func() {
|
||||
if got := Build(db); got == nil {
|
||||
t.Error("expected non-nil bundle when URL is set, got nil")
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "/v1/health probe failed") {
|
||||
t.Errorf("expected boot log to mention probe failure; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -256,6 +256,7 @@ func TestWorkspaceAuth_WrongWorkspace_Returns401(t *testing.T) {
|
||||
// live tokens anywhere) the middleware must let the request through so existing
|
||||
// deployments keep working during the Phase-30 rollout.
|
||||
func TestAdminAuth_FailOpen_NoTokensGlobally(t *testing.T) {
|
||||
t.Setenv("ADMIN_TOKEN", "")
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New: %v", err)
|
||||
@@ -375,6 +376,7 @@ func TestAdminAuth_C11_DeleteNoBearer_Returns401(t *testing.T) {
|
||||
// TestAdminAuth_ValidBearer_Passes — a valid bearer token (from any workspace)
|
||||
// must be accepted for admin routes.
|
||||
func TestAdminAuth_ValidBearer_Passes(t *testing.T) {
|
||||
t.Setenv("ADMIN_TOKEN", "")
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New: %v", err)
|
||||
@@ -418,6 +420,7 @@ func TestAdminAuth_ValidBearer_Passes(t *testing.T) {
|
||||
|
||||
// TestAdminAuth_InvalidBearer_Returns401 — wrong token must not grant admin access.
|
||||
func TestAdminAuth_InvalidBearer_Returns401(t *testing.T) {
|
||||
t.Setenv("ADMIN_TOKEN", "")
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New: %v", err)
|
||||
@@ -700,6 +703,7 @@ func TestAdminAuth_Issue180_ApprovalsListing_NoBearer_Returns401(t *testing.T) {
|
||||
// fail-open contract: on a fresh install (no tokens anywhere), the middleware
|
||||
// must not block the canvas from polling /approvals/pending.
|
||||
func TestAdminAuth_Issue180_ApprovalsListing_FailOpen_NoTokens(t *testing.T) {
|
||||
t.Setenv("ADMIN_TOKEN", "")
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New: %v", err)
|
||||
@@ -1098,6 +1102,7 @@ func TestCanvasOrBearer_TokensExist_CanvasOrigin_Passes(t *testing.T) {
|
||||
// issuing workspace has status='removed' must not grant admin access.
|
||||
// The JOIN in ValidateAnyToken filters the row out, resulting in ErrNoRows.
|
||||
func TestAdminAuth_RemovedWorkspaceToken_Returns401(t *testing.T) {
|
||||
t.Setenv("ADMIN_TOKEN", "")
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New: %v", err)
|
||||
@@ -1251,6 +1256,7 @@ func TestAdminAuth_623_ForgedCORSOrigin_Returns401(t *testing.T) {
|
||||
// TestAdminAuth_623_ValidBearer_WithOrigin_Passes — bearer + matching Origin
|
||||
// should still work (the Origin is irrelevant once the bearer validates).
|
||||
func TestAdminAuth_623_ValidBearer_WithOrigin_Passes(t *testing.T) {
|
||||
t.Setenv("ADMIN_TOKEN", "")
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock: %v", err)
|
||||
|
||||
@@ -1,39 +1,31 @@
|
||||
package models
|
||||
|
||||
// runtime_defaults.go — single source of truth for per-runtime defaults
|
||||
// the platform applies when the operator/agent didn't supply a value.
|
||||
// runtime_defaults.go — DELETED helper. Intentionally empty.
|
||||
//
|
||||
// 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.
|
||||
// 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:
|
||||
//
|
||||
// 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.
|
||||
// - 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.
|
||||
//
|
||||
// - 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.
|
||||
// - The fallback hid the contract bug at every callsite: Create
|
||||
// handler, org_import, anywhere a stale CreateWorkspacePayload
|
||||
// bubbled through to provisionWorkspace.
|
||||
//
|
||||
// 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"
|
||||
}
|
||||
// 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.
|
||||
|
||||
@@ -1,59 +1,11 @@
|
||||
package models
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
|
||||
@@ -152,14 +152,15 @@ func (p *CPProvisioner) adminAuthHeaders(req *http.Request) {
|
||||
}
|
||||
|
||||
type cpProvisionRequest struct {
|
||||
OrgID string `json:"org_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Runtime string `json:"runtime"`
|
||||
Tier int `json:"tier"`
|
||||
InstanceType string `json:"instance_type,omitempty"`
|
||||
DiskGB int32 `json:"disk_gb,omitempty"`
|
||||
PlatformURL string `json:"platform_url"`
|
||||
Env map[string]string `json:"env"`
|
||||
OrgID string `json:"org_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Runtime string `json:"runtime"`
|
||||
Tier int `json:"tier"`
|
||||
InstanceType string `json:"instance_type,omitempty"`
|
||||
DiskGB int32 `json:"disk_gb,omitempty"`
|
||||
Display WorkspaceDisplayConfig `json:"display,omitempty"`
|
||||
PlatformURL string `json:"platform_url"`
|
||||
Env map[string]string `json:"env"`
|
||||
// ConfigFiles are template + generated config files to write into the
|
||||
// EC2 instance's /configs directory. OFFSEC-010: collected by
|
||||
// collectCPConfigFiles which rejects symlinks and non-regular files
|
||||
@@ -214,6 +215,7 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
|
||||
Tier: cfg.Tier,
|
||||
InstanceType: cfg.InstanceType,
|
||||
DiskGB: cfg.DiskGB,
|
||||
Display: cfg.Display,
|
||||
PlatformURL: cfg.PlatformURL,
|
||||
Env: env,
|
||||
ConfigFiles: configFiles,
|
||||
@@ -241,9 +243,12 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
|
||||
// Cap body read at 64 KiB — the CP only ever returns small JSON
|
||||
// responses; an unbounded read could be weaponized into log-flood
|
||||
// DoS by a compromised upstream.
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
if readErr != nil {
|
||||
return "", fmt.Errorf("cp provisioner: read response body: %w", readErr)
|
||||
}
|
||||
var result cpProvisionResponse
|
||||
json.Unmarshal(respBody, &result)
|
||||
unmarshalErr := json.Unmarshal(respBody, &result)
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
// Prefer the structured {"error":"..."} field. Do NOT fall back
|
||||
@@ -257,6 +262,10 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
|
||||
return "", fmt.Errorf("cp provisioner: provision failed (%d): %s", resp.StatusCode, errMsg)
|
||||
}
|
||||
|
||||
if unmarshalErr != nil {
|
||||
return "", fmt.Errorf("cp provisioner: decode 201 response: %w", unmarshalErr)
|
||||
}
|
||||
|
||||
log.Printf("CP provisioner: workspace %s → EC2 instance %s (%s)", cfg.WorkspaceID, result.InstanceID, result.State)
|
||||
provlog.Event("provision.ec2_started", map[string]any{
|
||||
"workspace_id": cfg.WorkspaceID,
|
||||
@@ -409,7 +418,11 @@ func (p *CPProvisioner) Stop(ctx context.Context, workspaceID string) error {
|
||||
// Read a bounded slice of the body so the error message gives ops
|
||||
// enough to triage without risking a multi-MB log line on a
|
||||
// pathological response. 512 bytes covers any sane error envelope.
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("cp provisioner: stop %s: unexpected %d (read body failed: %w)",
|
||||
workspaceID, resp.StatusCode, readErr)
|
||||
}
|
||||
return fmt.Errorf("cp provisioner: stop %s: unexpected %d: %s",
|
||||
workspaceID, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
@@ -197,6 +197,12 @@ func TestStart_HappyPath(t *testing.T) {
|
||||
if body.DiskGB != 100 {
|
||||
t.Errorf("disk_gb = %d, want 100", body.DiskGB)
|
||||
}
|
||||
if body.Display.Mode != "desktop-control" || body.Display.Protocol != "novnc" {
|
||||
t.Errorf("display mode/protocol = %q/%q, want desktop-control/novnc", body.Display.Mode, body.Display.Protocol)
|
||||
}
|
||||
if body.Display.Width != 1920 || body.Display.Height != 1080 {
|
||||
t.Errorf("display size = %dx%d, want 1920x1080", body.Display.Width, body.Display.Height)
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = io.WriteString(w, `{"instance_id":"i-abc123","state":"pending"}`)
|
||||
}))
|
||||
@@ -212,6 +218,7 @@ func TestStart_HappyPath(t *testing.T) {
|
||||
id, err := p.Start(context.Background(), WorkspaceConfig{
|
||||
WorkspaceID: "ws-1", Runtime: "python", Tier: 1, PlatformURL: "http://tenant",
|
||||
InstanceType: "m6i.xlarge", DiskGB: 100,
|
||||
Display: WorkspaceDisplayConfig{Mode: "desktop-control", Protocol: "novnc", Width: 1920, Height: 1080},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
@@ -442,6 +449,26 @@ func TestStart_SymlinkTemplatePathError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStart_Malformed201SurfacesError — when CP returns 201 Created with
|
||||
// unparseable JSON, Start must return an error instead of silently
|
||||
// returning an empty instance_id. CR2 blocker from review #5552.
|
||||
func TestStart_Malformed201SurfacesError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = io.WriteString(w, `{"instance_id": broken-json`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := &CPProvisioner{baseURL: srv.URL, orgID: "org-1", httpClient: srv.Client()}
|
||||
_, err := p.Start(context.Background(), WorkspaceConfig{WorkspaceID: "ws-1", Runtime: "py"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error on malformed 201, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "decode 201 response") {
|
||||
t.Errorf("error should mention decode 201 response, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestStop_SendsBothAuthHeaders — verify #118/#130 compliance on the
|
||||
// teardown path. Any call to /cp/workspaces/:id must carry both the
|
||||
// platform-wide shared secret AND the per-tenant admin token, or the
|
||||
|
||||
@@ -97,9 +97,10 @@ type WorkspaceConfig struct {
|
||||
PluginsPath string // Host path to plugins directory (mounted at /plugins)
|
||||
WorkspacePath string // Host path to bind-mount as /workspace (if empty, uses Docker named volume)
|
||||
Tier int
|
||||
Runtime string // "langgraph" (default) or "claude-code", "codex", "ollama", "custom"
|
||||
InstanceType string // Optional CP EC2 instance type override (SaaS only)
|
||||
DiskGB int32 // Optional CP root volume size override in GiB (SaaS only)
|
||||
Runtime string // "langgraph" (default) or "claude-code", "codex", "ollama", "custom"
|
||||
InstanceType string // Optional CP EC2 instance type override (SaaS only)
|
||||
DiskGB int32 // Optional CP root volume size override in GiB (SaaS only)
|
||||
Display WorkspaceDisplayConfig
|
||||
EnvVars map[string]string // Additional env vars (API keys, etc.)
|
||||
PlatformURL string
|
||||
AwarenessURL string
|
||||
@@ -122,6 +123,13 @@ type WorkspaceConfig struct {
|
||||
Image string
|
||||
}
|
||||
|
||||
type WorkspaceDisplayConfig struct {
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
}
|
||||
|
||||
// selectImage resolves the final Docker image ref for a workspace. The handler
|
||||
// layer is the source of truth — if it set cfg.Image (the digest-pinned form
|
||||
// supplied by CP, the SSOT for runtime image pins; molecule-core's own
|
||||
@@ -710,7 +718,19 @@ func buildContainerEnv(cfg WorkspaceConfig) []string {
|
||||
env = append(env, fmt.Sprintf("AWARENESS_NAMESPACE=%s", cfg.AwarenessNamespace))
|
||||
env = append(env, fmt.Sprintf("AWARENESS_URL=%s", cfg.AwarenessURL))
|
||||
}
|
||||
// #1687: track explicit GH_TOKEN / GITHUB_TOKEN so they win over GH_PAT
|
||||
// alias. These are normally stripped by the SCM-write guard below, but
|
||||
// when a user explicitly sets them we preserve the value.
|
||||
var explicitGHToken, explicitGitHubToken string
|
||||
for k, v := range cfg.EnvVars {
|
||||
if k == "GH_TOKEN" {
|
||||
explicitGHToken = v
|
||||
continue
|
||||
}
|
||||
if k == "GITHUB_TOKEN" {
|
||||
explicitGitHubToken = v
|
||||
continue
|
||||
}
|
||||
// Forensic #145 hardening: tenant workspace containers run
|
||||
// agent-controlled code and must NEVER receive a Git SCM *write*
|
||||
// credential. Without merge/approve creds in-container the
|
||||
@@ -728,6 +748,19 @@ func buildContainerEnv(cfg WorkspaceConfig) []string {
|
||||
}
|
||||
env = append(env, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
// #1687: alias GH_PAT → GH_TOKEN / GITHUB_TOKEN on the READ side
|
||||
// (container env assembly). Explicit values win: only alias when the
|
||||
// key was not set in workspace secrets.
|
||||
if explicitGHToken != "" {
|
||||
env = append(env, fmt.Sprintf("GH_TOKEN=%s", explicitGHToken))
|
||||
} else if pat, hasPAT := cfg.EnvVars["GH_PAT"]; hasPAT && pat != "" {
|
||||
env = append(env, fmt.Sprintf("GH_TOKEN=%s", pat))
|
||||
}
|
||||
if explicitGitHubToken != "" {
|
||||
env = append(env, fmt.Sprintf("GITHUB_TOKEN=%s", explicitGitHubToken))
|
||||
} else if pat, hasPAT := cfg.EnvVars["GH_PAT"]; hasPAT && pat != "" {
|
||||
env = append(env, fmt.Sprintf("GITHUB_TOKEN=%s", pat))
|
||||
}
|
||||
// Inject ADMIN_TOKEN from the platform server's environment so workspace
|
||||
// containers can call /admin/liveness and other admin-gated endpoints
|
||||
// (core#831). cp_provisioner.go handles this separately for SaaS tenants.
|
||||
|
||||
@@ -770,9 +770,12 @@ func TestBuildContainerEnv_CustomEnvVarsAppended(t *testing.T) {
|
||||
// place — i.e. the guard is proven by construction, not by environment
|
||||
// accident.
|
||||
func TestBuildContainerEnv_StripsSCMWriteTokens(t *testing.T) {
|
||||
// GH_TOKEN and GITHUB_TOKEN are preserved when explicitly set (#1687)
|
||||
// because they win over the GH_PAT alias. The unconditional strip list
|
||||
// therefore excludes them; see TestBuildContainerEnv_GHPATAliasPrecedence
|
||||
// for the positive assertion.
|
||||
scmTokens := []string{
|
||||
"GITEA_TOKEN", "GITHUB_TOKEN", "GH_TOKEN",
|
||||
"GITLAB_TOKEN", "GL_TOKEN", "BITBUCKET_TOKEN",
|
||||
"GITEA_TOKEN", "GITLAB_TOKEN", "GL_TOKEN", "BITBUCKET_TOKEN",
|
||||
}
|
||||
|
||||
t.Run("normal path — SCM tokens explicitly set in EnvVars", func(t *testing.T) {
|
||||
@@ -780,6 +783,9 @@ func TestBuildContainerEnv_StripsSCMWriteTokens(t *testing.T) {
|
||||
for _, k := range scmTokens {
|
||||
envVars[k] = "leaked-write-credential-" + k
|
||||
}
|
||||
// Explicit GH_TOKEN / GITHUB_TOKEN are now preserved (#1687).
|
||||
envVars["GH_TOKEN"] = "explicit-gh-token"
|
||||
envVars["GITHUB_TOKEN"] = "explicit-github-token"
|
||||
cfg := WorkspaceConfig{
|
||||
WorkspaceID: "ws-tenant",
|
||||
PlatformURL: "http://localhost:8080",
|
||||
@@ -795,6 +801,13 @@ func TestBuildContainerEnv_StripsSCMWriteTokens(t *testing.T) {
|
||||
if !envContains(buildContainerEnv(cfg), "ANTHROPIC_API_KEY=sk-keep") {
|
||||
t.Errorf("filter must not strip non-SCM API keys")
|
||||
}
|
||||
// Explicit GH tokens must be preserved (not stripped).
|
||||
if !envContains(buildContainerEnv(cfg), "GH_TOKEN=explicit-gh-token") {
|
||||
t.Errorf("explicit GH_TOKEN must be preserved")
|
||||
}
|
||||
if !envContains(buildContainerEnv(cfg), "GITHUB_TOKEN=explicit-github-token") {
|
||||
t.Errorf("explicit GITHUB_TOKEN must be preserved")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("persona-file path — simulates loadPersonaEnvFile merge", func(t *testing.T) {
|
||||
@@ -855,6 +868,106 @@ func TestCPProvisionerEnv_StripsSCMWriteTokens(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildContainerEnv_GHPATAliasPrecedence asserts that explicit GH_TOKEN /
|
||||
// GITHUB_TOKEN in workspace secrets win over the GH_PAT alias (#1687 CR2
|
||||
// review_id=5646). The alias must only inject a key when it was NOT explicitly
|
||||
// set.
|
||||
func TestBuildContainerEnv_GHPATAliasPrecedence(t *testing.T) {
|
||||
pat := "ghp_pat_from_secrets"
|
||||
explicitGH := "gh_explicit_token"
|
||||
explicitGitHub := "github_explicit_token"
|
||||
|
||||
t.Run("GH_PAT alone → alias both", func(t *testing.T) {
|
||||
cfg := WorkspaceConfig{
|
||||
WorkspaceID: "ws-x",
|
||||
PlatformURL: "http://localhost:8080",
|
||||
EnvVars: map[string]string{"GH_PAT": pat},
|
||||
}
|
||||
env := buildContainerEnv(cfg)
|
||||
if !envContains(env, "GH_TOKEN="+pat) {
|
||||
t.Errorf("GH_PAT alias must set GH_TOKEN, got %v", env)
|
||||
}
|
||||
if !envContains(env, "GITHUB_TOKEN="+pat) {
|
||||
t.Errorf("GH_PAT alias must set GITHUB_TOKEN, got %v", env)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit GH_TOKEN wins over GH_PAT alias", func(t *testing.T) {
|
||||
cfg := WorkspaceConfig{
|
||||
WorkspaceID: "ws-x",
|
||||
PlatformURL: "http://localhost:8080",
|
||||
EnvVars: map[string]string{
|
||||
"GH_PAT": pat,
|
||||
"GH_TOKEN": explicitGH,
|
||||
},
|
||||
}
|
||||
env := buildContainerEnv(cfg)
|
||||
if envContains(env, "GH_TOKEN="+pat) {
|
||||
t.Errorf("explicit GH_TOKEN must win over GH_PAT alias, got GH_TOKEN=%q", pat)
|
||||
}
|
||||
if !envContains(env, "GH_TOKEN="+explicitGH) {
|
||||
t.Errorf("explicit GH_TOKEN must be preserved, got %v", env)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit GITHUB_TOKEN wins over GH_PAT alias", func(t *testing.T) {
|
||||
cfg := WorkspaceConfig{
|
||||
WorkspaceID: "ws-x",
|
||||
PlatformURL: "http://localhost:8080",
|
||||
EnvVars: map[string]string{
|
||||
"GH_PAT": pat,
|
||||
"GITHUB_TOKEN": explicitGitHub,
|
||||
},
|
||||
}
|
||||
env := buildContainerEnv(cfg)
|
||||
if envContains(env, "GITHUB_TOKEN="+pat) {
|
||||
t.Errorf("explicit GITHUB_TOKEN must win over GH_PAT alias, got GITHUB_TOKEN=%q", pat)
|
||||
}
|
||||
if !envContains(env, "GITHUB_TOKEN="+explicitGitHub) {
|
||||
t.Errorf("explicit GITHUB_TOKEN must be preserved, got %v", env)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit both → both preserved, no alias", func(t *testing.T) {
|
||||
cfg := WorkspaceConfig{
|
||||
WorkspaceID: "ws-x",
|
||||
PlatformURL: "http://localhost:8080",
|
||||
EnvVars: map[string]string{
|
||||
"GH_PAT": pat,
|
||||
"GH_TOKEN": explicitGH,
|
||||
"GITHUB_TOKEN": explicitGitHub,
|
||||
},
|
||||
}
|
||||
env := buildContainerEnv(cfg)
|
||||
if envContains(env, "GH_TOKEN="+pat) {
|
||||
t.Errorf("explicit GH_TOKEN must win, got alias value %q", pat)
|
||||
}
|
||||
if envContains(env, "GITHUB_TOKEN="+pat) {
|
||||
t.Errorf("explicit GITHUB_TOKEN must win, got alias value %q", pat)
|
||||
}
|
||||
if !envContains(env, "GH_TOKEN="+explicitGH) {
|
||||
t.Errorf("explicit GH_TOKEN must be preserved, got %v", env)
|
||||
}
|
||||
if !envContains(env, "GITHUB_TOKEN="+explicitGitHub) {
|
||||
t.Errorf("explicit GITHUB_TOKEN must be preserved, got %v", env)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no GH_PAT → no alias injected", func(t *testing.T) {
|
||||
cfg := WorkspaceConfig{
|
||||
WorkspaceID: "ws-x",
|
||||
PlatformURL: "http://localhost:8080",
|
||||
EnvVars: map[string]string{"OTHER": "ok"},
|
||||
}
|
||||
env := buildContainerEnv(cfg)
|
||||
for _, e := range env {
|
||||
if strings.HasPrefix(e, "GH_TOKEN=") || strings.HasPrefix(e, "GITHUB_TOKEN=") {
|
||||
t.Errorf("no GH_PAT present → no alias should be injected, got %q", e)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func assertNoSCMWriteToken(t *testing.T, env []string, scmTokens []string) {
|
||||
t.Helper()
|
||||
for _, e := range env {
|
||||
|
||||
@@ -93,6 +93,9 @@ 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)
|
||||
@@ -159,6 +162,9 @@ 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,6 +166,9 @@ 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,6 +81,7 @@ func TestTestTokenRoute_RequiresAdminAuth_WhenTokensExist(t *testing.T) {
|
||||
// bootstrap path still works before the first workspace has registered.
|
||||
func TestTestTokenRoute_FailOpenOnFreshInstall(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ENV", "development")
|
||||
t.Setenv("ADMIN_TOKEN", "")
|
||||
mock := setupRouterTestDB(t)
|
||||
|
||||
// HasAnyLiveTokenGlobal: no tokens yet — fresh install.
|
||||
|
||||
@@ -182,6 +182,7 @@ 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/session/*proxyPath", wh.DisplaySession)
|
||||
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)
|
||||
|
||||
@@ -18,6 +18,7 @@ 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.GET("/workspaces/:id/display/session/*proxyPath", middleware.AdminAuth(db.DB), wh.DisplaySession)
|
||||
r.POST("/workspaces/:id/display/control/acquire", middleware.AdminAuth(db.DB), wh.AcquireDisplayControl)
|
||||
return r
|
||||
}
|
||||
@@ -59,3 +60,22 @@ func TestWorkspaceDisplayControlRoute_RequiresAdminAuth(t *testing.T) {
|
||||
t.Errorf("sqlmock unmet: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplaySessionRoute_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.MethodGet, "/workspaces/ws-display/display/session/vnc.html", 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,9 +832,18 @@ 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" 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.
|
||||
// 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].
|
||||
//
|
||||
// Known non-ok kinds:
|
||||
// - "rate_limited" — LLM API rate-limit hit (Max-plan, etc.)
|
||||
@@ -842,6 +851,59 @@ 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 ""
|
||||
@@ -854,18 +916,32 @@ 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 error envelope field).
|
||||
// result.kind (canonical JSON-RPC envelope field).
|
||||
if rawKind, ok := result["kind"]; ok {
|
||||
var k string
|
||||
if json.Unmarshal(rawKind, &k) == nil && k != "" && k != "ok" {
|
||||
return k
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
// result.result_kind (legacy / alternative field name).
|
||||
if rawKind, ok := result["result_kind"]; ok {
|
||||
var k string
|
||||
if json.Unmarshal(rawKind, &k) == nil && k != "" && k != "ok" {
|
||||
return k
|
||||
if json.Unmarshal(rawKind, &k) == nil {
|
||||
if k == "task" {
|
||||
if bad := classifyTaskState(result); bad != "" {
|
||||
return bad
|
||||
}
|
||||
} else if _, isOK := resultOKKinds[k]; !isOK {
|
||||
return k
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -702,6 +702,61 @@ 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"}]}}`,
|
||||
|
||||
Reference in New Issue
Block a user