Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84634768d9 | |||
| 406d73ff61 | |||
| 878c74eafe | |||
| f7e2976324 | |||
| 0fc1649a0c | |||
| f1571a04ab | |||
| 2e027df890 | |||
| cb59e658b2 | |||
| 8e3fd8fabe | |||
| a492d11175 | |||
| 90f0399f57 | |||
| 220a04b1b3 | |||
| be9190e57a | |||
| 5c0a48f0f5 | |||
| 50720fb84a | |||
| d594190653 | |||
| c94eca9557 | |||
| 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 |
@@ -50,9 +50,6 @@ MOLECULE_ENV=development # Environment label (development/
|
||||
# Container/runtime detection
|
||||
# MOLECULE_IN_DOCKER= # Set when running the platform inside Docker (accepts 1/0, true/false). Triggers A2A proxy to rewrite 127.0.0.1:<port> agent URLs to Docker bridge hostnames. Auto-detected via /.dockerenv; only set if detection fails or to force off.
|
||||
|
||||
# Observability (Awareness)
|
||||
# AWARENESS_URL= # If set, injected into workspace containers along with a deterministic AWARENESS_NAMESPACE derived from workspace ID. Enables the cross-session memory MCP server.
|
||||
|
||||
# GitHub
|
||||
# GITHUB_REPO=owner/repo # Target repo for agent initial_prompt clone (e.g. Molecule-AI/molecule-monorepo). Read inside workspace containers.
|
||||
# GITHUB_TOKEN= # Personal access token / installation token used by agents that clone private repos. Register as a global secret via POST /admin/secrets for propagation to workspace env. Token is used in-URL during clone and then scrubbed from .git/config via `git remote set-url`.
|
||||
|
||||
@@ -21,10 +21,6 @@ from urllib.parse import quote
|
||||
TRUE_VALUES = {"1", "true", "yes", "on", "disabled", "disable"}
|
||||
PROD_CP_URL = "https://api.moleculesai.app"
|
||||
DEFAULT_REQUIRED_CONTEXTS = [
|
||||
"CI / Platform (Go) (push)",
|
||||
"CI / Canvas (Next.js) (push)",
|
||||
"CI / Shellcheck (E2E scripts) (push)",
|
||||
"CI / Python Lint & Test (push)",
|
||||
"CI / all-required (push)",
|
||||
"Secret scan / Scan diff for credential-shaped strings (push)",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -146,3 +146,10 @@ def test_context_is_terminal_failure_rejects_cancelled_and_skipped():
|
||||
assert prod.context_is_terminal_failure(state) is True
|
||||
for state in ("pending", "missing", "success"):
|
||||
assert prod.context_is_terminal_failure(state) is False
|
||||
|
||||
|
||||
def test_default_required_contexts_delegate_path_gating_to_all_required():
|
||||
assert prod.required_contexts({}) == [
|
||||
"CI / all-required (push)",
|
||||
"Secret scan / Scan diff for credential-shaped strings (push)",
|
||||
]
|
||||
|
||||
@@ -101,7 +101,8 @@ jobs:
|
||||
# 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.
|
||||
# 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
|
||||
|
||||
+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
|
||||
|
||||
@@ -53,7 +53,7 @@ Molecule AI is the most powerful way to govern an AI agent organization in produ
|
||||
It combines the parts that are usually scattered across demos, internal glue code, and framework-specific tooling into one product:
|
||||
|
||||
- one org-native control plane for teams, roles, hierarchy, and lifecycle
|
||||
- one runtime layer that lets **eight** agent runtimes — LangGraph, DeepAgents, Claude Code, CrewAI, AutoGen, **Hermes**, **Gemini CLI**, and OpenClaw — run side by side behind one workspace contract
|
||||
- one runtime layer that lets **four** maintained agent runtimes — Claude Code, Codex, **Hermes**, and OpenClaw — run side by side behind one workspace contract
|
||||
- one memory model that keeps recall, sharing, and skill evolution aligned with organizational boundaries (Memory v2 backed by pgvector for semantic recall)
|
||||
- one operational surface for observing, pausing, restarting, inspecting, and improving live workspaces
|
||||
|
||||
@@ -75,11 +75,11 @@ You do not wire collaboration paths by hand. Hierarchy defines the default commu
|
||||
|
||||
### 3. Runtime choice stops being a dead-end decision
|
||||
|
||||
LangGraph, DeepAgents, Claude Code, CrewAI, AutoGen, Hermes, Gemini CLI, and OpenClaw can all plug into the same workspace abstraction. Teams can standardize governance without forcing every group onto one runtime.
|
||||
Claude Code, Codex, Hermes, and OpenClaw can all plug into the same workspace abstraction. Teams can standardize governance without forcing every group onto one runtime.
|
||||
|
||||
### 4. Memory is treated like infrastructure
|
||||
|
||||
Molecule AI's HMA approach is designed around organizational boundaries, not just “store more context somewhere.” Durable recall, scoped sharing, awareness namespaces, and skill promotion are all part of one coherent system.
|
||||
Molecule AI's HMA approach is designed around organizational boundaries, not just "store more context somewhere." Durable recall, scoped sharing through the v2 memory plugin, and skill promotion are all part of one coherent system.
|
||||
|
||||
### 5. It comes with a real control plane
|
||||
|
||||
@@ -101,7 +101,7 @@ Registry, heartbeats, restart, pause/resume, activity logs, approvals, terminal
|
||||
| **Role-native workspace abstraction** | Your org structure survives model swaps, framework changes, and team expansion |
|
||||
| **Fractal team expansion** | A single specialist can become a managed department without breaking upstream integrations |
|
||||
| **Heterogeneous runtime compatibility** | Different teams can keep their preferred agent architecture while sharing one control plane |
|
||||
| **HMA + awareness namespaces** | Memory sharing follows hierarchy instead of leaking across the whole system |
|
||||
| **HMA + v2 memory plugin** | Memory sharing follows hierarchy instead of leaking across the whole system; one plugin per tenant, namespace-scoped per workspace |
|
||||
| **Skill evolution loop** | Durable successful workflows can graduate from memory into reusable, hot-reloadable skills |
|
||||
| **WebSocket-first operational UX** | The canvas reflects task state, structure changes, and A2A responses in near real time |
|
||||
| **Global secrets with local override** | Centralize provider access, then override only where a workspace needs specialized credentials |
|
||||
@@ -112,13 +112,9 @@ Molecule AI is not trying to replace the frameworks below. It is the system that
|
||||
|
||||
| Runtime / architecture | Status in current repo | Native strength | What Molecule AI adds |
|
||||
|---|---|---|---|
|
||||
| **LangGraph** | Shipping on `main` | Graph control, tool use, Python extensibility | Canvas orchestration, hierarchy routing, A2A, memory scopes, operational lifecycle |
|
||||
| **DeepAgents** | Shipping on `main` | Deeper planning and decomposition | Same workspace contract, team topology, activity stream, restart behavior |
|
||||
| **Claude Code** | Shipping on `main` | Real coding workflows, CLI-native continuity | Secure workspace abstraction, A2A delegation, org boundaries, shared control plane |
|
||||
| **CrewAI** | Shipping on `main` | Role-based crews | Persistent workspace identity, policy consistency, shared canvas and registry |
|
||||
| **AutoGen** | Shipping on `main` | Assistant/tool orchestration | Standardized deployment, hierarchy-aware collaboration, shared ops plane |
|
||||
| **Codex** | Shipping on `main` | OpenAI Codex CLI workflows | Secure workspace abstraction, A2A delegation, org boundaries, shared control plane |
|
||||
| **Hermes 4** | Shipping on `main` | Hybrid reasoning, native tools, json_schema (NousResearch/hermes-agent) | Option B upstream hook, A2A bridge to OpenAI-compat API, multi-provider provider derivation |
|
||||
| **Gemini CLI** | Shipping on `main` | Google Gemini CLI continuity | Workspace lifecycle, A2A, hierarchy-aware collaboration, shared ops plane |
|
||||
| **OpenClaw** | Shipping on `main` | CLI-native runtime with its own session model | Workspace lifecycle, templates, activity logs, topology-aware collaboration |
|
||||
| **NemoClaw** | WIP on `feat/nemoclaw-t4-docker` | NVIDIA-oriented runtime path | Planned to join the same abstraction once merged; not yet part of `main` |
|
||||
|
||||
@@ -133,7 +129,7 @@ Most projects stop at “we added memory.” Molecule AI pushes further:
|
||||
| Flat store or weak namespaces | Hierarchy-aligned `LOCAL`, `TEAM`, `GLOBAL` scopes |
|
||||
| Sharing is easy to overexpose | Sharing is explicit and structure-aware |
|
||||
| Memory and procedure get mixed together | Memory stores durable facts; skills store repeatable procedure |
|
||||
| Every agent can become over-privileged | Workspace awareness namespaces reduce blast radius |
|
||||
| Every agent can become over-privileged | Per-workspace namespaces in the v2 memory plugin reduce blast radius |
|
||||
| UI memory and runtime memory blur together | Separate surfaces for scoped agent memory, key/value workspace memory, and recall |
|
||||
|
||||
### The flywheel
|
||||
@@ -163,7 +159,7 @@ Most agent systems stop at "a smart runtime." Molecule AI pushes further: it giv
|
||||
|
||||
| Core mechanism | Molecule AI module(s) | Why it matters |
|
||||
|---|---|---|
|
||||
| **Durable memory that survives sessions** | `molecule-ai-workspace-runtime/molecule_runtime/builtin_tools/`, `workspace-server/internal/handlers/memories.go` | Memory is not just durable, it is **workspace-scoped** and can route into awareness namespaces tied to the org structure |
|
||||
| **Durable memory that survives sessions** | `molecule-ai-workspace-runtime/molecule_runtime/builtin_tools/`, `workspace-server/internal/handlers/memories.go`, `workspace-server/internal/memory/` (v2 plugin client + namespace resolver) | Memory is not just durable, it is **workspace-scoped** — every write lands in the workspace's own `workspace:<id>` namespace, with `team:<root>` and `org:<root>` available for cross-workspace shares via the platform's namespace ACL when an agent explicitly promotes a memory |
|
||||
| **Cross-session recall** | `workspace-server/internal/handlers/activity.go` (`/workspaces/:id/session-search`) | Recall spans both activity history and memory rows, so the system can search what happened and what was learned without inventing a separate hidden store |
|
||||
| **Skills built from experience** | `molecule-ai-workspace-runtime/molecule_runtime/builtin_tools/memory.py` (`_maybe_log_skill_promotion`) | Promotion from memory into a skill candidate is surfaced as an explicit platform activity, not a silent internal side effect |
|
||||
| **Skill improvement during use** | `molecule-ai-workspace-runtime/molecule_runtime/skill_loader/`, `molecule-ai-workspace-runtime/molecule_runtime/main.py` | Skills hot-reload into the live runtime, so improvements become available on the next A2A task without restarting the workspace |
|
||||
@@ -172,7 +168,7 @@ Most agent systems stop at "a smart runtime." Molecule AI pushes further: it giv
|
||||
### Why this matters in Molecule AI
|
||||
|
||||
1. **The learning loop is org-aware, not just session-aware.**
|
||||
Memory can live at `LOCAL`, `TEAM`, or `GLOBAL` scope, and awareness namespaces give each workspace a durable identity boundary.
|
||||
Memory can live at `LOCAL`, `TEAM`, or `GLOBAL` scope, and the v2 plugin's namespace ACL gives each workspace a durable identity boundary.
|
||||
|
||||
2. **The learning loop is visible to operators.**
|
||||
Promotion events, activity logs, current-task updates, traces, and WebSocket fanout mean self-improvement is part of the control plane, not a hidden black box.
|
||||
@@ -209,9 +205,9 @@ The result is not just “an agent that learns.” It is **an organization that
|
||||
### Runtime
|
||||
|
||||
- standalone workspace-template images that install `molecule-ai-workspace-runtime` from the Gitea package registry; thin AMI in production (us-east-2)
|
||||
- adapter-driven execution across **8 runtimes** (Claude Code, Hermes, Gemini CLI, LangGraph, DeepAgents, CrewAI, AutoGen, OpenClaw)
|
||||
- adapter-driven execution across **4 maintained runtimes** (Claude Code, Codex, Hermes, OpenClaw)
|
||||
- Agent Card registration
|
||||
- awareness-backed memory integration; **Memory v2 backed by pgvector** for semantic recall
|
||||
- **Memory v2 backed by pgvector** — per-tenant plugin sidecar serving HMA namespaces with FTS + semantic recall
|
||||
- plugin-mounted shared rules/skills
|
||||
- hot-reloadable local skills
|
||||
- coordinator-only delegation path
|
||||
@@ -245,7 +241,7 @@ The result is not just “an agent that learns.” It is **an organization that
|
||||
Molecule AI is especially strong when you need to run:
|
||||
|
||||
- AI engineering teams with PM / Dev Lead / QA / Research / Ops roles
|
||||
- mixed runtime organizations where one team prefers LangGraph and another prefers Claude Code
|
||||
- mixed runtime organizations where one team prefers Hermes and another prefers Claude Code
|
||||
- long-lived agent organizations that need memory boundaries and reusable procedures
|
||||
- internal platforms that want to expose agent teams as structured infrastructure, not ad hoc scripts
|
||||
|
||||
@@ -260,9 +256,9 @@ Canvas (Next.js 15, warm-paper :3000) <--HTTP / WS--> Platform (Go 1.25 :8080)
|
||||
+------------------------- shows ------------------------> workspaces, teams, tasks, traces, events
|
||||
|
||||
Workspace Runtime (Python ≥3.11, image with adapters)
|
||||
- 8 adapters: LangGraph / DeepAgents / Claude Code / CrewAI / AutoGen / Hermes / Gemini CLI / OpenClaw
|
||||
- 4 adapters: Claude Code / Codex / Hermes / OpenClaw
|
||||
- Agent Card + A2A server (typed-SSOT response path, RFC #2967)
|
||||
- heartbeat + activity + awareness-backed memory (Memory v2 — pgvector semantic recall)
|
||||
- heartbeat + activity + Memory v2 (pgvector semantic recall via per-tenant plugin sidecar)
|
||||
- skills + plugins + hot reload
|
||||
|
||||
SaaS Control Plane (molecule-controlplane, private)
|
||||
@@ -328,7 +324,7 @@ Then open `http://localhost:3000`:
|
||||
|
||||
## Current Scope
|
||||
|
||||
The current `main` branch ships the core platform, Canvas v4 (warm-paper themed), Memory v2 (pgvector semantic recall), the typed-SSOT A2A response path (RFC #2967), **eight production adapters** (Claude Code, Hermes, Gemini CLI, LangGraph, DeepAgents, CrewAI, AutoGen, OpenClaw), skill lifecycle, and operational surfaces.
|
||||
The current `main` branch ships the core platform, Canvas v4 (warm-paper themed), Memory v2 (pgvector semantic recall), the typed-SSOT A2A response path (RFC #2967), **four maintained production adapters** (Claude Code, Codex, Hermes, OpenClaw), skill lifecycle, and operational surfaces.
|
||||
|
||||
The companion private repo [`molecule-controlplane`](https://git.moleculesai.app/molecule-ai/molecule-controlplane) provides the SaaS surface — multi-tenant orchestration on EC2 + Neon + Cloudflare Tunnels, KMS envelope encryption, WorkOS auth, Stripe billing, and a `tenant_resources` audit table with a 30-min reconciler.
|
||||
|
||||
|
||||
+13
-17
@@ -52,7 +52,7 @@ Molecule AI 是目前最强的 AI Agent 组织治理方案之一,用来把 age
|
||||
它把过去分散在 demo、内部胶水代码和各类 framework 私有工具里的关键能力,收敛成一个产品:
|
||||
|
||||
- 一套组织原生 control plane,管理团队、角色、层级和生命周期
|
||||
- 一套 runtime abstraction,让 **8 个** agent runtime —— LangGraph、DeepAgents、Claude Code、CrewAI、AutoGen、**Hermes**、**Gemini CLI**、OpenClaw —— 共用一套 workspace 契约
|
||||
- 一套 runtime abstraction,让 **4 个**维护中的 agent runtime —— Claude Code、Codex、**Hermes**、OpenClaw —— 共用一套 workspace 契约
|
||||
- 一套与组织边界对齐的 memory 模型,把 recall、sharing 和 skill evolution 放进同一体系(Memory v2 由 pgvector 支撑语义召回)
|
||||
- 一套面向线上 workspace 的运维面,统一完成观测、暂停、重启、检查和持续改进
|
||||
|
||||
@@ -74,11 +74,11 @@ Molecule AI 填的就是这个空白。
|
||||
|
||||
### 3. Runtime 选择不再是死路
|
||||
|
||||
LangGraph、DeepAgents、Claude Code、CrewAI、AutoGen、Hermes、Gemini CLI、OpenClaw 都可以挂到同一个 workspace abstraction 下。团队可以统一治理方式,而不必统一到底层 runtime。
|
||||
Claude Code、Codex、Hermes、OpenClaw 都可以挂到同一个 workspace abstraction 下。团队可以统一治理方式,而不必统一到底层 runtime。
|
||||
|
||||
### 4. Memory 被当成基础设施来做
|
||||
|
||||
Molecule AI 的 HMA 不是“多存一点上下文”而已。它关注组织边界、durable recall、scope sharing、awareness namespace、skill promotion,把这些放在一个完整体系里。
|
||||
Molecule AI 的 HMA 不是“多存一点上下文”而已。它关注组织边界、durable recall、scope sharing、v2 memory plugin、skill promotion,把这些放在一个完整体系里。
|
||||
|
||||
### 5. 它自带真正的 control plane
|
||||
|
||||
@@ -100,7 +100,7 @@ Registry、heartbeat、restart、pause/resume、activity、approval、terminal
|
||||
| **角色原生 workspace 抽象** | 模型切换、框架切换、团队扩容都不会打碎你的组织结构 |
|
||||
| **分形式团队扩展** | 一个 specialist 可以平滑升级成一个部门,而不影响上游集成 |
|
||||
| **异构 runtime 兼容** | 不同团队可以保留偏好的 agent 架构,但共用一套平台规则 |
|
||||
| **HMA + awareness namespace** | Memory 分享沿组织边界走,而不是全局乱穿透 |
|
||||
| **HMA + v2 memory plugin** | Memory 分享沿组织边界走,而不是全局乱穿透;每个 tenant 一个 plugin,按 workspace namespace 隔离 |
|
||||
| **Skill 演化闭环** | 成功工作流可以从 memory 逐步提升成可热加载的 skill |
|
||||
| **WebSocket-first 运维体验** | Canvas 能即时反映任务状态、结构变更和 A2A 响应 |
|
||||
| **Global secrets + local override** | 统一管理 provider 凭据,只在需要时做 workspace 级覆写 |
|
||||
@@ -111,13 +111,9 @@ Molecule AI 并不是要替代下面这些 framework,而是把它们纳入更
|
||||
|
||||
| Runtime / 架构 | 当前仓库状态 | 原生优势 | Molecule AI 额外补上的能力 |
|
||||
|---|---|---|---|
|
||||
| **LangGraph** | `main` 已支持 | 图控制强、工具调用成熟、Python 扩展性好 | Canvas orchestration、层级路由、A2A、memory scope、operational lifecycle |
|
||||
| **DeepAgents** | `main` 已支持 | 规划和任务拆解更强 | 同一套 workspace contract、团队拓扑、activity、restart 行为 |
|
||||
| **Claude Code** | `main` 已支持 | 真实编码工作流、CLI-native continuity | 安全 workspace 抽象、A2A delegation、组织边界、共享 control plane |
|
||||
| **CrewAI** | `main` 已支持 | 角色型 crew 模式清晰 | 持久 workspace 身份、统一策略、共享 Canvas 和 registry |
|
||||
| **AutoGen** | `main` 已支持 | assistant/tool orchestration | 统一部署、层级协作、共享运维平面 |
|
||||
| **Codex** | `main` 已支持 | OpenAI Codex CLI 工作流 | 安全 workspace 抽象、A2A delegation、组织边界、共享 control plane |
|
||||
| **Hermes 4** | `main` 已支持 | 混合推理、原生工具调用、json_schema 输出(NousResearch/hermes-agent) | Option B 上游 hook、A2A 桥接 OpenAI 兼容 API、多 provider 自动派生 |
|
||||
| **Gemini CLI** | `main` 已支持 | Google Gemini CLI 持续会话 | workspace 生命周期、A2A、层级感知协作、共享运维平面 |
|
||||
| **OpenClaw** | `main` 已支持 | CLI-native runtime,自有 session 模型 | workspace 生命周期、templates、activity logs、拓扑感知协作 |
|
||||
| **NemoClaw** | `feat/nemoclaw-t4-docker` 分支 WIP | NVIDIA 方向 runtime 路线 | 计划并入同一抽象层,但当前还不是 `main` 已合并能力 |
|
||||
|
||||
@@ -132,7 +128,7 @@ Molecule AI 并不是要替代下面这些 framework,而是把它们纳入更
|
||||
| 扁平 store 或弱命名空间隔离 | 与层级对齐的 `LOCAL`、`TEAM`、`GLOBAL` scope |
|
||||
| 分享很容易越界 | 分享是显式且结构感知的 |
|
||||
| Memory 和 procedure 混成一团 | Memory 存 durable facts,skills 存 repeatable procedure |
|
||||
| 任意 agent 容易过权 | workspace awareness namespace 缩小 blast radius |
|
||||
| 任意 agent 容易过权 | v2 memory plugin 的 per-workspace namespace 缩小 blast radius |
|
||||
| UI memory 和 runtime memory 混在一起 | scoped agent memory、key/value workspace memory、recall surface 分层清晰 |
|
||||
|
||||
### 这套飞轮怎么转
|
||||
@@ -162,7 +158,7 @@ Molecule AI 并不是要替代下面这些 framework,而是把它们纳入更
|
||||
|
||||
| 核心机制 | Molecule AI 对应模块 | 为什么重要 |
|
||||
|---|---|---|
|
||||
| **跨 session 的 durable memory** | `workspace/builtin_tools/memory.py`、`workspace/builtin_tools/awareness_client.py`、`workspace-server/internal/handlers/memories.go` | 不只是持久化,而且是**按 workspace 隔离**的,可进一步路由到和组织结构绑定的 awareness namespace |
|
||||
| **跨 session 的 durable memory** | `workspace/builtin_tools/memory.py`、`workspace-server/internal/handlers/memories.go`、`workspace-server/internal/memory/`(v2 plugin client + namespace resolver)| 不只是持久化,而且是**按 workspace 隔离**的 —— 每次写入都落在 workspace 自己的 `workspace:<id>` namespace 里;当 agent 显式升级到跨 workspace 共享时,可以通过平台 namespace ACL 写到 `team:<root>` 和 `org:<root>` |
|
||||
| **Cross-session recall** | `workspace-server/internal/handlers/activity.go` 中的 `/workspaces/:id/session-search` | Recall 同时覆盖 activity history 和 memory rows,不需要再造一个隐蔽的新存储层 |
|
||||
| **从经验里长出技能** | `workspace/builtin_tools/memory.py` 里的 `_maybe_log_skill_promotion` | 从 memory 到 skill candidate 的提升会被显式记录成平台 activity,而不是默默发生在黑盒里 |
|
||||
| **技能在使用中持续改进** | `workspace/skill_loader/watcher.py`、`workspace/skill_loader/loader.py`、`workspace/main.py` | Skill 改动可以热加载进 live runtime,下一次 A2A 任务就能直接使用,不需要重启 workspace |
|
||||
@@ -171,7 +167,7 @@ Molecule AI 并不是要替代下面这些 framework,而是把它们纳入更
|
||||
### 为什么这在 Molecule AI 里更适合团队级系统
|
||||
|
||||
1. **学习闭环是 org-aware 的,而不只是 session-aware。**
|
||||
Memory 可以按 `LOCAL`、`TEAM`、`GLOBAL` scope 运作,awareness namespace 让每个 workspace 都有清晰的持久边界。
|
||||
Memory 可以按 `LOCAL`、`TEAM`、`GLOBAL` scope 运作,v2 plugin 的 namespace ACL 让每个 workspace 都有清晰的持久边界。
|
||||
|
||||
2. **学习闭环是对运维可见的。**
|
||||
Promotion events、activity logs、current-task updates、traces、WebSocket fanout 让自我进化进入 control plane,而不是藏在黑盒内部。
|
||||
@@ -208,9 +204,9 @@ Molecule AI 并不是要替代下面这些 framework,而是把它们纳入更
|
||||
### Runtime
|
||||
|
||||
- 统一 `workspace/` 镜像;生产环境采用 thin AMI(us-east-2)
|
||||
- adapter 驱动执行,覆盖 **8 个 runtime**(Claude Code、Hermes、Gemini CLI、LangGraph、DeepAgents、CrewAI、AutoGen、OpenClaw)
|
||||
- adapter 驱动执行,覆盖 **4 个维护中的 runtime**(Claude Code、Codex、Hermes、OpenClaw)
|
||||
- Agent Card 注册
|
||||
- awareness-backed memory;**Memory v2 由 pgvector 支撑**语义召回
|
||||
- **Memory v2 由 pgvector 支撑** —— 每个 tenant 一个 plugin sidecar,承载 HMA namespace、FTS 与语义召回
|
||||
- plugin 挂载共享 rules/skills
|
||||
- 本地 skills 热加载
|
||||
- coordinator-only delegation 路径
|
||||
@@ -259,9 +255,9 @@ Canvas (Next.js 15, warm-paper :3000) <--HTTP / WS--> Platform (Go 1.25 :8080)
|
||||
+------------------------- 展示 ------------------------> workspaces, teams, tasks, traces, events
|
||||
|
||||
Workspace Runtime (Python ≥3.11,含 adapter 集合的镜像)
|
||||
- 8 个 adapter: LangGraph / DeepAgents / Claude Code / CrewAI / AutoGen / Hermes / Gemini CLI / OpenClaw
|
||||
- 4 个 adapter: Claude Code / Codex / Hermes / OpenClaw
|
||||
- Agent Card + A2A server(typed-SSOT 响应路径,RFC #2967)
|
||||
- heartbeat + activity + awareness-backed memory(Memory v2 —— pgvector 语义召回)
|
||||
- heartbeat + activity + Memory v2(pgvector 语义召回,per-tenant plugin sidecar)
|
||||
- skills + plugins + hot reload
|
||||
|
||||
SaaS Control Plane (molecule-controlplane,私有)
|
||||
@@ -321,7 +317,7 @@ npm run dev
|
||||
|
||||
## 当前范围说明
|
||||
|
||||
当前 `main` 已经包含核心平台、Canvas v4(warm-paper 主题)、Memory v2(pgvector 语义召回)、typed-SSOT A2A 响应路径(RFC #2967)、**8 个正式 adapter**(Claude Code、Hermes、Gemini CLI、LangGraph、DeepAgents、CrewAI、AutoGen、OpenClaw)、skill lifecycle,以及主要运维面。
|
||||
当前 `main` 已经包含核心平台、Canvas v4(warm-paper 主题)、Memory v2(pgvector 语义召回)、typed-SSOT A2A 响应路径(RFC #2967)、**4 个维护中的正式 adapter**(Claude Code、Codex、Hermes、OpenClaw)、skill lifecycle,以及主要运维面。
|
||||
|
||||
配套的私有仓库 [`molecule-controlplane`](https://git.moleculesai.app/molecule-ai/molecule-controlplane) 提供 SaaS 层 —— 多租户编排(EC2 + Neon + Cloudflare Tunnels)、KMS 信封加密、WorkOS 鉴权、Stripe 计费,以及 `tenant_resources` 审计表加 30 分钟 reconciler。
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -253,7 +253,7 @@ interface RuntimeOption {
|
||||
// its config.yaml under runtime_config.providers. The /templates API
|
||||
// surfaces it (workspace-server templates.go) so canvas stays
|
||||
// adapter-driven: hermes ships ~20 slugs, claude-code ships
|
||||
// ["anthropic"], gemini-cli ships ["gemini"], etc. Empty list →
|
||||
// ["anthropic"], codex ships OpenAI-compatible model ids, etc. Empty list →
|
||||
// canvas falls back to deriving unique vendor prefixes from
|
||||
// models[].id (still adapter-driven, just inferred).
|
||||
providers: string[];
|
||||
@@ -301,16 +301,13 @@ export function deriveProvidersFromModels(models: ModelSpec[]): string[] {
|
||||
// config.yaml` on the container is a separate runtime-internal file,
|
||||
// not this one.
|
||||
const RUNTIMES_WITH_OWN_CONFIG = new Set<string>(["external", "kimi", "kimi-cli", "openclaw"]);
|
||||
const SUPPORTED_RUNTIME_VALUES = new Set(["claude-code", "codex", "openclaw", "hermes"]);
|
||||
|
||||
const FALLBACK_RUNTIME_OPTIONS: RuntimeOption[] = [
|
||||
{ value: "", label: "LangGraph (default)", models: [], providers: [] },
|
||||
{ value: "claude-code", label: "Claude Code", models: [], providers: [] },
|
||||
{ value: "crewai", label: "CrewAI", models: [], providers: [] },
|
||||
{ value: "autogen", label: "AutoGen", models: [], providers: [] },
|
||||
{ value: "deepagents", label: "DeepAgents", models: [], providers: [] },
|
||||
{ value: "codex", label: "Codex", models: [], providers: [] },
|
||||
{ value: "openclaw", label: "OpenClaw", models: [], providers: [] },
|
||||
{ value: "hermes", label: "Hermes", models: [], providers: [] },
|
||||
{ value: "gemini-cli", label: "Gemini CLI", models: [], providers: [] },
|
||||
];
|
||||
|
||||
export function ConfigTab({ workspaceId }: Props) {
|
||||
@@ -499,10 +496,9 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
.then((rows) => {
|
||||
if (cancelled || !Array.isArray(rows)) return;
|
||||
const byRuntime = new Map<string, RuntimeOption>();
|
||||
byRuntime.set("", { value: "", label: "LangGraph (default)", models: [], providers: [] });
|
||||
for (const r of rows) {
|
||||
const v = (r.runtime || "").trim();
|
||||
if (!v || v === "langgraph") continue;
|
||||
if (!SUPPORTED_RUNTIME_VALUES.has(v)) continue;
|
||||
// Last template wins if two templates share a runtime — rare, and the
|
||||
// one with the richer models list is probably newer.
|
||||
const existing = byRuntime.get(v);
|
||||
@@ -512,7 +508,7 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
byRuntime.set(v, { value: v, label: r.name || v, models, providers });
|
||||
}
|
||||
}
|
||||
if (byRuntime.size > 1) setRuntimeOptions(Array.from(byRuntime.values()));
|
||||
if (byRuntime.size > 0) setRuntimeOptions(Array.from(byRuntime.values()));
|
||||
})
|
||||
.catch(() => { /* keep fallback */ });
|
||||
return () => { cancelled = true; };
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import type RFB from "@novnc/novnc";
|
||||
|
||||
interface DisplayStatus {
|
||||
available: boolean;
|
||||
@@ -17,6 +18,7 @@ interface DisplayControlStatus {
|
||||
controller: "none" | "user" | "agent";
|
||||
controlled_by?: string;
|
||||
expires_at?: string;
|
||||
session_url?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -29,6 +31,7 @@ export function DisplayTab({ workspaceId }: Props) {
|
||||
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(() => {
|
||||
@@ -37,6 +40,7 @@ export function DisplayTab({ workspaceId }: Props) {
|
||||
let cancelled = false;
|
||||
setStatus(null);
|
||||
setControl(null);
|
||||
setSessionUrl(null);
|
||||
setError(null);
|
||||
setControlError(null);
|
||||
setControlBusy(false);
|
||||
@@ -77,6 +81,7 @@ export function DisplayTab({ workspaceId }: Props) {
|
||||
});
|
||||
if (requestGeneration.current !== generation) return;
|
||||
setControl(next);
|
||||
setSessionUrl(next.session_url || null);
|
||||
} catch (err) {
|
||||
if (requestGeneration.current !== generation) return;
|
||||
setControlError("Failed to take control");
|
||||
@@ -93,6 +98,32 @@ export function DisplayTab({ workspaceId }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const releaseControl = async () => {
|
||||
const generation = requestGeneration.current;
|
||||
const controlPath = `/workspaces/${workspaceId}/display/control`;
|
||||
setControlBusy(true);
|
||||
setControlError(null);
|
||||
try {
|
||||
const next = await api.post<DisplayControlStatus>(`${controlPath}/release`, {});
|
||||
if (requestGeneration.current !== generation) return;
|
||||
setControl(next);
|
||||
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">
|
||||
@@ -185,7 +216,152 @@ export function DisplayTab({ workspaceId }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
return (
|
||||
<div className="flex h-full min-h-[360px] flex-col bg-surface-sunken/30">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-line/50 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium text-ink">Desktop</h3>
|
||||
<p className="mt-0.5 font-mono text-[10px] text-ink-mid">
|
||||
{status.mode || "desktop-control"} · {status.protocol || "display"}
|
||||
</p>
|
||||
</div>
|
||||
<DisplayControlBar
|
||||
control={control}
|
||||
controlBusy={controlBusy}
|
||||
controlError={controlError}
|
||||
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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,11 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
const { mockGet, mockPost } = vi.hoisted(() => ({ mockGet: vi.fn(), mockPost: vi.fn() }));
|
||||
const { mockGet, mockPost, mockRFBConstructor } = vi.hoisted(() => ({
|
||||
mockGet: vi.fn(),
|
||||
mockPost: vi.fn(),
|
||||
mockRFBConstructor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
@@ -11,6 +15,25 @@ vi.mock("@/lib/api", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
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() {}
|
||||
},
|
||||
}));
|
||||
|
||||
import { DisplayTab } from "../DisplayTab";
|
||||
|
||||
describe("DisplayTab", () => {
|
||||
@@ -18,6 +41,7 @@ describe("DisplayTab", () => {
|
||||
cleanup();
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
mockRFBConstructor.mockReset();
|
||||
});
|
||||
|
||||
it("renders unavailable state for non-display workspaces", async () => {
|
||||
@@ -71,6 +95,98 @@ describe("DisplayTab", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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({
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
});
|
||||
@@ -12,47 +12,35 @@ import { resolveRuntime } from "../deploy-preflight";
|
||||
|
||||
describe("resolveRuntime", () => {
|
||||
describe("explicit runtime-map entries", () => {
|
||||
it('maps "langgraph" to "langgraph"', () => {
|
||||
expect(resolveRuntime("langgraph")).toBe("langgraph");
|
||||
});
|
||||
|
||||
it('maps "claude-code-default" to "claude-code"', () => {
|
||||
expect(resolveRuntime("claude-code-default")).toBe("claude-code");
|
||||
});
|
||||
|
||||
it('maps "codex" to "codex"', () => {
|
||||
expect(resolveRuntime("codex")).toBe("codex");
|
||||
});
|
||||
|
||||
it('maps "hermes" to "hermes"', () => {
|
||||
expect(resolveRuntime("hermes")).toBe("hermes");
|
||||
});
|
||||
|
||||
it('maps "openclaw" to "openclaw"', () => {
|
||||
expect(resolveRuntime("openclaw")).toBe("openclaw");
|
||||
});
|
||||
|
||||
it('maps "deepagents" to "deepagents"', () => {
|
||||
expect(resolveRuntime("deepagents")).toBe("deepagents");
|
||||
});
|
||||
|
||||
it('maps "crewai" to "crewai"', () => {
|
||||
expect(resolveRuntime("crewai")).toBe("crewai");
|
||||
});
|
||||
|
||||
it('maps "autogen" to "autogen"', () => {
|
||||
expect(resolveRuntime("autogen")).toBe("autogen");
|
||||
});
|
||||
});
|
||||
|
||||
describe("identity fallback for modern template ids", () => {
|
||||
it("returns the id unchanged when not in the map", () => {
|
||||
expect(resolveRuntime("hermes")).toBe("hermes");
|
||||
});
|
||||
|
||||
it("strips trailing -default suffix as fallback", () => {
|
||||
expect(resolveRuntime("hermes-default")).toBe("hermes");
|
||||
});
|
||||
|
||||
it("strips -default only when it is the suffix", () => {
|
||||
// "default-something" should NOT strip
|
||||
expect(resolveRuntime("default-langgraph")).toBe("default-langgraph");
|
||||
expect(resolveRuntime("default-custom")).toBe("default-custom");
|
||||
});
|
||||
|
||||
it("returns the id unchanged when id has no -default suffix", () => {
|
||||
expect(resolveRuntime("gemini-cli")).toBe("gemini-cli");
|
||||
expect(resolveRuntime("custom-runtime")).toBe("custom-runtime");
|
||||
});
|
||||
|
||||
it("handles custom template ids from community templates", () => {
|
||||
|
||||
@@ -13,11 +13,9 @@ import { runtimeDisplayName } from "../runtime-names";
|
||||
describe("runtimeDisplayName", () => {
|
||||
it.each([
|
||||
["claude-code", "Claude Code"],
|
||||
["langgraph", "LangGraph"],
|
||||
["deepagents", "DeepAgents"],
|
||||
["codex", "Codex"],
|
||||
["hermes", "Hermes"],
|
||||
["openclaw", "OpenClaw"],
|
||||
["crewai", "CrewAI"],
|
||||
["autogen", "AutoGen"],
|
||||
])("known runtime %q maps to %q", (input, expected) => {
|
||||
expect(runtimeDisplayName(input)).toBe(expected);
|
||||
});
|
||||
@@ -25,7 +23,6 @@ describe("runtimeDisplayName", () => {
|
||||
it("unknown runtime falls back to the input string verbatim", () => {
|
||||
// A future runtime not yet in the lookup map should render with
|
||||
// its own id — better than a generic placeholder for ops debugging.
|
||||
expect(runtimeDisplayName("hermes")).toBe("hermes");
|
||||
expect(runtimeDisplayName("custom-runtime-9000")).toBe(
|
||||
"custom-runtime-9000",
|
||||
);
|
||||
@@ -43,6 +40,6 @@ describe("runtimeDisplayName", () => {
|
||||
// the input "for safety" doesn't silently change behavior — the
|
||||
// upstream slug is already normalized lowercase.
|
||||
expect(runtimeDisplayName("Claude-Code")).toBe("Claude-Code");
|
||||
expect(runtimeDisplayName("LANGGRAPH")).toBe("LANGGRAPH");
|
||||
expect(runtimeDisplayName("CODEX")).toBe("CODEX");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,12 +63,10 @@ export interface Template extends TemplateLike {
|
||||
* id needs a non-identity mapping. */
|
||||
export function resolveRuntime(templateId: string): string {
|
||||
const runtimeMap: Record<string, string> = {
|
||||
langgraph: "langgraph",
|
||||
"claude-code-default": "claude-code",
|
||||
codex: "codex",
|
||||
hermes: "hermes",
|
||||
openclaw: "openclaw",
|
||||
deepagents: "deepagents",
|
||||
crewai: "crewai",
|
||||
autogen: "autogen",
|
||||
};
|
||||
return runtimeMap[templateId] ?? templateId.replace(/-default$/, "");
|
||||
}
|
||||
|
||||
@@ -4,11 +4,9 @@
|
||||
|
||||
const RUNTIME_NAMES: Record<string, string> = {
|
||||
"claude-code": "Claude Code",
|
||||
langgraph: "LangGraph",
|
||||
deepagents: "DeepAgents",
|
||||
codex: "Codex",
|
||||
hermes: "Hermes",
|
||||
openclaw: "OpenClaw",
|
||||
crewai: "CrewAI",
|
||||
autogen: "AutoGen",
|
||||
kimi: "Kimi",
|
||||
"kimi-cli": "Kimi CLI",
|
||||
};
|
||||
|
||||
@@ -49,7 +49,7 @@ export interface RuntimeProfile {
|
||||
}
|
||||
|
||||
/** The floor every runtime inherits unless it overrides. Calibrated for
|
||||
* docker-local fast runtimes (claude-code, langgraph, crewai) where cold
|
||||
* docker-local fast runtimes (claude-code, codex, openclaw) where cold
|
||||
* boot is 30-90s. */
|
||||
export const DEFAULT_RUNTIME_PROFILE: Required<
|
||||
Pick<RuntimeProfile, "provisionTimeoutMs">
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
## Overview
|
||||
|
||||
The workspace runtime uses a **pluggable adapter architecture** — each agent infrastructure (Claude Code, OpenClaw, LangGraph, CrewAI, AutoGen, etc.) has its own adapter that bridges the A2A protocol to the infra's native interface.
|
||||
The workspace runtime uses a **pluggable adapter architecture** — each maintained agent infrastructure (Claude Code, Codex, Hermes, OpenClaw) has its own adapter that bridges the A2A protocol to the infra's native interface.
|
||||
|
||||
Adapters live in `workspace/adapters/<runtime>/` and are auto-discovered at startup. Each adapter implements `BaseAdapter` (from `adapters/base.py`) with `setup()` and `create_executor()` methods.
|
||||
|
||||
The runtime is selected via `config.yaml`:
|
||||
|
||||
```yaml
|
||||
runtime: claude-code # or: langgraph, openclaw, deepagents, crewai, autogen
|
||||
runtime: claude-code # or: codex, hermes, openclaw
|
||||
runtime_config:
|
||||
model: sonnet
|
||||
auth_token_file: .auth-token
|
||||
@@ -18,7 +18,7 @@ runtime_config:
|
||||
|
||||
## How It Works
|
||||
|
||||
The unified `workspace-template` Docker image includes both Python (LangGraph) and Node.js (CLI runtimes). At startup, `main.py` checks the `runtime` field in `config.yaml`, discovers the matching adapter in `adapters/<runtime>/`, calls `adapter.setup(config)` then `adapter.create_executor(config)` to get an `AgentExecutor` that handles A2A requests.
|
||||
The unified runtime checks the `runtime` field in `config.yaml`, discovers the matching adapter, calls `adapter.setup(config)` then `adapter.create_executor(config)` to get an `AgentExecutor` that handles A2A requests.
|
||||
|
||||
```
|
||||
A2A request arrives
|
||||
@@ -28,7 +28,7 @@ AgentExecutor.execute(context, event_queue)
|
||||
| - extracts user message from A2A parts
|
||||
| - extracts conversation history from params.metadata.history
|
||||
| - sets current_task on heartbeat (shows on canvas card)
|
||||
| - invokes the runtime (LangGraph graph, CLI subprocess, etc.)
|
||||
| - invokes the runtime adapter
|
||||
v
|
||||
Response → A2A event queue → JSON-RPC response
|
||||
```
|
||||
@@ -37,9 +37,9 @@ Response → A2A event queue → JSON-RPC response
|
||||
|
||||
Chat sessions in the Canvas UI send prior messages (up to 20) via `params.metadata.history` in each A2A `message/send` request. Executors extract this history:
|
||||
|
||||
- **LangGraph/DeepAgents**: Prepends history as `("human", text)` / `("ai", text)` tuples to the LangGraph message list
|
||||
- **CrewAI/AutoGen**: Prepends history as a text prefix in the task description (`"Conversation so far:\n..."`)
|
||||
- **Claude Code**: Uses `--resume <session_id>` for native session continuity (history not needed)
|
||||
- **Codex**: Uses the Codex runtime's native session state
|
||||
- **Hermes**: Uses Hermes' agent runtime session handling
|
||||
- **OpenClaw**: Uses `--session-id` for native session continuity
|
||||
|
||||
### Current Task Reporting
|
||||
@@ -48,10 +48,6 @@ All executors update the workspace's `current_task` via the heartbeat during exe
|
||||
|
||||
## Built-in Adapters
|
||||
|
||||
### LangGraph (`runtime: langgraph`) — Default
|
||||
|
||||
Full Python agent with LangGraph ReAct pattern. Supports skills, tools, plugins, peer coordination, and team routing.
|
||||
|
||||
### Claude Code (`runtime: claude-code`)
|
||||
|
||||
```yaml
|
||||
@@ -71,35 +67,18 @@ The SDK uses the same Claude Code engine under the hood — plugins, CLAUDE.md d
|
||||
|
||||
**Important:** Claude Code refuses to run as root with `--dangerously-skip-permissions`. The Dockerfile creates a non-root `agent` user.
|
||||
|
||||
### CrewAI (`runtime: crewai`)
|
||||
|
||||
Role-based multi-agent framework. Creates a CrewAI Agent + Task + Crew per request with A2A delegation tools (`delegate_to_peer`, `list_available_peers`).
|
||||
### Codex (`runtime: codex`)
|
||||
|
||||
```yaml
|
||||
runtime: crewai
|
||||
model: openrouter:google/gemini-2.5-flash
|
||||
runtime: codex
|
||||
model: openai/gpt-5.3-codex
|
||||
```
|
||||
|
||||
**Auth:** Uses `OPENROUTER_API_KEY` or `OPENAI_API_KEY` env var.
|
||||
|
||||
### AutoGen (`runtime: autogen`)
|
||||
|
||||
Microsoft AutoGen AssistantAgent with tool use. Creates an `AssistantAgent` per request with A2A delegation tools.
|
||||
### Hermes (`runtime: hermes`)
|
||||
|
||||
```yaml
|
||||
runtime: autogen
|
||||
model: openai:gpt-4.1-mini
|
||||
```
|
||||
|
||||
**Auth:** Uses `OPENAI_API_KEY` env var.
|
||||
|
||||
### DeepAgents (`runtime: deepagents`)
|
||||
|
||||
LangGraph-based agent with deep planning capabilities. Uses the same `LangGraphA2AExecutor` as the default runtime but with a specialized agent setup including delegation, memory, and search tools.
|
||||
|
||||
```yaml
|
||||
runtime: deepagents
|
||||
model: openrouter:google/gemini-2.5-flash
|
||||
runtime: hermes
|
||||
model: openai/gpt-4o
|
||||
```
|
||||
|
||||
### OpenClaw (`runtime: openclaw`)
|
||||
@@ -219,9 +198,9 @@ a2a info # Show workspace info
|
||||
|
||||
Both approaches use the same backend: platform registry for discovery, A2A protocol for messaging, and access control enforcement (parent↔child, siblings only).
|
||||
|
||||
## Workspace Awareness
|
||||
## Memory Tools
|
||||
|
||||
CLI runtimes keep the same memory tool surface as the Python runtime. When `AWARENESS_URL` and `AWARENESS_NAMESPACE` are injected into the workspace, `commit_memory` and `search_memory` route to the workspace's own awareness namespace instead of the fallback platform memory API. This keeps the agent contract stable while giving each workspace an isolated memory scope.
|
||||
CLI runtimes keep the same memory tool surface as the Python runtime: `commit_memory` / `commit_memory_v2` / `search_memory` / `commit_summary` / `forget_memory` are exposed via the workspace's MCP bridge and route through the platform's v2 memory plugin under the workspace's `workspace:<id>` namespace. See [Memory Architecture](../architecture/memory.md) for the backend.
|
||||
|
||||
## Task Status Reporting
|
||||
|
||||
|
||||
@@ -103,8 +103,6 @@ env:
|
||||
required:
|
||||
- ANTHROPIC_API_KEY
|
||||
optional:
|
||||
- AWARENESS_URL
|
||||
- AWARENESS_NAMESPACE
|
||||
- ANTHROPIC_BASE_URL
|
||||
- OPENAI_BASE_URL
|
||||
- GSC_CLIENT_ID
|
||||
|
||||
@@ -4,13 +4,11 @@ The `workspace/` directory is Molecule AI's unified runtime image. Every provisi
|
||||
|
||||
## Runtime Matrix In Current `main`
|
||||
|
||||
Current `main` ships six adapters:
|
||||
Current `main` ships four maintained adapters:
|
||||
|
||||
- `langgraph`
|
||||
- `deepagents`
|
||||
- `claude-code`
|
||||
- `crewai`
|
||||
- `autogen`
|
||||
- `codex`
|
||||
- `hermes`
|
||||
- `openclaw`
|
||||
|
||||
This is the merged runtime surface today. Branch-level experiments such as NemoClaw are separate and should be treated as roadmap/WIP, not merged support.
|
||||
@@ -27,7 +25,7 @@ Adapter-specific behavior is documented in [Agent Runtime Adapters](./cli-runtim
|
||||
- serving A2A over HTTP
|
||||
- registering with the platform and sending heartbeats
|
||||
- reporting activity and task state
|
||||
- integrating with awareness-backed memory when configured
|
||||
- proxying durable memory tools through the v2 memory plugin
|
||||
- hot-reloading skills while the workspace is running
|
||||
|
||||
## Environment Model
|
||||
@@ -39,8 +37,6 @@ WORKSPACE_ID=ws-123
|
||||
WORKSPACE_CONFIG_PATH=/configs
|
||||
PLATFORM_URL=http://platform:8080
|
||||
PARENT_ID=
|
||||
AWARENESS_URL=http://awareness:37800
|
||||
AWARENESS_NAMESPACE=workspace:ws-123
|
||||
LANGFUSE_HOST=http://langfuse-web:3000
|
||||
LANGFUSE_PUBLIC_KEY=...
|
||||
LANGFUSE_SECRET_KEY=...
|
||||
@@ -49,8 +45,7 @@ LANGFUSE_SECRET_KEY=...
|
||||
Important behavior:
|
||||
|
||||
- `WORKSPACE_CONFIG_PATH` points at the mounted config directory for that workspace.
|
||||
- `AWARENESS_URL` + `AWARENESS_NAMESPACE` enable workspace-scoped awareness-backed memory.
|
||||
- If awareness is absent, runtime memory tools fall back to the platform memory endpoints for compatibility.
|
||||
- Memory MCP tools route through the platform's v2 memory plugin (see Memory Architecture doc); there is no per-workspace memory env var anymore — the plugin sidecar is provisioned at the tenant EC2 boundary.
|
||||
|
||||
## Startup Sequence
|
||||
|
||||
@@ -82,8 +77,7 @@ At a high level, `workspace/main.py` does this:
|
||||
| `skills/loader.py` | Parses `SKILL.md`, loads tool modules, returns loaded skill metadata |
|
||||
| `skills/watcher.py` | Hot reload path for skill changes |
|
||||
| `plugins.py` | Scans mounted plugins for shared rules, prompt fragments, and extra skills |
|
||||
| `tools/memory.py` | Agent memory tools |
|
||||
| `tools/awareness_client.py` | Awareness-backed persistence wrapper |
|
||||
| `tools/memory.py` | Agent memory tools (route through the platform's v2 memory plugin via the workspace-server proxy) |
|
||||
| `coordinator.py` | Coordinator-only delegation path for team leads |
|
||||
|
||||
## Skills, Plugins, And Hot Reload
|
||||
@@ -103,23 +97,28 @@ Hot reload matters because the runtime is designed to keep a workspace alive whi
|
||||
|
||||
The watcher rescans the skill package, rebuilds the agent tool surface, and updates the Agent Card so peers and the canvas reflect the new capabilities.
|
||||
|
||||
## Awareness And Memory Integration
|
||||
## Memory Integration
|
||||
|
||||
The runtime keeps the agent-facing contract stable:
|
||||
|
||||
- `commit_memory(content, scope)`
|
||||
- `search_memory(query, scope)`
|
||||
- `commit_memory(content, scope)` — legacy MCP name, routed through the
|
||||
v2 plugin's scope→namespace shim
|
||||
- `commit_memory_v2(content, namespace)` — direct v2 surface
|
||||
- `search_memory(query, namespace?)` — v2 plugin search with FTS +
|
||||
semantic scoring when the plugin declares the capability
|
||||
|
||||
When awareness is configured:
|
||||
All writes land in the workspace's `workspace:<workspace_id>` namespace
|
||||
unless the agent passes an explicit one. Cross-workspace namespaces
|
||||
(`team:<root>`, `org:<root>`) follow the platform's namespace ACL
|
||||
(`internal/memory/namespace/resolver.go`). There is no per-workspace
|
||||
memory env var on the runtime side — the plugin lives on the tenant
|
||||
EC2 at `localhost:9100`, spawned by `entrypoint-tenant.sh` when
|
||||
`MEMORY_PLUGIN_URL` is present in the tenant-server's env (CP
|
||||
user-data injects it during tenant provisioning). The workspace-server
|
||||
proxies all memory calls through that sidecar.
|
||||
|
||||
- the tools route durable facts to the workspace's own awareness namespace
|
||||
- the namespace defaults to `workspace:<workspace_id>` unless explicitly overridden
|
||||
|
||||
When awareness is not configured:
|
||||
|
||||
- the same tools fall back to the platform memory endpoints
|
||||
|
||||
That design lets the platform improve the backend memory boundary without forcing every agent prompt or tool signature to change.
|
||||
See [Memory Architecture](../architecture/memory.md) for the full
|
||||
backend story.
|
||||
|
||||
## Coordinator Enforcement
|
||||
|
||||
|
||||
@@ -90,8 +90,6 @@ Poll `GET /workspaces/:id/delegations` to check results. Each entry includes `de
|
||||
|
||||
This is the recommended way for agents to delegate work — it works for all runtimes (Claude Code, LangGraph, etc.) since it operates at the platform level.
|
||||
|
||||
Workspace creation also assigns an `awareness_namespace` on the workspace row. That namespace is later injected into the provisioned runtime.
|
||||
|
||||
### Registry
|
||||
|
||||
| Method | Path | Description | Auth |
|
||||
|
||||
@@ -103,7 +103,7 @@ Migration files live in `workspace-server/migrations/` (latest: `022_workspace_s
|
||||
|
||||
| Table | Description |
|
||||
|-------|-------------|
|
||||
| `workspaces` | Core entity — status, runtime, `agent_card` JSONB, heartbeat columns, `current_task`, `awareness_namespace`, `workspace_dir` |
|
||||
| `workspaces` | Core entity — status, runtime, `agent_card` JSONB, heartbeat columns, `current_task`, `workspace_dir` |
|
||||
| `canvas_layouts` | Per-workspace x/y canvas position |
|
||||
| `structure_events` | Append-only event log (workspace lifecycle, agent, approval events) |
|
||||
| `activity_logs` | A2A communications, task updates, agent logs, errors. `error_detail` is populated by the scheduler so cron run history can surface failure reasons. |
|
||||
|
||||
+18
-10
@@ -47,18 +47,26 @@ It is useful for structured per-workspace state and optional TTL entries. It is
|
||||
|
||||
`GET /workspaces/:id/session-search` provides a thin recall surface over recent activity rows and memory rows. It is for “what just happened in this workspace?” rather than long-term semantic storage.
|
||||
|
||||
### 4. Awareness-backed persistence
|
||||
### 4. Memory v2 plugin (`memory_records` / `memory_namespaces`)
|
||||
|
||||
When the runtime receives:
|
||||
This is the production-direction backend, behind the RFC #2728 HTTP
|
||||
contract. The plugin runs as a sidecar on each tenant EC2 (auto-spawned
|
||||
by `entrypoint-tenant.sh` when `MEMORY_PLUGIN_URL` is set), owns its
|
||||
own tables under the `memory_plugin` schema, and serves:
|
||||
|
||||
```bash
|
||||
AWARENESS_URL=...
|
||||
AWARENESS_NAMESPACE=workspace:<id>
|
||||
```
|
||||
- `POST /workspaces/:id/v2/memories` (canvas `MemoryInspectorPanel`)
|
||||
- `GET /workspaces/:id/v2/memories`
|
||||
- `DELETE /workspaces/:id/v2/memories/:id`
|
||||
- runtime tools `commit_memory_v2`, `search_memory`, `commit_summary`,
|
||||
`forget_memory`
|
||||
- legacy MCP tool names `commit_memory` / `recall_memory` via the
|
||||
scope→namespace shim in `mcp_tools_memory_legacy_shim.go`
|
||||
|
||||
the same memory tools keep the same interface, but durable memory writes/reads are routed through the workspace's awareness namespace.
|
||||
|
||||
This is the current production direction of the memory boundary: stable tool surface, stronger backend isolation.
|
||||
Capability negotiation (FTS, embedding, TTL, pin, propagation) is
|
||||
declared by the plugin via `GET /v1/health`; workspace-server adapts
|
||||
the tool surface to what the plugin actually supports. See
|
||||
[`memory-plugin-v1.yaml`](../api-protocol/memory-plugin-v1.yaml) for
|
||||
the full wire contract.
|
||||
|
||||
## Access Model
|
||||
|
||||
@@ -121,7 +129,7 @@ If you need:
|
||||
- **org-wide guidance**: use `GLOBAL`
|
||||
- **simple UI-visible structured state**: use `workspace_memory`
|
||||
- **recent decision/task recall**: use `session-search`
|
||||
- **stronger durable isolation**: enable awareness namespaces
|
||||
- **semantic / FTS search across memories**: use the v2 plugin endpoints (`/v2/memories?q=…`); they go through the plugin's pgvector + tsvector indexes when the plugin declares the capability
|
||||
|
||||
## Related Docs
|
||||
|
||||
|
||||
@@ -426,10 +426,10 @@ submitted → working → completed
|
||||
|
||||
| Surface | Storage | Endpoint | Purpose |
|
||||
|---------|---------|----------|---------|
|
||||
| **Scoped agent memory** | `agent_memories` table | `POST /workspaces/:id/memories` | HMA-backed distributed memory with scope enforcement |
|
||||
| **Key/value workspace memory** | `workspace_memory` table | `POST /workspaces/:id/memory` | Simple structured state, UI-visible, optional TTL |
|
||||
| **Activity recall** | `activity_logs` + `agent_memories` | `GET /workspaces/:id/session-search` | "What just happened?" contextual recall |
|
||||
| **Awareness-backed** | External service | Same tool interface | When `AWARENESS_URL` + `AWARENESS_NAMESPACE` configured |
|
||||
| **Memory v2 plugin (SSOT)** | `memory_plugin.memory_records` table via RFC #2728 HTTP plugin | `POST /workspaces/:id/v2/memories`, MCP tools `commit_memory` / `commit_memory_v2` / `commit_summary` | Production memory backend — agent reads/writes route through here exclusively |
|
||||
| **Key/value workspace memory** | `workspace_memory` table | `POST /workspaces/:id/memory` | Simple structured state, UI-visible, optional TTL — separate from agent memory |
|
||||
| **Activity recall** | `activity_logs` + `agent_memories` (legacy read-only) | `GET /workspaces/:id/session-search` | "What just happened?" contextual recall |
|
||||
| **Legacy `agent_memories`** | `agent_memories` table | `POST /workspaces/:id/memories` (REST) | Frozen post-A1 — kept only for the REST canvas-side path; the workspace-create `seedInitialMemories` writer routes through the v2 plugin once #1755 (PR #1759) lands. Scheduled for drop in Phase A3 (#1733). |
|
||||
|
||||
### Memory → Skill Compounding Flywheel
|
||||
|
||||
@@ -511,7 +511,7 @@ description: ""
|
||||
version: "1.0.0"
|
||||
tier: 2 # 1=sandboxed, 2=standard, 3=privileged, 4=full-host
|
||||
model: "anthropic:claude-sonnet-4-6" # provider:model syntax
|
||||
runtime: "langgraph" # langgraph | deepagents | claude-code | crewai | autogen | openclaw
|
||||
runtime: "claude-code" # claude-code | codex | hermes | openclaw
|
||||
runtime_config: # Runtime-specific settings
|
||||
command: "claude" # For CLI runtimes
|
||||
args: []
|
||||
@@ -565,15 +565,13 @@ compliance:
|
||||
max_task_duration_seconds: 300
|
||||
```
|
||||
|
||||
### Six Runtime Adapters
|
||||
### Four Runtime Adapters
|
||||
|
||||
| Adapter | Core Strength | Image Tag |
|
||||
|---------|--------------|-----------|
|
||||
| **LangGraph** | Graph-based state machine, tool use, streaming | `workspace-template:langgraph` |
|
||||
| **DeepAgents** | Deep planning, multi-step task decomposition | `workspace-template:deepagents` |
|
||||
| **Claude Code** | Native coding workflows, CLI continuity, OAuth auth | `workspace-template:claude-code` |
|
||||
| **CrewAI** | Role-based crews, structured task orchestration | `workspace-template:crewai` |
|
||||
| **AutoGen** | Multi-agent conversations, explicit strategies | `workspace-template:autogen` |
|
||||
| **Codex** | OpenAI Codex coding workflows | `workspace-template:codex` |
|
||||
| **Hermes** | Hermes agent runtime | `workspace-template:hermes` |
|
||||
| **OpenClaw** | CLI-native runtime, own session model | `workspace-template:openclaw` |
|
||||
|
||||
**Branch-level WIP**: NemoClaw (NVIDIA T4 + Docker socket) on `feat/nemoclaw-t4-docker`.
|
||||
@@ -740,7 +738,6 @@ requires:
|
||||
| `hitl.py` | Multi-channel HITL (dashboard, Slack, email) | hitl.bypass_roles |
|
||||
| `sandbox.py` | Code execution (subprocess or Docker backend) | sandbox access |
|
||||
| `telemetry.py` | OpenTelemetry span creation and tracing | trace emission |
|
||||
| `awareness_client.py` | Awareness namespace memory wrapper | memory scope |
|
||||
| `security_scan.py` | CVE and security scanning (pip-audit/Snyk) | security audit |
|
||||
| `temporal_workflow.py` | Temporal.io workflow integration | workflow engine |
|
||||
| `a2a_tools.py` | A2A delegation helpers and route resolution | delegate/receive |
|
||||
@@ -749,8 +746,7 @@ requires:
|
||||
|
||||
| Server | Purpose |
|
||||
|--------|---------|
|
||||
| `molecule` | 20+ platform management tools (workspace CRUD, chat, memory, teams, secrets, files, approvals) |
|
||||
| `awareness-memory` | Persistent cross-session memory via Awareness SDK |
|
||||
| `molecule` | 20+ platform management tools (workspace CRUD, chat, memory, teams, secrets, files, approvals) — includes `commit_memory` / `commit_memory_v2` / `search_memory` routed through the v2 plugin |
|
||||
|
||||
---
|
||||
|
||||
@@ -909,7 +905,7 @@ Postgres + Redis + Langfuse only (for local development without containerized wo
|
||||
| `CORS_ORIGINS` | `http://localhost:3000,...` | CORS whitelist |
|
||||
| `RATE_LIMIT` | `600` | Requests per minute |
|
||||
| `WORKSPACE_DIR` | Optional | Shared workspace volume |
|
||||
| `AWARENESS_URL` | Optional | Awareness service URL |
|
||||
| `MEMORY_PLUGIN_URL` | Unset by default | v2 memory plugin sidecar address. Typically set externally — CP user-data injects `http://localhost:9100` on tenant EC2 boot, which `entrypoint-tenant.sh` reads as the signal to spawn the bundled `memory-plugin` sidecar on the matching loopback port. When unset, today (pre-#1747) the legacy `agent_memories` SQL path is used as silent fallback; after #1747 (RFC #1733 Phase A1) lands, memory MCP tools return a "plugin not configured" error instead. |
|
||||
|
||||
### Canvas (Next.js)
|
||||
|
||||
@@ -927,8 +923,6 @@ Postgres + Redis + Langfuse only (for local development without containerized wo
|
||||
| `WORKSPACE_CONFIG_PATH` | `/configs` | Config directory mount |
|
||||
| `PLATFORM_URL` | `http://platform:8080` | Platform connection |
|
||||
| `PARENT_ID` | Empty | Parent workspace ID (set if nested) |
|
||||
| `AWARENESS_URL` | Optional | Awareness service |
|
||||
| `AWARENESS_NAMESPACE` | Optional | Scoped namespace for awareness memory |
|
||||
| `LANGFUSE_HOST` | `http://langfuse-web:3000` | Langfuse endpoint |
|
||||
| `LANGFUSE_PUBLIC_KEY` | Optional | Langfuse auth |
|
||||
| `LANGFUSE_SECRET_KEY` | Optional | Langfuse auth |
|
||||
@@ -1091,20 +1085,6 @@ Every Tier 1 launch (Open Interpreter, CrewAI) had all four elements.
|
||||
}
|
||||
```
|
||||
|
||||
### Awareness MCP Server
|
||||
|
||||
For persistent cross-session memory:
|
||||
|
||||
```json
|
||||
{
|
||||
"awareness-memory": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@awareness-sdk/local", "mcp"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 29. Summary Statistics
|
||||
|
||||
@@ -44,7 +44,7 @@ All three call `onWorkspaceOffline`, which broadcasts `WORKSPACE_OFFLINE` and ca
|
||||
|
||||
### Template Resolution (Workspace Create)
|
||||
|
||||
Runtime detection happens **before** the DB insert: if `payload.Runtime` is empty and a template is specified, the handler reads `runtime:` from `configsDir/template/config.yaml` first. If still empty, it defaults to `"langgraph"`. This ensures the correct runtime (e.g. `claude-code`) is persisted in the DB and used for container image selection.
|
||||
Runtime detection happens **before** the DB insert: if `payload.Runtime` is empty and a template is specified, the handler reads `runtime:` from `configsDir/template/config.yaml` first. If still empty, it defaults to `"claude-code"`. This ensures the correct runtime is persisted in the DB and used for container image selection.
|
||||
|
||||
When the requested template does not exist, the Create handler falls back in order:
|
||||
|
||||
|
||||
+5
-5
@@ -24,10 +24,10 @@ features:
|
||||
details: Build agent organizations as nested workspaces on a live React Flow canvas with drag-to-nest hierarchy, template deployment, bundles, and real-time updates.
|
||||
icon: "🗺️"
|
||||
- title: Runtime Compatibility
|
||||
details: Current main ships adapters for LangGraph, DeepAgents, Claude Code, CrewAI, AutoGen, and OpenClaw under one workspace contract and A2A surface.
|
||||
details: Current main ships adapters for Claude Code, Codex, Hermes, and OpenClaw under one workspace contract and A2A surface.
|
||||
icon: "⚙️"
|
||||
- title: Hierarchical Memory
|
||||
details: HMA-style LOCAL, TEAM, and GLOBAL scopes plus workspace-scoped awareness namespaces when awareness is configured.
|
||||
details: HMA-style LOCAL, TEAM, and GLOBAL scopes backed by the v2 memory plugin (per-tenant pgvector sidecar with FTS + semantic recall).
|
||||
icon: "🧠"
|
||||
- title: Skill Evolution
|
||||
details: Local SKILL.md packages, tool loading, plugin-mounted shared capabilities, hot reload, and a documented memory-to-skill promotion path.
|
||||
@@ -49,13 +49,13 @@ features:
|
||||
|---|---|
|
||||
| **Canvas** | Empty-state deployment, onboarding guide, 10-tab side panel, template palette, bundle import/export, drag-to-nest teams, search, activity and trace views |
|
||||
| **Platform** | Workspace CRUD, registry, A2A proxy, team expansion, approvals, secrets, global secrets, memory APIs, files API, terminal, viewport persistence, WebSocket fanout |
|
||||
| **Runtime** | One workspace image with six shipping adapters on `main`: LangGraph, DeepAgents, Claude Code, CrewAI, AutoGen, OpenClaw |
|
||||
| **Memory** | Scoped agent memories, key/value workspace memory, session-search recall, awareness namespace injection |
|
||||
| **Runtime** | One workspace image with four shipping adapters on `main`: Claude Code, Codex, Hermes, OpenClaw |
|
||||
| **Memory** | v2 plugin (pgvector + FTS) serving scoped agent memories under per-workspace namespaces; key/value workspace memory; session-search recall |
|
||||
| **Skills** | Local skill packages, plugin-mounted shared skills/rules, audit/install/publish CLI helpers, hot reload |
|
||||
|
||||
## Compatibility Note
|
||||
|
||||
`main` currently ships six runtime adapters. `NemoClaw` appears in branch-level work (`feat/nemoclaw-t4-docker`) and is not documented here as merged `main` functionality.
|
||||
`main` currently ships four runtime adapters: Claude Code, Codex, Hermes, and OpenClaw. `NemoClaw` appears in branch-level work (`feat/nemoclaw-t4-docker`) and is not documented here as merged `main` functionality.
|
||||
|
||||
## Recommended Reading
|
||||
|
||||
|
||||
+2
-2
@@ -238,7 +238,7 @@ No inbound firewall rules needed — the agent initiates the outbound WebSocket
|
||||
## What To Try Next
|
||||
|
||||
- **Expand to a team:** right-click a workspace and choose `Expand to Team`.
|
||||
- **Switch runtime:** use `Config -> Runtime` to move between LangGraph, DeepAgents, Claude Code, CrewAI, AutoGen, and OpenClaw.
|
||||
- **Switch runtime:** use `Config -> Runtime` to move between Claude Code, Codex, Hermes, and OpenClaw.
|
||||
- **Inspect operations:** check `Activity`, `Traces`, `Events`, and `Terminal`.
|
||||
- **Use global keys:** configure one provider once in `Secrets & API Keys -> Global`.
|
||||
- **Import a template:** use the template palette or `POST /templates/import`.
|
||||
@@ -268,7 +268,7 @@ Browser --> Canvas (Next.js :3000)
|
||||
|
|
||||
v
|
||||
Provisioned workspaces
|
||||
(LangGraph / Claude Code / CrewAI / AutoGen / etc.)
|
||||
(Claude Code / Codex / Hermes / OpenClaw)
|
||||
```
|
||||
|
||||
For the full system model, see [Architecture](./architecture/architecture.md).
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Runbook: stale CI umbrella with green sub-jobs — compensating status
|
||||
|
||||
**When to use this:** A PR's `CI / all-required (pull_request)` status is `failure` (so branch protection blocks the merge), but all 5 required sub-jobs (`Detect changes`, `Platform (Go)`, `Canvas (Next.js)`, `Shellcheck (E2E scripts)`, `Python Lint & Test`) actually succeeded. The umbrella job's internal 40-min poll deadline elapsed before the success statuses propagated through Gitea's commit-status pipeline.
|
||||
|
||||
**When NOT to use this:** Any required sub-job actually failed. The umbrella correctly reflects reality; a compensating-status post would lie.
|
||||
|
||||
This pattern parallels what `.gitea/workflows/status-reaper.yml` does for default-branch `(push)` status drift, but applied to PR umbrellas instead of main-branch contexts.
|
||||
|
||||
## Diagnose
|
||||
|
||||
1. Look up the umbrella status:
|
||||
|
||||
```bash
|
||||
TOK=$(cat ~/.molecule-ai/gitea-token)
|
||||
API=https://git.moleculesai.app/api/v1/repos/molecule-ai/molecule-core
|
||||
PR=<pr-number>
|
||||
sha=$(curl -sS -H "Authorization: token $TOK" "$API/pulls/$PR" | python3 -c "import sys,json; print(json.load(sys.stdin)['head']['sha'])")
|
||||
curl -sS -H "Authorization: token $TOK" "$API/commits/$sha/status" \
|
||||
| python3 -c "import sys,json; [print(s['status'], s['context']) for s in json.load(sys.stdin)['statuses'] if 'all-required' in s['context']]"
|
||||
```
|
||||
|
||||
2. Look up the actual sub-job statuses in the Gitea DB:
|
||||
|
||||
```bash
|
||||
ssh root@5.78.80.188 "docker exec molecule-postgres-1 psql -U gitea -d gitea -tAc \"
|
||||
SELECT aj.name,
|
||||
CASE aj.status WHEN 1 THEN 'success' WHEN 2 THEN 'failure' WHEN 3 THEN 'cancelled' WHEN 4 THEN 'skipped' WHEN 5 THEN 'waiting' WHEN 6 THEN 'running' END
|
||||
FROM action_run ar JOIN action_run_job aj ON aj.run_id=ar.id
|
||||
WHERE ar.repo_id=17 AND ar.workflow_id='ci.yml' AND ar.commit_sha='$sha'
|
||||
ORDER BY aj.id;\""
|
||||
```
|
||||
|
||||
The 5 required-by-umbrella sub-jobs must all be `success`. (`Canvas Deploy Reminder` is intentionally not required — its state doesn't matter.)
|
||||
|
||||
## Recover
|
||||
|
||||
If diagnosis confirms all 5 required sub-jobs are success but the umbrella is stuck at failure:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST -H "Authorization: token $TOK" -H "Content-Type: application/json" \
|
||||
"$API/statuses/$sha" -d '{
|
||||
"context": "CI / all-required (pull_request)",
|
||||
"state": "success",
|
||||
"description": "Compensating status: all 5 required sub-jobs verified success in action_run_job; umbrella stale due to commit-status propagation race. Posted by <operator> per ci-umbrella-stale-compensating-status runbook."
|
||||
}'
|
||||
```
|
||||
|
||||
The status posts immediately; the merge gate flips green within ~5 seconds.
|
||||
|
||||
**Always include WHO and WHY in the `description` field** so the audit trail is honest. Future operators (and `audit-force-merge.yml` consumers) need to be able to tell a recovery from a real bypass.
|
||||
|
||||
## Why this happens
|
||||
|
||||
- The umbrella's 40-min internal poll loop (`.gitea/workflows/ci.yml` → `all-required` job → `Wait for required CI contexts` step) treats `missing` statuses as pending.
|
||||
- Status propagation: a job completing on a runner posts its `action_run_job.status=1` row first, then Gitea's notifier walks `action_run_job` → `commit_status` table. Under high write load (many concurrent PRs synchronizing) the notifier walk can lag by several minutes.
|
||||
- If propagation lag pushes the last sub-job's commit-status past the umbrella's 40-min wall-clock deadline, the umbrella fails even though the sub-jobs were green well within the window.
|
||||
- The umbrella correctly does not retry once it has emitted a terminal status (per RFC internal#219 design — retries would mask real failures).
|
||||
|
||||
## Prevent
|
||||
|
||||
Most cases are downstream of the runner-pool dispatch deadlock fixed by commit `7da843f2` (issue #1779). With the umbrella running on the dedicated `ci-meta` pool, sub-jobs are no longer competing for runners with their own umbrella, so propagation completes well before the 40-min deadline in normal load.
|
||||
|
||||
If you find yourself reaching for this runbook frequently, that's the signal to either:
|
||||
- Raise `timeout-minutes` on the umbrella above 45.
|
||||
- Build the `umbrella-reaper.yml` auto-recovery described in issue #1780 (this runbook is its precursor).
|
||||
|
||||
## Cross-refs
|
||||
|
||||
- Issue #1780 — original write-up; tracks auto-recovery
|
||||
- Issue #1779 — runner-pool deadlock; root cause of the propagation lag in most cases
|
||||
- `.gitea/workflows/status-reaper.yml` — sibling pattern for default-branch `(push)` status drift
|
||||
- `.gitea/workflows/audit-force-merge.yml` — audits bypass merges; this runbook's `description` field is what makes a compensating-status merge auditable vs. opaque
|
||||
|
||||
## Session-local examples
|
||||
|
||||
The pattern was used twice during the 2026-05-24 CTO-bypass session:
|
||||
|
||||
- **PR #1737** merged via compensating-status — all 5 sub-jobs green, umbrella timed out on propagation race. Merge commit `d5941906`.
|
||||
- **PR #1759** merged via compensating-status — 4/5 sub-jobs green, the 5th (`Platform (Go)`) was an inherited-from-main failure (templates_test fixtures bug, tracked as #1778, fixed in #1781). The compensating-status description called out the inherited failure honestly. Merge commit `220a04b1`.
|
||||
+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"},
|
||||
|
||||
@@ -8,7 +8,7 @@ cd "$(dirname "$0")/../workspace-template"
|
||||
echo "=== Building base image ==="
|
||||
docker build -t workspace-template:base -t workspace-template:latest .
|
||||
|
||||
for adapter in langgraph claude_code openclaw deepagents crewai autogen; do
|
||||
for adapter in claude_code codex hermes openclaw; do
|
||||
DOCKERFILE="adapters/${adapter}/Dockerfile"
|
||||
if [ ! -f "$DOCKERFILE" ]; then
|
||||
echo "Skipping $adapter (no Dockerfile)"
|
||||
|
||||
@@ -32,7 +32,7 @@ log() { echo -e "${GREEN}[refresh]${NC} $1" >&2; }
|
||||
warn() { echo -e "${YELLOW}[refresh]${NC} $1" >&2; }
|
||||
err() { echo -e "${RED}[refresh]${NC} $1" >&2; }
|
||||
|
||||
ALL_RUNTIMES=(claude-code langgraph crewai autogen deepagents hermes gemini-cli openclaw)
|
||||
ALL_RUNTIMES=(claude-code codex hermes openclaw)
|
||||
RUNTIMES=("${ALL_RUNTIMES[@]}")
|
||||
RECREATE=true
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# E2E test: All 6 adapters — create one agent per runtime, test A2A between all
|
||||
# E2E test: all maintained adapters — create one agent per runtime, test A2A
|
||||
set -euo pipefail
|
||||
|
||||
PLATFORM="${1:-http://localhost:8080}"
|
||||
@@ -52,12 +52,12 @@ a2a_send() {
|
||||
}
|
||||
|
||||
echo "============================================"
|
||||
echo " All-Adapters E2E Test (6 runtimes)"
|
||||
echo " All-Adapters E2E Test (4 runtimes)"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
# --- Create workspaces ---
|
||||
echo "--- Step 1: Create 6 workspaces ---"
|
||||
echo "--- Step 1: Create 4 workspaces ---"
|
||||
|
||||
R=$(curl -s -X POST "$PLATFORM/workspaces" -H 'Content-Type: application/json' \
|
||||
-d '{"name":"Alice-Claude","role":"claude-code test","tier":2,"template":"claude-code-default"}')
|
||||
@@ -65,9 +65,9 @@ ALICE=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'
|
||||
check "Create Alice (claude-code)" "provisioning" "$R"
|
||||
|
||||
R=$(curl -s -X POST "$PLATFORM/workspaces" -H 'Content-Type: application/json' \
|
||||
-d '{"name":"Bob-LangGraph","role":"langgraph test","tier":2,"template":"langgraph"}')
|
||||
-d '{"name":"Bob-Codex","role":"codex test","tier":2,"template":"codex"}')
|
||||
BOB=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
check "Create Bob (langgraph)" "provisioning" "$R"
|
||||
check "Create Bob (codex)" "provisioning" "$R"
|
||||
|
||||
R=$(curl -s -X POST "$PLATFORM/workspaces" -H 'Content-Type: application/json' \
|
||||
-d '{"name":"Carol-OpenClaw","role":"openclaw test","tier":2,"template":"openclaw"}')
|
||||
@@ -75,29 +75,19 @@ CAROL=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'
|
||||
check "Create Carol (openclaw)" "provisioning" "$R"
|
||||
|
||||
R=$(curl -s -X POST "$PLATFORM/workspaces" -H 'Content-Type: application/json' \
|
||||
-d '{"name":"Dave-DeepAgents","role":"deepagents test","tier":2,"template":"deepagents"}')
|
||||
-d '{"name":"Dave-Hermes","role":"hermes test","tier":2,"template":"hermes"}')
|
||||
DAVE=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
check "Create Dave (deepagents)" "provisioning" "$R"
|
||||
|
||||
R=$(curl -s -X POST "$PLATFORM/workspaces" -H 'Content-Type: application/json' \
|
||||
-d '{"name":"Eve-CrewAI","role":"crewai test","tier":2,"template":"crewai"}')
|
||||
EVE=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
check "Create Eve (crewai)" "provisioning" "$R"
|
||||
|
||||
R=$(curl -s -X POST "$PLATFORM/workspaces" -H 'Content-Type: application/json' \
|
||||
-d '{"name":"Frank-AutoGen","role":"autogen test","tier":2,"template":"autogen"}')
|
||||
FRANK=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
check "Create Frank (autogen)" "provisioning" "$R"
|
||||
check "Create Dave (hermes)" "provisioning" "$R"
|
||||
|
||||
# --- Set API keys (skip Claude which uses OAuth) ---
|
||||
echo ""
|
||||
echo "--- Step 2: Set API keys ---"
|
||||
for ID in $BOB $CAROL $DAVE $EVE $FRANK; do
|
||||
for ID in $BOB $CAROL $DAVE; do
|
||||
curl -s -X POST "$PLATFORM/workspaces/$ID/secrets" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"key\":\"OPENAI_API_KEY\",\"value\":\"$OPENAI_KEY\"}" > /dev/null
|
||||
done
|
||||
echo "Set OPENAI_API_KEY on 5 agents"
|
||||
echo "Set OPENAI_API_KEY on 3 agents"
|
||||
|
||||
# Auto-restart happens automatically when secrets are set
|
||||
echo "Secrets trigger auto-restart — waiting for agents to come back..."
|
||||
@@ -105,13 +95,11 @@ sleep 15
|
||||
|
||||
# --- Wait for all online ---
|
||||
echo ""
|
||||
echo "--- Step 3: Wait for agents (OpenClaw ~3min, CrewAI/AutoGen/DeepAgents ~2min) ---"
|
||||
echo "--- Step 3: Wait for agents (OpenClaw ~3min, Hermes may take longer) ---"
|
||||
|
||||
wait_online "$ALICE" "Alice-Claude" 20 && check "Alice online" "ok" "ok" || check "Alice online" "online" "timeout"
|
||||
wait_online "$BOB" "Bob-LangGraph" 60 && check "Bob online" "ok" "ok" || check "Bob online" "online" "timeout"
|
||||
wait_online "$DAVE" "Dave-DeepAgents" 120 && check "Dave online" "ok" "ok" || check "Dave online" "online" "timeout"
|
||||
wait_online "$EVE" "Eve-CrewAI" 120 && check "Eve online" "ok" "ok" || check "Eve online" "online" "timeout"
|
||||
wait_online "$FRANK" "Frank-AutoGen" 120 && check "Frank online" "ok" "ok" || check "Frank online" "online" "timeout"
|
||||
wait_online "$BOB" "Bob-Codex" 60 && check "Bob online" "ok" "ok" || check "Bob online" "online" "timeout"
|
||||
wait_online "$DAVE" "Dave-Hermes" 180 && check "Dave online" "ok" "ok" || check "Dave online" "online" "timeout"
|
||||
wait_online "$CAROL" "Carol-OpenClaw" 360 && check "Carol online" "ok" "ok" || check "Carol online" "online" "timeout"
|
||||
|
||||
# --- Test A2A messages ---
|
||||
@@ -123,7 +111,7 @@ RESP=$(a2a_send "$ALICE" "say hello in one word")
|
||||
echo " -> $RESP"
|
||||
check "Alice responds" "hello" "$RESP"
|
||||
|
||||
echo " Talking to Bob (LangGraph)..."
|
||||
echo " Talking to Bob (Codex)..."
|
||||
RESP=$(a2a_send "$BOB" "say hello in one word")
|
||||
echo " -> $RESP"
|
||||
check "Bob responds" "hello" "$RESP"
|
||||
@@ -133,21 +121,11 @@ RESP=$(a2a_send "$CAROL" "say hello in one word")
|
||||
echo " -> $RESP"
|
||||
check "Carol responds" "hello" "$RESP"
|
||||
|
||||
echo " Talking to Dave (DeepAgents)..."
|
||||
echo " Talking to Dave (Hermes)..."
|
||||
RESP=$(a2a_send "$DAVE" "say hello in one word")
|
||||
echo " -> $RESP"
|
||||
check "Dave responds" "hello" "$RESP"
|
||||
|
||||
echo " Talking to Eve (CrewAI)..."
|
||||
RESP=$(a2a_send "$EVE" "say hello in one word")
|
||||
echo " -> $RESP"
|
||||
check "Eve responds" "hello" "$RESP"
|
||||
|
||||
echo " Talking to Frank (AutoGen)..."
|
||||
RESP=$(a2a_send "$FRANK" "say hello in one word")
|
||||
echo " -> $RESP"
|
||||
check "Frank responds" "hello" "$RESP"
|
||||
|
||||
# --- Peer discovery ---
|
||||
echo ""
|
||||
echo "--- Step 5: Peer discovery ---"
|
||||
@@ -157,7 +135,7 @@ peers = json.load(sys.stdin)
|
||||
print(f'{len(peers)} peers: {\" \".join(p.get(\"name\",\"\") for p in peers)}')
|
||||
" 2>/dev/null)
|
||||
echo " Alice sees: $R"
|
||||
check "Alice sees 5 peers" "5 peers" "$R"
|
||||
check "Alice sees 3 peers" "3 peers" "$R"
|
||||
|
||||
# --- Isolation ---
|
||||
echo ""
|
||||
@@ -168,7 +146,7 @@ check "No ws-* dirs on host" "0" "$HOST_WS"
|
||||
# --- Cleanup ---
|
||||
echo ""
|
||||
echo "--- Step 7: Cleanup ---"
|
||||
for ID in $ALICE $BOB $CAROL $DAVE $EVE $FRANK; do
|
||||
for ID in $ALICE $BOB $CAROL $DAVE; do
|
||||
curl -s -X DELETE "$PLATFORM/workspaces/$ID" > /dev/null 2>&1
|
||||
done
|
||||
check "Cleanup" "ok" "ok"
|
||||
|
||||
@@ -10,15 +10,6 @@
|
||||
# "gpt-4o" falls through to Anthropic
|
||||
# default + 401, see PR #1714.)
|
||||
#
|
||||
# langgraph → "openai:gpt-4o" (colon-form: langchain init_chat_model
|
||||
# requires "<provider>:<model>".
|
||||
# Slash-form was misinterpreted as
|
||||
# OpenRouter routing → fell through
|
||||
# without auth, surfaced 2026-05-03
|
||||
# after the a2a-sdk v1 contract bugs
|
||||
# PR #2558+#2563+#2567 cleared the
|
||||
# masking layers.)
|
||||
#
|
||||
# claude-code → auth-aware:
|
||||
# E2E_MINIMAX_API_KEY → "MiniMax-M2"
|
||||
# E2E_ANTHROPIC_API_KEY → "claude-sonnet-4-6"
|
||||
@@ -51,7 +42,6 @@ pick_model_slug() {
|
||||
fi
|
||||
case "$runtime" in
|
||||
hermes) printf 'openai/gpt-4o' ;;
|
||||
langgraph) printf 'openai:gpt-4o' ;;
|
||||
claude-code)
|
||||
if [ -n "${E2E_MINIMAX_API_KEY:-}" ]; then
|
||||
printf 'MiniMax-M2'
|
||||
|
||||
@@ -2,10 +2,16 @@
|
||||
# lint_cleanup_traps.sh — regression gate for the OSS-shape program's
|
||||
# "all E2E tests must have proper cleanup" bar (RFC #2873).
|
||||
#
|
||||
# Asserts: every shell file under tests/e2e/ that calls `mktemp` ALSO
|
||||
# installs an `EXIT` trap somewhere in the file. The trap is the
|
||||
# minimum-viable guarantee that scratch files won't leak when an
|
||||
# assertion or curl exits the script non-zero.
|
||||
# Asserts:
|
||||
# 1. every shell file under tests/e2e/ that calls `mktemp` ALSO
|
||||
# installs an `EXIT` trap somewhere in the file.
|
||||
# 2. every staging tenant E2E script that provisions a real org uses a
|
||||
# slug prefix caught by sweep-stale-e2e-orgs.yml and installs an
|
||||
# EXIT trap.
|
||||
#
|
||||
# These are the minimum-viable guarantees that scratch files and real
|
||||
# staging EC2 tenants converge back to zero when an assertion or curl
|
||||
# exits the script non-zero.
|
||||
#
|
||||
# Why this lints (instead of the test runner enforcing): shell scripts
|
||||
# can't easily be wrapped by an outer harness without breaking the
|
||||
@@ -21,6 +27,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
repo_root="$(cd ../.. && pwd)"
|
||||
|
||||
violations=0
|
||||
for f in test_*.sh; do
|
||||
@@ -32,6 +39,76 @@ for f in test_*.sh; do
|
||||
fi
|
||||
done
|
||||
|
||||
if ! python3 - "$repo_root" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
repo = Path(sys.argv[1])
|
||||
e2e_dir = repo / "tests" / "e2e"
|
||||
sweeper = repo / ".gitea" / "workflows" / "sweep-stale-e2e-orgs.yml"
|
||||
|
||||
errors: list[str] = []
|
||||
sweeper_text = sweeper.read_text()
|
||||
|
||||
required_sweeper_prefixes = ('"e2e-"', '"rt-e2e-"')
|
||||
for prefix in required_sweeper_prefixes:
|
||||
if prefix not in sweeper_text:
|
||||
errors.append(
|
||||
f"::error file=.gitea/workflows/sweep-stale-e2e-orgs.yml::"
|
||||
f"missing stale-org sweeper prefix {prefix}"
|
||||
)
|
||||
|
||||
slug_assignment_re = re.compile(r'^\s*SLUG=(["\'])(?P<value>.+?)\1', re.MULTILINE)
|
||||
covered_prefixes = ("e2e-", "rt-e2e-")
|
||||
|
||||
for path in sorted(e2e_dir.glob("test_*staging*.sh")):
|
||||
text = path.read_text()
|
||||
creates_org = "/cp/admin/orgs" in text and re.search(r"\bPOST\b", text)
|
||||
deletes_org = "/cp/admin/tenants" in text and re.search(r"\bDELETE\b", text)
|
||||
if not (creates_org or deletes_org):
|
||||
continue
|
||||
|
||||
rel = path.relative_to(repo)
|
||||
if not re.search(r"trap\s+.*\bEXIT\b", text):
|
||||
errors.append(
|
||||
f"::error file={rel}::staging tenant E2E touches CP org lifecycle "
|
||||
"but has no EXIT trap for teardown"
|
||||
)
|
||||
|
||||
assignments = [m.group("value") for m in slug_assignment_re.finditer(text)]
|
||||
if not assignments:
|
||||
errors.append(
|
||||
f"::error file={rel}::staging tenant E2E touches CP org lifecycle "
|
||||
"but has no quoted SLUG=... assignment for scoped cleanup"
|
||||
)
|
||||
continue
|
||||
|
||||
for value in assignments:
|
||||
literal_prefix = re.split(r"[$`]", value, maxsplit=1)[0]
|
||||
if not literal_prefix:
|
||||
errors.append(
|
||||
f"::error file={rel}::SLUG assignment starts with dynamic data "
|
||||
f"({value!r}); use a fixed e2e-* or rt-e2e-* prefix so "
|
||||
"sweep-stale-e2e-orgs can reap orphans"
|
||||
)
|
||||
continue
|
||||
if not literal_prefix.startswith(covered_prefixes):
|
||||
errors.append(
|
||||
f"::error file={rel}::SLUG prefix {literal_prefix!r} is not "
|
||||
"covered by sweep-stale-e2e-orgs.yml; use e2e-* or rt-e2e-*"
|
||||
)
|
||||
|
||||
if errors:
|
||||
print("\n".join(errors))
|
||||
raise SystemExit(1)
|
||||
|
||||
print("✓ staging tenant E2E slug prefixes are covered by sweep-stale-e2e-orgs and use EXIT traps")
|
||||
PY
|
||||
then
|
||||
violations=$((violations + 1))
|
||||
fi
|
||||
|
||||
if [ "$violations" -gt 0 ]; then
|
||||
echo "::error::$violations shell E2E file(s) leak scratch on early exit. See above."
|
||||
exit 1
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
# Required env:
|
||||
# BASE default http://localhost:8080
|
||||
# override to https://<id>.<tenant>.staging...
|
||||
# WORKSPACE_RUNTIME default langgraph (any internal runtime)
|
||||
# WORKSPACE_RUNTIME default claude-code (any maintained internal runtime)
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 upload + read-back round-trip succeeded
|
||||
@@ -26,7 +26,7 @@
|
||||
set -uo pipefail
|
||||
|
||||
BASE="${BASE:-http://localhost:8080}"
|
||||
RUNTIME="${WORKSPACE_RUNTIME:-langgraph}"
|
||||
RUNTIME="${WORKSPACE_RUNTIME:-claude-code}"
|
||||
|
||||
PARENT=""
|
||||
PARENT_TOK=""
|
||||
@@ -49,7 +49,7 @@ trap cleanup EXIT INT TERM
|
||||
echo "[1/5] POST /workspaces (runtime=$RUNTIME)..."
|
||||
P_RESP=$(curl -sS -X POST "$BASE/workspaces" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"e2e-chat-upload\",\"runtime\":\"$RUNTIME\",\"tier\":2}")
|
||||
-d "{\"name\":\"e2e-chat-upload\",\"runtime\":\"$RUNTIME\",\"tier\":2,\"model\":\"sonnet\"}")
|
||||
PARENT=$(echo "$P_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
|
||||
[ -n "$PARENT" ] || { echo " ✗ workspace create failed: $P_RESP"; exit 1; }
|
||||
echo " ✓ workspace=$PARENT"
|
||||
|
||||
@@ -137,14 +137,14 @@ check "Create claude-code workspace" '"status":"provisioning"' "$R"
|
||||
RT_CC_ID=$(echo "$R" | jq_extract "['id']")
|
||||
|
||||
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
||||
-d '{"name":"RT LangGraph","role":"Test","tier":2,"runtime":"langgraph"}')
|
||||
check "Create langgraph workspace" '"status":"provisioning"' "$R"
|
||||
RT_LG_ID=$(echo "$R" | jq_extract "['id']")
|
||||
-d '{"name":"RT Codex","role":"Test","tier":2,"runtime":"codex"}')
|
||||
check "Create codex workspace" '"status":"provisioning"' "$R"
|
||||
RT_CX_ID=$(echo "$R" | jq_extract "['id']")
|
||||
|
||||
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
||||
-d '{"name":"RT CrewAI","role":"Test","tier":2,"runtime":"crewai"}')
|
||||
check "Create crewai workspace" '"status":"provisioning"' "$R"
|
||||
RT_CR_ID=$(echo "$R" | jq_extract "['id']")
|
||||
-d '{"name":"RT Hermes","role":"Test","tier":2,"runtime":"hermes"}')
|
||||
check "Create hermes workspace" '"status":"provisioning"' "$R"
|
||||
RT_HM_ID=$(echo "$R" | jq_extract "['id']")
|
||||
|
||||
# Wait for containers to start (poll up to 30s for first one to appear)
|
||||
if command -v docker &>/dev/null; then
|
||||
@@ -174,8 +174,8 @@ if command -v docker &>/dev/null; then
|
||||
}
|
||||
|
||||
_check_image "$RT_CC_ID" "claude-code" "claude-code uses claude-code image"
|
||||
_check_image "$RT_LG_ID" "langgraph" "langgraph uses langgraph image"
|
||||
_check_image "$RT_CR_ID" "crewai" "crewai uses crewai image"
|
||||
_check_image "$RT_CX_ID" "codex" "codex uses codex image"
|
||||
_check_image "$RT_HM_ID" "hermes" "hermes uses hermes image"
|
||||
else
|
||||
echo " SKIP: Docker not available — cannot verify container images"
|
||||
SKIP=$((SKIP + 3))
|
||||
@@ -183,7 +183,7 @@ fi
|
||||
|
||||
# Verify runtime in agent card after registration
|
||||
sleep 5
|
||||
for rt_id in $RT_CC_ID $RT_LG_ID $RT_CR_ID; do
|
||||
for rt_id in $RT_CC_ID $RT_CX_ID $RT_HM_ID; do
|
||||
# Register so we can check agent card
|
||||
curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \
|
||||
-d "{\"id\":\"$rt_id\",\"url\":\"http://localhost:19999\",\"agent_card\":{\"name\":\"Test\",\"skills\":[]}}" > /dev/null 2>&1
|
||||
@@ -204,20 +204,20 @@ fi
|
||||
|
||||
# Verify runtime change persists on restart (if provisioner supports ExecRead)
|
||||
# Write a new runtime to config, restart, check image changes
|
||||
R=$(curl -s -X PUT "$BASE/workspaces/$RT_LG_ID/files/config.yaml" \
|
||||
R=$(curl -s -X PUT "$BASE/workspaces/$RT_CX_ID/files/config.yaml" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"content":"name: RT LangGraph\nruntime: deepagents\nmodel: openai:gpt-4.1-mini\ntier: 2\n"}')
|
||||
-d '{"content":"name: RT Codex\nruntime: openclaw\nmodel: openai:gpt-4.1-mini\ntier: 2\n"}')
|
||||
if echo "$R" | grep -qF "saved"; then
|
||||
curl -s -X POST "$BASE/workspaces/$RT_LG_ID/restart" > /dev/null 2>&1
|
||||
curl -s -X POST "$BASE/workspaces/$RT_CX_ID/restart" > /dev/null 2>&1
|
||||
# Poll up to 30s for the new container image to appear (restart can take a while)
|
||||
if command -v docker &>/dev/null; then
|
||||
short_id="${RT_LG_ID:0:12}"
|
||||
short_id="${RT_CX_ID:0:12}"
|
||||
for _ in 1 2 3 4 5 6; do
|
||||
sleep 5
|
||||
actual=$(docker inspect "ws-${short_id}" --format '{{.Config.Image}}' 2>/dev/null || echo "")
|
||||
if echo "$actual" | grep -qF "deepagents"; then break; fi
|
||||
if echo "$actual" | grep -qF "openclaw"; then break; fi
|
||||
done
|
||||
_check_image "$RT_LG_ID" "deepagents" "Runtime change langgraph→deepagents on restart"
|
||||
_check_image "$RT_CX_ID" "openclaw" "Runtime change codex to openclaw on restart"
|
||||
else
|
||||
echo " SKIP: Docker not available"
|
||||
SKIP=$((SKIP + 1))
|
||||
@@ -228,7 +228,7 @@ else
|
||||
fi
|
||||
|
||||
# Clean up runtime test workspaces
|
||||
for rt_id in $RT_CC_ID $RT_LG_ID $RT_CR_ID; do
|
||||
for rt_id in $RT_CC_ID $RT_CX_ID $RT_HM_ID; do
|
||||
curl -s -X DELETE "$BASE/workspaces/$rt_id?confirm=true" > /dev/null 2>&1
|
||||
sleep 0.3
|
||||
done
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
# Regression test for tests/e2e/lib/model_slug.sh.
|
||||
#
|
||||
# PR #2571 fixed a synth-E2E masking bug where MODEL_SLUG was hardcoded
|
||||
# to "openai/gpt-4o" (slash-form) but langgraph's init_chat_model needs
|
||||
# "openai:gpt-4o" (colon-form). Fix shipped as a per-runtime case
|
||||
# statement. Without this regression test, dropping any branch of the
|
||||
# case (or flipping a slug format) would silently revert behavior — the
|
||||
# E2E only fails as "Could not resolve authentication method" at the
|
||||
# very first message, after a successful tenant + workspace provision.
|
||||
# to "openai/gpt-4o" (slash-form). Without this regression test, dropping
|
||||
# any branch of the case (or flipping a slug format) would silently revert
|
||||
# behavior — the E2E only fails as "Could not resolve authentication method"
|
||||
# at the very first message, after a successful tenant + workspace provision.
|
||||
#
|
||||
# Each branch must FAIL the test if the dispatch behavior changes, not
|
||||
# just produce some non-empty string.
|
||||
@@ -47,7 +45,7 @@ echo
|
||||
|
||||
# ── Per-runtime branches (the load-bearing ones for synth-E2E) ──
|
||||
run_test "hermes → slash-form (derive-provider.sh contract)" hermes "openai/gpt-4o"
|
||||
run_test "langgraph → colon-form (init_chat_model contract)" langgraph "openai:gpt-4o"
|
||||
run_test "codex → slash-form fallback" codex "openai/gpt-4o"
|
||||
run_test "claude-code → OAuth/default alias" claude-code "sonnet"
|
||||
|
||||
got=$(unset E2E_MODEL_SLUG E2E_ANTHROPIC_API_KEY; E2E_MINIMAX_API_KEY="mx-test" pick_model_slug claude-code)
|
||||
@@ -74,8 +72,8 @@ echo
|
||||
echo "Test: pick_model_slug — E2E_MODEL_SLUG override"
|
||||
echo
|
||||
|
||||
got=$(E2E_MODEL_SLUG="anthropic:claude-opus-4-7" pick_model_slug langgraph)
|
||||
assert_eq "override beats langgraph default" "$got" "anthropic:claude-opus-4-7"
|
||||
got=$(E2E_MODEL_SLUG="anthropic:claude-opus-4-7" pick_model_slug codex)
|
||||
assert_eq "override beats codex default" "$got" "anthropic:claude-opus-4-7"
|
||||
|
||||
got=$(E2E_MODEL_SLUG="custom/whatever" pick_model_slug hermes)
|
||||
assert_eq "override beats hermes default" "$got" "custom/whatever"
|
||||
@@ -88,8 +86,8 @@ assert_eq "override beats claude-code default" "$got" "some-b
|
||||
# it because changing this behavior (e.g. via -v test) would silently
|
||||
# break the dispatch when an operator passes "" to clear an inherited
|
||||
# env var.
|
||||
got=$(E2E_MODEL_SLUG="" pick_model_slug langgraph)
|
||||
assert_eq "empty-string override falls through to dispatch" "$got" "openai:gpt-4o"
|
||||
got=$(E2E_MODEL_SLUG="" pick_model_slug codex)
|
||||
assert_eq "empty-string override falls through to dispatch" "$got" "openai/gpt-4o"
|
||||
|
||||
echo
|
||||
echo "─────────────────────────────────────────────────"
|
||||
|
||||
@@ -94,10 +94,10 @@ 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.
|
||||
# Body has no runtime → defaults to claude-code; pass the matching model
|
||||
# that the workspace-creation contract now requires.
|
||||
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
||||
-d '{"name":"Notify E2E","tier":1,"model":"anthropic:claude-opus-4-7"}')
|
||||
-d '{"name":"Notify E2E","tier":1,"model":"sonnet"}')
|
||||
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"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# E2E test: every runtime template (8 total) works end-to-end.
|
||||
# E2E test: every maintained runtime template works end-to-end.
|
||||
#
|
||||
# Self-contained happy-path smoke per runtime. Provisions a fresh
|
||||
# workspace, waits for status=online, sends a real A2A message, and
|
||||
@@ -7,13 +7,12 @@
|
||||
# extraction (and ongoing template work) can't silently break any
|
||||
# runtime.
|
||||
#
|
||||
# Runtimes covered: claude-code, hermes, langgraph, crewai, autogen,
|
||||
# deepagents, openclaw, gemini-cli. claude-code + hermes have unique
|
||||
# Runtimes covered: claude-code, codex, hermes, openclaw.
|
||||
# claude-code + hermes have unique
|
||||
# provisioning quirks (claude-code OAuth, hermes 15-min cold-boot)
|
||||
# and stay first-class with their own run_<runtime> functions; the
|
||||
# 5 OpenAI-backed runtimes share run_openai_runtime; gemini-cli has
|
||||
# its own block (Google AI key). Each phase skips cleanly if its
|
||||
# prerequisite secret is missing.
|
||||
# OpenAI-backed runtimes share run_openai_runtime. Each phase skips cleanly
|
||||
# if its prerequisite secret is missing.
|
||||
#
|
||||
# What this proves:
|
||||
# 1. Provisioning + container boot works for each runtime.
|
||||
@@ -296,9 +295,8 @@ print(json.dumps({
|
||||
####################################################################
|
||||
# Secondary runtimes — same provision/online/A2A loop, parametrized.
|
||||
####################################################################
|
||||
# These 5 templates (langgraph, crewai, autogen, deepagents, openclaw)
|
||||
# all use OpenAI as their LLM provider in the default config and don't
|
||||
# need the hermes-specific HERMES_* secret block. Skip if no key.
|
||||
# Codex and OpenClaw use OpenAI as their LLM provider in this smoke and
|
||||
# don't need the hermes-specific HERMES_* secret block. Skip if no key.
|
||||
# claude-code + hermes stay first-class above because each has unique
|
||||
# provisioning quirks (claude-code OAuth, hermes cold-boot tolerance);
|
||||
# refactoring them into this generic loop would lose those guards.
|
||||
@@ -361,67 +359,17 @@ print(json.dumps({
|
||||
fi
|
||||
}
|
||||
|
||||
run_langgraph() { run_openai_runtime "langgraph" "langgraph"; }
|
||||
run_crewai() { run_openai_runtime "crewai" "crewai"; }
|
||||
run_autogen() { run_openai_runtime "autogen" "autogen"; }
|
||||
run_deepagents() { run_openai_runtime "deepagents" "deepagents"; }
|
||||
run_codex() { run_openai_runtime "codex" "codex"; }
|
||||
run_openclaw() { run_openai_runtime "openclaw" "openclaw"; }
|
||||
|
||||
# gemini-cli wants a Google API key, not OpenAI. Skip if absent.
|
||||
run_gemini_cli() {
|
||||
echo ""
|
||||
echo "=== gemini-cli happy path ==="
|
||||
if [ -z "${E2E_GEMINI_API_KEY:-}" ]; then
|
||||
skip "E2E_GEMINI_API_KEY not set (gemini-cli needs Google AI key)"
|
||||
return 0
|
||||
fi
|
||||
local secrets
|
||||
secrets=$(python3 -c "
|
||||
import json, os
|
||||
print(json.dumps({'GEMINI_API_KEY': os.environ['E2E_GEMINI_API_KEY']}))
|
||||
")
|
||||
local resp wsid
|
||||
# model required (CTO 2026-05-22 SSOT) — gemini-cli routes via the gemini provider.
|
||||
resp=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"Priority E2E (gemini-cli)\",\"runtime\":\"gemini-cli\",\"model\":\"gemini-2.0-flash\",\"tier\":1,\"secrets\":$secrets}")
|
||||
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")
|
||||
echo " workspace=$wsid"
|
||||
local final
|
||||
final=$(wait_for_status "$wsid" "online failed" 240) || true
|
||||
if [ "$final" != "online" ]; then
|
||||
fail "gemini-cli workspace reaches online" "final status: $final"
|
||||
return 0
|
||||
fi
|
||||
pass "gemini-cli workspace reaches online"
|
||||
local token; token=$(e2e_mint_test_token "$wsid")
|
||||
if [ -z "$token" ]; then fail "mint gemini-cli test token" "no token"; return 0; fi
|
||||
local reply
|
||||
if reply=$(send_test_prompt "$wsid" "$token"); then
|
||||
if echo "$reply" | grep -q "PONG"; then
|
||||
pass "gemini-cli reply contains PONG"
|
||||
else
|
||||
pass "gemini-cli reply non-empty (first 80 chars: ${reply:0:80})"
|
||||
fi
|
||||
assert_activity_logged "gemini-cli" "$wsid" "$token"
|
||||
else
|
||||
fail "gemini-cli reply" "${reply:-<empty or error>}"
|
||||
fi
|
||||
}
|
||||
|
||||
WANT="${E2E_RUNTIMES:-claude-code hermes}"
|
||||
WANT="${E2E_RUNTIMES:-claude-code codex hermes openclaw}"
|
||||
for r in $WANT; do
|
||||
case "$r" in
|
||||
claude-code) run_claude_code ;;
|
||||
codex) run_codex ;;
|
||||
hermes) run_hermes ;;
|
||||
langgraph) run_langgraph ;;
|
||||
crewai) run_crewai ;;
|
||||
autogen) run_autogen ;;
|
||||
deepagents) run_deepagents ;;
|
||||
openclaw) run_openclaw ;;
|
||||
gemini-cli) run_gemini_cli ;;
|
||||
all) run_claude_code; run_hermes; run_langgraph; run_crewai; run_autogen; run_deepagents; run_openclaw; run_gemini_cli ;;
|
||||
all) run_claude_code; run_codex; run_hermes; run_openclaw ;;
|
||||
*) echo "unknown runtime in E2E_RUNTIMES: $r" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -27,9 +27,9 @@ create_workspace() {
|
||||
local tenant="$1" name="$2" tier="$3" parent="${4:-}"
|
||||
local body
|
||||
if [ -n "$parent" ]; then
|
||||
body="{\"name\":\"$name\",\"tier\":$tier,\"parent_id\":\"$parent\",\"runtime\":\"langgraph\"}"
|
||||
body="{\"name\":\"$name\",\"tier\":$tier,\"parent_id\":\"$parent\",\"runtime\":\"claude-code\",\"model\":\"sonnet\"}"
|
||||
else
|
||||
body="{\"name\":\"$name\",\"tier\":$tier,\"runtime\":\"langgraph\"}"
|
||||
body="{\"name\":\"$name\",\"tier\":$tier,\"runtime\":\"claude-code\",\"model\":\"sonnet\"}"
|
||||
fi
|
||||
local id
|
||||
if [ "$tenant" = "alpha" ]; then
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Reverse of 000_schema_bootstrap.up.sql.
|
||||
--
|
||||
-- Drops the dedicated schema and every plugin object inside it. Operator
|
||||
-- runs this only when intentionally tearing down the v2 plugin store on
|
||||
-- a shared tenant Postgres. Down migrations are NOT auto-applied by the
|
||||
-- plugin (cmd/memory-plugin-postgres/main.go:runMigrations comment) —
|
||||
-- this file is for manual operator-driven cleanup.
|
||||
|
||||
DROP SCHEMA IF EXISTS memory_plugin CASCADE;
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Create the dedicated schema this plugin owns when it shares a Postgres
|
||||
-- with the tenant platform (the default deployment shape — see
|
||||
-- entrypoint-tenant.sh which appends `search_path=memory_plugin,public`
|
||||
-- to DATABASE_URL when the operator hasn't set MEMORY_PLUGIN_DATABASE_URL
|
||||
-- explicitly).
|
||||
--
|
||||
-- The schema name `memory_plugin` matches the search_path injected by
|
||||
-- the entrypoint; sql.Open's URL controls *where* subsequent CREATE
|
||||
-- TABLE lands (search_path) but does NOT auto-create the target schema,
|
||||
-- so the plugin has to do it itself before 001_memory_v2 runs.
|
||||
--
|
||||
-- About the `,public` fallback in search_path: pgvector ships as an
|
||||
-- extension that lives in one schema. On fresh tenants 001_memory_v2's
|
||||
-- `CREATE EXTENSION IF NOT EXISTS vector` installs it into the first
|
||||
-- writable schema in search_path (memory_plugin — SSOT preserved). On
|
||||
-- tenants where pgvector was already installed into `public` by a
|
||||
-- prior boot, the IF NOT EXISTS is a no-op and the extension stays in
|
||||
-- public; the `vector(1536)` type reference in 001 then resolves via
|
||||
-- the public fallback. Without that fallback, 001 would die with
|
||||
-- "type vector does not exist" on every pre-existing tenant (#1742
|
||||
-- review finding).
|
||||
--
|
||||
-- Operators who point the plugin at a dedicated database (no shared
|
||||
-- tenant tables) can leave this no-op: CREATE SCHEMA IF NOT EXISTS is
|
||||
-- idempotent and a fresh DB happily owns a `memory_plugin` schema.
|
||||
--
|
||||
-- Migration files are sorted alphabetically (cmd/memory-plugin-postgres/
|
||||
-- main.go:runMigrationsFromEmbed → sort.Strings), so 000_ runs before
|
||||
-- 001_memory_v2 which assumes the schema already exists in search_path.
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS memory_plugin;
|
||||
@@ -34,13 +34,49 @@ func TestMigrationsEmbedded_ContainsCreateTable(t *testing.T) {
|
||||
t.Errorf("read embedded %q: %v", e.Name(), err)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(string(data), "CREATE TABLE") {
|
||||
t.Errorf("embedded %q has no CREATE TABLE — wrong file embedded?", e.Name())
|
||||
// Each file must contain at least one DDL statement we expect to see
|
||||
// — guards against truncated / empty files that would silently embed.
|
||||
if !containsAnyDDL(string(data)) {
|
||||
t.Errorf("embedded %q contains no recognized DDL (CREATE TABLE/SCHEMA/EXTENSION/INDEX) — wrong or truncated file?", e.Name())
|
||||
}
|
||||
}
|
||||
if !seenUp {
|
||||
t.Fatal("no *.up.sql in embedded migrations — runtime would have no schema to apply")
|
||||
}
|
||||
|
||||
// Per-file invariants (issue #1742 review finding). The previous global
|
||||
// "at least one file has CREATE TABLE somewhere" check let a future
|
||||
// rewrite of 001_memory_v2.up.sql silently regress to schema-only as
|
||||
// long as any other file declared a table. Pin the load-bearing DDL
|
||||
// per filename so a wrong-or-truncated 001 fails this test loudly.
|
||||
assertFileContains(t, "000_schema_bootstrap.up.sql", "CREATE SCHEMA")
|
||||
assertFileContains(t, "001_memory_v2.up.sql", "CREATE TABLE")
|
||||
assertFileContains(t, "001_memory_v2.up.sql", "memory_records")
|
||||
assertFileContains(t, "001_memory_v2.up.sql", "memory_namespaces")
|
||||
}
|
||||
|
||||
// assertFileContains fails the test if the embedded migration `name`
|
||||
// either can't be read or doesn't contain `needle`. Pulled out so each
|
||||
// per-file pin reads as one line.
|
||||
func assertFileContains(t *testing.T, name, needle string) {
|
||||
t.Helper()
|
||||
data, err := migrationsFS.ReadFile("migrations/" + name)
|
||||
if err != nil {
|
||||
t.Errorf("required migration %q not embedded: %v", name, err)
|
||||
return
|
||||
}
|
||||
if !strings.Contains(string(data), needle) {
|
||||
t.Errorf("migration %q must contain %q — wrong or truncated file?", name, needle)
|
||||
}
|
||||
}
|
||||
|
||||
func containsAnyDDL(sql string) bool {
|
||||
for _, kw := range []string{"CREATE TABLE", "CREATE SCHEMA", "CREATE EXTENSION", "CREATE INDEX", "CREATE OR REPLACE", "ALTER TABLE"} {
|
||||
if strings.Contains(sql, kw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestRunMigrationsFromEmbed_OrderingIsAlphabetic pins that we apply
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -210,6 +210,12 @@ func main() {
|
||||
memBundle := memwiring.Build(db.DB)
|
||||
if memBundle != nil {
|
||||
wh.WithNamespaceCleanup(memBundle.NamespaceCleanupFn())
|
||||
// Issue #1755: route workspace-create `initial_memories` through
|
||||
// the v2 plugin instead of the legacy `agent_memories` table.
|
||||
// Same plugin client the MCP tools use, same namespace
|
||||
// (`workspace:<id>`); writes are visible to subsequent
|
||||
// `recall_memory` calls on the same workspace.
|
||||
wh.WithSeedMemoryPlugin(memBundle.Plugin)
|
||||
}
|
||||
|
||||
// External-plugin env mutators — each plugin contributes 0+ mutators
|
||||
|
||||
@@ -23,22 +23,57 @@ 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}"
|
||||
# Schema isolation (issue #1733): when defaulting from the tenant
|
||||
# DATABASE_URL we co-locate the plugin's tables under a dedicated
|
||||
# `memory_plugin` schema so they never collide with platform-tenant
|
||||
# tables in `public`. The plugin's 000_schema_bootstrap migration
|
||||
# creates the schema; search_path here directs every subsequent CREATE
|
||||
# TABLE / SELECT to land in it.
|
||||
#
|
||||
# The search_path includes `public` as a fallback so the `vector` type
|
||||
# resolves regardless of which schema pgvector was installed into.
|
||||
# Fresh tenants (no prior `CREATE EXTENSION vector`) install the
|
||||
# extension into `memory_plugin` (first writable schema in the path),
|
||||
# keeping the SSOT intent. Tenants where pgvector was already
|
||||
# installed into `public` by a prior boot or operator action keep the
|
||||
# extension where it is and resolve `vector(1536)` via the public
|
||||
# fallback — without this fallback those tenants would crash the
|
||||
# plugin boot with "type vector does not exist" once the migrations
|
||||
# try to create memory_records (#1742 review finding).
|
||||
#
|
||||
# Operators who explicitly set MEMORY_PLUGIN_DATABASE_URL (separate DB
|
||||
# entirely) keep full control — search_path is only injected when we
|
||||
# default from DATABASE_URL.
|
||||
if [ -z "$MEMORY_PLUGIN_DATABASE_URL" ]; then
|
||||
case "$DATABASE_URL" in
|
||||
*\?*) MEMORY_PLUGIN_DATABASE_URL="${DATABASE_URL}&search_path=memory_plugin,public" ;;
|
||||
*) MEMORY_PLUGIN_DATABASE_URL="${DATABASE_URL}?search_path=memory_plugin,public" ;;
|
||||
esac
|
||||
fi
|
||||
: "${MEMORY_PLUGIN_LISTEN_ADDR:=:9100}"
|
||||
export MEMORY_PLUGIN_DATABASE_URL MEMORY_PLUGIN_LISTEN_ADDR
|
||||
echo "memory-plugin: starting sidecar on $MEMORY_PLUGIN_LISTEN_ADDR" >&2
|
||||
|
||||
@@ -60,8 +60,8 @@ func Import(
|
||||
// Build config files in memory for the provisioner
|
||||
configFiles := buildBundleConfigFiles(b)
|
||||
|
||||
// Extract runtime from config.yaml in the bundle
|
||||
bundleRuntime := "langgraph"
|
||||
// Extract runtime from config.yaml in the bundle.
|
||||
bundleRuntime := "claude-code"
|
||||
if configYaml, ok := b.Prompts["config.yaml"]; ok {
|
||||
for _, line := range strings.Split(configYaml, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
@@ -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{
|
||||
@@ -694,7 +705,7 @@ func (h *WorkspaceHandler) resolveAgentURL(ctx context.Context, workspaceID stri
|
||||
if strings.HasPrefix(agentURL, "http://127.0.0.1:") && h.provisioner != nil && platformInDocker {
|
||||
var wsRuntime string
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(runtime, 'langgraph') FROM workspaces WHERE id = $1`,
|
||||
`SELECT COALESCE(runtime, 'claude-code') FROM workspaces WHERE id = $1`,
|
||||
workspaceID,
|
||||
).Scan(&wsRuntime); err != nil {
|
||||
log.Printf("ProxyA2A: runtime lookup before Docker URL rewrite failed for %s: %v", workspaceID, err)
|
||||
|
||||
@@ -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"},
|
||||
@@ -156,7 +169,7 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
|
||||
// canvas-chat-to-dead-workspace incident traces to exactly this gap.
|
||||
func (h *WorkspaceHandler) maybeMarkContainerDead(ctx context.Context, workspaceID string) bool {
|
||||
var wsRuntime string
|
||||
db.DB.QueryRowContext(ctx, `SELECT COALESCE(runtime, 'langgraph') FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsRuntime)
|
||||
db.DB.QueryRowContext(ctx, `SELECT COALESCE(runtime, 'claude-code') FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsRuntime)
|
||||
if isExternalLikeRuntime(wsRuntime) {
|
||||
return false
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -280,7 +280,7 @@ func TestProxyA2A_Upstream502_TriggersContainerDeadCheck(t *testing.T) {
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// maybeMarkContainerDead's runtime lookup, then the offline-flip UPDATE.
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, 'langgraph'\) FROM workspaces WHERE id =`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, 'claude-code'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-tunnel-dead").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("hermes"))
|
||||
mock.ExpectExec(`UPDATE workspaces SET status =`).
|
||||
@@ -340,7 +340,7 @@ func TestProxyA2A_Upstream502_AliveAgent_PropagatesAsIs(t *testing.T) {
|
||||
expectBudgetCheck(mock, "ws-alive-502")
|
||||
mock.ExpectExec("INSERT INTO activity_logs").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// IsRunning runtime lookup runs but no UPDATE follows (running=true).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, 'langgraph'\) FROM workspaces WHERE id =`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, 'claude-code'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-alive-502").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("hermes"))
|
||||
|
||||
@@ -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)
|
||||
@@ -1815,9 +1954,9 @@ func TestMaybeMarkContainerDead_NilProvisioner(t *testing.T) {
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, 'langgraph'\) FROM workspaces WHERE id =`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, 'claude-code'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-nilprov").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("langgraph"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("claude-code"))
|
||||
|
||||
if got := handler.maybeMarkContainerDead(context.Background(), "ws-nilprov"); got {
|
||||
t.Error("expected false when provisioner is nil")
|
||||
@@ -1838,7 +1977,7 @@ func TestMaybeMarkContainerDead_CPOnly_NotRunning(t *testing.T) {
|
||||
cp := &fakeCPProv{running: false}
|
||||
handler.SetCPProvisioner(cp)
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, 'langgraph'\) FROM workspaces WHERE id =`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, 'claude-code'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-saas-dead").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("hermes"))
|
||||
mock.ExpectExec(`UPDATE workspaces SET status =`).
|
||||
@@ -1867,7 +2006,7 @@ func TestMaybeMarkContainerDead_CPOnly_Running(t *testing.T) {
|
||||
cp := &fakeCPProv{running: true}
|
||||
handler.SetCPProvisioner(cp)
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, 'langgraph'\) FROM workspaces WHERE id =`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, 'claude-code'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-saas-alive").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("hermes"))
|
||||
|
||||
@@ -1968,7 +2107,7 @@ func TestMaybeMarkContainerDead_ExternalRuntime(t *testing.T) {
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, 'langgraph'\) FROM workspaces WHERE id =`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, 'claude-code'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-ext").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("external"))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ func discoverHostPeer(ctx context.Context, c *gin.Context, targetID string) {
|
||||
// of `callerID` and writes the JSON response (or an appropriate 404/503 error).
|
||||
func discoverWorkspacePeer(ctx context.Context, c *gin.Context, callerID, targetID string) {
|
||||
var wsName, wsRuntime string
|
||||
db.DB.QueryRowContext(ctx, `SELECT COALESCE(name,''), COALESCE(runtime,'langgraph') FROM workspaces WHERE id = $1`, targetID).Scan(&wsName, &wsRuntime)
|
||||
db.DB.QueryRowContext(ctx, `SELECT COALESCE(name,''), COALESCE(runtime,'claude-code') FROM workspaces WHERE id = $1`, targetID).Scan(&wsName, &wsRuntime)
|
||||
|
||||
// External workspaces: return their registered URL.
|
||||
// Rewrite 127.0.0.1/localhost → host.docker.internal ONLY when the
|
||||
@@ -180,7 +180,7 @@ func writeExternalWorkspaceURL(ctx context.Context, c *gin.Context, callerID, ta
|
||||
}
|
||||
outURL := wsURL
|
||||
var callerRuntime string
|
||||
db.DB.QueryRowContext(ctx, `SELECT COALESCE(runtime,'langgraph') FROM workspaces WHERE id = $1`, callerID).Scan(&callerRuntime)
|
||||
db.DB.QueryRowContext(ctx, `SELECT COALESCE(runtime,'claude-code') FROM workspaces WHERE id = $1`, callerID).Scan(&callerRuntime)
|
||||
if !isExternalLikeRuntime(callerRuntime) {
|
||||
outURL = strings.Replace(outURL, "127.0.0.1", "host.docker.internal", 1)
|
||||
outURL = strings.Replace(outURL, "localhost", "host.docker.internal", 1)
|
||||
@@ -203,8 +203,8 @@ func writeExternalWorkspaceURL(ctx context.Context, c *gin.Context, callerID, ta
|
||||
|
||||
// Peers handles GET /registry/:id/peers
|
||||
//
|
||||
// Optional ``?q=<substring>`` filters the result by case-insensitive
|
||||
// substring match against ``name`` or ``role`` (#1038). Filtering is done
|
||||
// Optional “?q=<substring>“ filters the result by case-insensitive
|
||||
// substring match against “name“ or “role“ (#1038). Filtering is done
|
||||
// in Go after the DB read — keeps the SQL identical to the no-filter path
|
||||
// (no injection risk, no DB-driver collation surprises) at the cost of
|
||||
// loading the unfiltered set first. Acceptable because the peer set is
|
||||
@@ -301,8 +301,8 @@ func (h *DiscoveryHandler) Peers(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, peers)
|
||||
}
|
||||
|
||||
// excludeSelfFromPeers strips any peer entry whose ``id`` equals
|
||||
// ``workspaceID`` (the caller's own row). Final-line defense for #383
|
||||
// excludeSelfFromPeers strips any peer entry whose “id“ equals
|
||||
// “workspaceID“ (the caller's own row). Final-line defense for #383
|
||||
// (self-delegation 400-loop on external workspaces): a peer-list that
|
||||
// includes the requester's own row is the root mechanism by which an
|
||||
// agent ends up delegating to itself. The pre-DB filters in Peers
|
||||
@@ -337,8 +337,8 @@ func filterPeersByQuery(peers []map[string]interface{}, q string) []map[string]i
|
||||
needle := strings.ToLower(q)
|
||||
out := make([]map[string]interface{}, 0, len(peers))
|
||||
for _, p := range peers {
|
||||
name, _ := p["name"].(string) // nil → "" — safe on empty-role rows
|
||||
role, _ := p["role"].(string) // nil → "" — queryPeerMaps sets nil when DB role is empty
|
||||
name, _ := p["name"].(string) // nil → "" — safe on empty-role rows
|
||||
role, _ := p["role"].(string) // nil → "" — queryPeerMaps sets nil when DB role is empty
|
||||
if strings.Contains(strings.ToLower(name), needle) ||
|
||||
strings.Contains(strings.ToLower(role), needle) {
|
||||
out = append(out, p)
|
||||
|
||||
@@ -404,11 +404,11 @@ func TestPeers_Q_NoMatches_RawBodyIsArrayNotNull(t *testing.T) {
|
||||
// role and asserts no panic + correct filter behaviour.
|
||||
func TestFilterPeersByQuery_NilRoleRegression(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
peers []map[string]interface{}
|
||||
q string
|
||||
wantLen int
|
||||
wantIDs []string
|
||||
name string
|
||||
peers []map[string]interface{}
|
||||
q string
|
||||
wantLen int
|
||||
wantIDs []string
|
||||
}{
|
||||
{
|
||||
name: "nil role matches on name",
|
||||
@@ -555,7 +555,7 @@ func TestDiscoverWorkspacePeer_Online(t *testing.T) {
|
||||
setupTestRedis(t)
|
||||
|
||||
// name/runtime lookup → non-external
|
||||
mock.ExpectQuery(`SELECT COALESCE\(name,''\), COALESCE\(runtime,'langgraph'\) FROM workspaces WHERE id =`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(name,''\), COALESCE\(runtime,'claude-code'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-online").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "runtime"}).AddRow("Target", "langgraph"))
|
||||
// No cached internal URL → DB status lookup → online
|
||||
@@ -583,7 +583,7 @@ func TestDiscoverWorkspacePeer_NotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(name,''\), COALESCE\(runtime,'langgraph'\) FROM workspaces WHERE id =`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(name,''\), COALESCE\(runtime,'claude-code'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-missing").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "runtime"}).AddRow("", "langgraph"))
|
||||
mock.ExpectQuery(`SELECT status FROM workspaces WHERE id =`).
|
||||
@@ -605,14 +605,14 @@ func TestDiscoverWorkspacePeer_ExternalRuntime_HandledByExternalURL(t *testing.T
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(name,''\), COALESCE\(runtime,'langgraph'\) FROM workspaces WHERE id =`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(name,''\), COALESCE\(runtime,'claude-code'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-ext").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "runtime"}).AddRow("Ext", "external"))
|
||||
// writeExternalWorkspaceURL's two queries
|
||||
mock.ExpectQuery(`SELECT COALESCE\(url,''\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-ext").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"url"}).AddRow("http://external.example"))
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime,'langgraph'\) FROM workspaces WHERE id =`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime,'claude-code'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-caller").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("external"))
|
||||
|
||||
@@ -630,7 +630,7 @@ func TestDiscoverWorkspacePeer_CachedInternalURLHit(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mr := setupTestRedis(t)
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(name,''\), COALESCE\(runtime,'langgraph'\) FROM workspaces WHERE id =`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(name,''\), COALESCE\(runtime,'claude-code'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-cached").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "runtime"}).AddRow("Cached", "langgraph"))
|
||||
mr.Set("ws:ws-cached:internal_url", "http://ws-cached:8000")
|
||||
@@ -654,7 +654,7 @@ func TestDiscoverWorkspacePeer_NotReachable(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(name,''\), COALESCE\(runtime,'langgraph'\) FROM workspaces WHERE id =`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(name,''\), COALESCE\(runtime,'claude-code'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-paused").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "runtime"}).AddRow("Paused", "langgraph"))
|
||||
mock.ExpectQuery(`SELECT status FROM workspaces WHERE id =`).
|
||||
@@ -681,9 +681,9 @@ func TestWriteExternalWorkspaceURL_Success(t *testing.T) {
|
||||
mock.ExpectQuery(`SELECT COALESCE\(url,''\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-ext").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"url"}).AddRow("http://external.example/a2a"))
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime,'langgraph'\) FROM workspaces WHERE id =`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime,'claude-code'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-caller").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("langgraph"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("claude-code"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -731,9 +731,9 @@ func TestWriteExternalWorkspaceURL_RewritesLocalhostForDockerCaller(t *testing.T
|
||||
WithArgs("ws-ext").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"url"}).AddRow("http://127.0.0.1:8000/a2a"))
|
||||
// non-external caller runtime → rewrite enabled
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime,'langgraph'\) FROM workspaces WHERE id =`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime,'claude-code'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-caller").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("langgraph"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("claude-code"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -818,9 +818,9 @@ func TestWriteExternalWorkspaceURL_RejectsMetadataIPURL(t *testing.T) {
|
||||
WillReturnRows(sqlmock.NewRows([]string{"url"}).
|
||||
AddRow("http://169.254.169.254/computeMetadata/v1/"))
|
||||
// callerRuntime lookup happens before isSafeURL — must mock it.
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime,'langgraph'\) FROM workspaces WHERE id =`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime,'claude-code'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-caller").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("langgraph"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("claude-code"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -33,9 +33,11 @@ func TestWorkspaceCreate_WithParentID(t *testing.T) {
|
||||
// Default tier is 3 (Privileged) — see workspace.go create-handler comment.
|
||||
// delivery_mode defaults to "push" when payload omits it (#2339).
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Child Agent", nil, 3, "langgraph", &parentID, nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Child Agent", nil, 3, "claude-code", &parentID, nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO workspace_secrets").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
WithArgs(sqlmock.AnyArg(), float64(0), float64(0)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
@@ -104,7 +106,7 @@ func TestWorkspaceCreate_MissingName(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
body := `{"tier":1,"runtime":"langgraph"}`
|
||||
body := `{"tier":1,"runtime":"claude-code"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -242,7 +244,7 @@ func TestWorkspaceList_WithData(t *testing.T) {
|
||||
}
|
||||
rows := sqlmock.NewRows(columns).
|
||||
AddRow("ws-1", "Agent One", "worker", 1, "online", []byte(`{"name":"agent1"}`), "http://localhost:8001",
|
||||
nil, 3, 1, 0.02, "", 7200, "processing", "langgraph", "", 10.0, 20.0, false, nil, int64(0), false, true, []byte(`{}`)).
|
||||
nil, 3, 1, 0.02, "", 7200, "processing", "claude-code", "", 10.0, 20.0, false, nil, int64(0), false, true, []byte(`{}`)).
|
||||
AddRow("ws-2", "Agent Two", "", 2, "degraded", []byte("null"), "",
|
||||
nil, 0, 1, 0.6, "timeout", 100, "", "claude-code", "", 50.0, 60.0, true, nil, int64(0), false, true, []byte(`{}`))
|
||||
|
||||
@@ -585,7 +587,7 @@ func TestDiscover_TargetOffline(t *testing.T) {
|
||||
// Name + runtime lookup (discovery now queries both)
|
||||
mock.ExpectQuery("SELECT COALESCE").
|
||||
WithArgs("ws-off").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "runtime"}).AddRow("Offline Agent", "langgraph"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "runtime"}).AddRow("Offline Agent", "claude-code"))
|
||||
|
||||
// No cached internal URL → falls to DB status check → offline
|
||||
mock.ExpectQuery("SELECT status FROM workspaces WHERE id =").
|
||||
@@ -917,7 +919,7 @@ func TestRestart_ParentPaused(t *testing.T) {
|
||||
mock.ExpectQuery("SELECT status, name, tier").
|
||||
WithArgs("dddddddd-0001-0000-0000-000000000000").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status", "name", "tier", "runtime"}).
|
||||
AddRow("offline", "Child Agent", 1, "langgraph"))
|
||||
AddRow("offline", "Child Agent", 1, "claude-code"))
|
||||
|
||||
// isParentPaused: get parent_id
|
||||
mock.ExpectQuery("SELECT parent_id FROM workspaces WHERE id").
|
||||
@@ -960,7 +962,7 @@ func TestRestart_ProvisionerNil(t *testing.T) {
|
||||
mock.ExpectQuery("SELECT status, name, tier").
|
||||
WithArgs("ws-noprov").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status", "name", "tier", "runtime"}).
|
||||
AddRow("offline", "Agent", 1, "langgraph"))
|
||||
AddRow("offline", "Agent", 1, "claude-code"))
|
||||
|
||||
// isParentPaused: no parent
|
||||
mock.ExpectQuery("SELECT parent_id FROM workspaces WHERE id").
|
||||
@@ -1195,7 +1197,7 @@ func TestResume_ProvisionerNil(t *testing.T) {
|
||||
mock.ExpectQuery("SELECT name, tier").
|
||||
WithArgs("ws-resume-noprov").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "tier", "runtime"}).
|
||||
AddRow("Paused Agent", 1, "langgraph"))
|
||||
AddRow("Paused Agent", 1, "claude-code"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
@@ -368,11 +368,13 @@ func TestWorkspaceCreate(t *testing.T) {
|
||||
// Default tier is 3 (Privileged) — see workspace.go create-handler comment.
|
||||
// delivery_mode defaults to "push" when payload omits it (#2339).
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Test Agent", nil, 3, "langgraph", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Test Agent", nil, 3, "claude-code", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// Expect transaction commit (no secrets in this payload)
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO workspace_secrets").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// Expect canvas_layouts INSERT
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
@@ -389,8 +391,8 @@ func TestWorkspaceCreate(t *testing.T) {
|
||||
// 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
|
||||
// the bare-defaults path (no template, no runtime → claude-code), so
|
||||
// the body must declare an explicit model. Using a claude-code-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))
|
||||
@@ -462,7 +464,7 @@ func TestWorkspaceList(t *testing.T) {
|
||||
AddRow("ws-1", "Agent One", "worker", 1, "online", []byte("null"), "http://localhost:8001",
|
||||
nil, 0, 1, 0.0, "", 100, "", "claude-code", "", 10.0, 20.0, false, nil, int64(0), false, true, []byte(`{}`)).
|
||||
AddRow("ws-2", "Agent Two", "manager", 2, "provisioning", []byte("null"), "",
|
||||
nil, 0, 1, 0.0, "", 0, "", "langgraph", "", 50.0, 60.0, false, nil, int64(0), false, true, []byte(`{}`))
|
||||
nil, 0, 1, 0.0, "", 0, "", "claude-code", "", 50.0, 60.0, false, nil, int64(0), false, true, []byte(`{}`))
|
||||
|
||||
mock.ExpectQuery("SELECT w.id, w.name").
|
||||
WillReturnRows(rows)
|
||||
@@ -1182,7 +1184,7 @@ func TestWorkspaceGet_CurrentTask(t *testing.T) {
|
||||
WithArgs("dddddddd-0004-0000-0000-000000000000").
|
||||
WillReturnRows(sqlmock.NewRows(columns).AddRow(
|
||||
"dddddddd-0004-0000-0000-000000000000", "Task Worker", "worker", 1, "online", []byte("null"), "http://localhost:9000",
|
||||
nil, 2, 1, 0.0, "", 300, "Analyzing document", "langgraph", "", 10.0, 20.0, false,
|
||||
nil, 2, 1, 0.0, "", 300, "Analyzing document", "claude-code", "", 10.0, 20.0, false,
|
||||
nil, int64(0), false, true, []byte(`{}`),
|
||||
))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -363,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) {
|
||||
|
||||
@@ -804,7 +804,7 @@ func (h *OrgHandler) Import(c *gin.Context) {
|
||||
for i, gm := range tmpl.GlobalMemories {
|
||||
globalSeeds[i] = models.MemorySeed{Content: gm.Content, Scope: "GLOBAL"}
|
||||
}
|
||||
seedInitialMemories(context.Background(), rootID, globalSeeds)
|
||||
h.workspace.seedInitialMemories(context.Background(), rootID, globalSeeds)
|
||||
log.Printf("Org import: seeded %d global memories on root workspace %s", len(globalSeeds), rootID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
|
||||
runtime = defaults.Runtime
|
||||
}
|
||||
if runtime == "" {
|
||||
runtime = "langgraph"
|
||||
runtime = "claude-code"
|
||||
}
|
||||
model := ws.Model
|
||||
if model == "" {
|
||||
@@ -258,7 +258,7 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
|
||||
if len(wsMemories) == 0 {
|
||||
wsMemories = defaults.InitialMemories
|
||||
}
|
||||
seedInitialMemories(ctx, id, wsMemories)
|
||||
h.workspace.seedInitialMemories(ctx, id, wsMemories)
|
||||
|
||||
// Handle external workspaces
|
||||
if ws.External {
|
||||
|
||||
@@ -119,7 +119,6 @@ func TestHostPluginPath_PerRuntime(t *testing.T) {
|
||||
}{
|
||||
{"claude-code", "browser-automation", "/configs/plugins/browser-automation"},
|
||||
{"hermes", "browser-automation", "/home/ubuntu/.hermes/plugins/browser-automation"},
|
||||
{"langgraph", "browser-automation", "/opt/configs/plugins/browser-automation"},
|
||||
// Unknown / empty runtime falls back to /configs (containerized
|
||||
// user-data layout) so a future runtime added to workspaces table
|
||||
// without a workspaceFilePathPrefix entry doesn't blow up the
|
||||
|
||||
@@ -89,9 +89,9 @@ func resolveRestartTemplate(configsDir, wsName, dbRuntime string, body restartTe
|
||||
// host, load an arbitrary host directory as the workspace template.
|
||||
//
|
||||
// sanitizeRuntime applies an allowlist of known runtimes; any unknown
|
||||
// value (including traversal strings) is remapped to "langgraph". The
|
||||
// value (including traversal strings) is remapped to "claude-code". The
|
||||
// attacker cannot choose an arbitrary host path — they can at most
|
||||
// trigger application of the langgraph-default template.
|
||||
// trigger application of the claude-code-default template.
|
||||
if body.ApplyTemplate && dbRuntime != "" {
|
||||
safeRuntime := sanitizeRuntime(dbRuntime)
|
||||
runtimeTemplate := filepath.Join(configsDir, safeRuntime+"-default")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -470,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"})
|
||||
|
||||
@@ -48,13 +48,10 @@ import (
|
||||
var workspaceFilePathPrefix = map[string]string{
|
||||
"hermes": "/home/ubuntu/.hermes",
|
||||
"claude-code": "/configs",
|
||||
"langgraph": "/opt/configs",
|
||||
"external": "/opt/configs",
|
||||
// Default for unknown / future runtimes is /configs — matches the
|
||||
// containerized user-data layout. The `langgraph` / `external`
|
||||
// entries pre-date the unified user-data path and are retained
|
||||
// until a migration audit confirms what the running tenants of
|
||||
// those runtimes actually have on disk.
|
||||
// containerized user-data layout. The `external` entry pre-dates the
|
||||
// unified user-data path and does not map to a spawned runtime.
|
||||
}
|
||||
|
||||
// resolveWorkspaceFilePath translates (runtime, root, relPath) into an
|
||||
|
||||
@@ -32,7 +32,6 @@ func TestResolveWorkspaceFilePath_RuntimeIndirection(t *testing.T) {
|
||||
// and would 500 with EACCES on save (the bug that motivated this gate).
|
||||
{"claude-code", "/configs", "config.yaml", "/configs/config.yaml"},
|
||||
{"CLAUDE-CODE", "/configs", "config.yaml", "/configs/config.yaml"}, // case-insensitive
|
||||
{"langgraph", "/configs", "config.yaml", "/opt/configs/config.yaml"},
|
||||
{"external", "/configs", "skills.json", "/opt/configs/skills.json"},
|
||||
{"", "/configs", "config.yaml", "/configs/config.yaml"}, // empty runtime → default
|
||||
{"unknown", "/configs", "config.yaml", "/configs/config.yaml"}, // unknown → default
|
||||
@@ -70,7 +69,7 @@ func TestResolveWorkspaceFilePath_LiteralRoots(t *testing.T) {
|
||||
// universal Linux path, not a managed-config indirection.
|
||||
{"hermes", "/home", "ubuntu/.bashrc", "/home/ubuntu/.bashrc"},
|
||||
{"claude-code", "/home", "ubuntu/notes.md", "/home/ubuntu/notes.md"},
|
||||
{"langgraph", "/home", "ubuntu/x", "/home/ubuntu/x"},
|
||||
{"codex", "/home", "ubuntu/x", "/home/ubuntu/x"},
|
||||
// /workspace and /plugins are also literal — runtime is ignored.
|
||||
{"hermes", "/workspace", "src/main.go", "/workspace/src/main.go"},
|
||||
{"claude-code", "/plugins", "p/manifest.yaml", "/plugins/p/manifest.yaml"},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -86,6 +86,7 @@ func TestTemplatesList_WithTemplates(t *testing.T) {
|
||||
configYaml := `name: Test Agent
|
||||
description: A test agent
|
||||
tier: 2
|
||||
runtime: claude-code
|
||||
model: anthropic:claude-sonnet-4-20250514
|
||||
skills:
|
||||
- web-search
|
||||
@@ -546,7 +547,7 @@ func TestTemplatesList_OmitsProvidersWhenAbsent(t *testing.T) {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
configYaml := `name: Legacy
|
||||
runtime: langgraph
|
||||
runtime: hermes
|
||||
runtime_config:
|
||||
model: anthropic:claude-opus-4-7
|
||||
skills: []
|
||||
@@ -582,6 +583,7 @@ func TestTemplatesList_LegacyTopLevelModel(t *testing.T) {
|
||||
}
|
||||
configYaml := `name: Legacy Agent
|
||||
tier: 1
|
||||
runtime: claude-code
|
||||
model: anthropic:claude-sonnet-4-6
|
||||
skills: []
|
||||
`
|
||||
@@ -602,8 +604,8 @@ skills: []
|
||||
if len(resp) != 1 || resp[0].Model != "anthropic:claude-sonnet-4-6" {
|
||||
t.Errorf("legacy top-level model not surfaced: %+v", resp)
|
||||
}
|
||||
if resp[0].Runtime != "" {
|
||||
t.Errorf("Runtime should be empty for legacy template, got %q", resp[0].Runtime)
|
||||
if resp[0].Runtime != "claude-code" {
|
||||
t.Errorf("Runtime should be claude-code for legacy template, got %q", resp[0].Runtime)
|
||||
}
|
||||
if len(resp[0].Models) != 0 {
|
||||
t.Errorf("Models should be empty for legacy template, got %+v", resp[0].Models)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/crypto"
|
||||
"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/memory/contract"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
|
||||
@@ -74,12 +75,30 @@ type WorkspaceHandler struct {
|
||||
// memory plugin). main.go sets this to plugin.DeleteNamespace
|
||||
// when MEMORY_PLUGIN_URL is configured.
|
||||
namespaceCleanupFn func(ctx context.Context, workspaceID string)
|
||||
// seedMemoryPlugin is the v2 memory plugin client used by
|
||||
// seedInitialMemories (issue #1755) to write workspace-create
|
||||
// `initial_memories` into the plugin instead of the legacy
|
||||
// `agent_memories` table. nil-safe: when nil, seeding logs a loud
|
||||
// warning and skips. After A1 (#1747) there is no SQL fallback —
|
||||
// seeded memories with no plugin wired are simply not persisted.
|
||||
// main.go attaches this alongside namespaceCleanupFn when
|
||||
// MEMORY_PLUGIN_URL is set (memBundle.Plugin).
|
||||
seedMemoryPlugin seedMemoryPluginAPI
|
||||
// asyncWG tracks goroutines launched by goAsync so tests can wait
|
||||
// for async DB users (restart, provision) before asserting results.
|
||||
// Matches the pattern from main commit 1c3b4ff3.
|
||||
asyncWG sync.WaitGroup
|
||||
}
|
||||
|
||||
// seedMemoryPluginAPI is the slice of the v2 memory plugin client that
|
||||
// seedInitialMemories needs. Defining it as an interface here (parallel
|
||||
// to memoryPluginAPI in mcp_tools_memory_v2.go) lets tests stub the
|
||||
// plugin with a capture-only spy and keeps the handler decoupled from
|
||||
// the concrete *client.Client.
|
||||
type seedMemoryPluginAPI interface {
|
||||
CommitMemory(ctx context.Context, namespace string, body contract.MemoryWrite) (*contract.MemoryWriteResponse, error)
|
||||
}
|
||||
|
||||
// newHandlerHook, when non-nil, is invoked for every WorkspaceHandler
|
||||
// created via NewWorkspaceHandler. It is nil in production (zero cost);
|
||||
// the test harness sets it so setupTestDB can drain every handler's
|
||||
@@ -174,6 +193,19 @@ func (h *WorkspaceHandler) WithNamespaceCleanup(fn func(ctx context.Context, wor
|
||||
return h
|
||||
}
|
||||
|
||||
// WithSeedMemoryPlugin wires the v2 memory plugin so
|
||||
// seedInitialMemories (issue #1755) routes workspace-create
|
||||
// `initial_memories` through the plugin instead of the legacy
|
||||
// `agent_memories` table. main.go passes memBundle.Plugin (a
|
||||
// `*client.Client`); tests pass a stub matching the
|
||||
// seedMemoryPluginAPI interface. Nil-safe: omitting this leaves the
|
||||
// field nil and seedInitialMemories logs a warning + skips on each
|
||||
// invocation.
|
||||
func (h *WorkspaceHandler) WithSeedMemoryPlugin(p seedMemoryPluginAPI) *WorkspaceHandler {
|
||||
h.seedMemoryPlugin = p
|
||||
return h
|
||||
}
|
||||
|
||||
// SetCPProvisioner wires the control plane provisioner for SaaS tenants.
|
||||
// Auto-activated when MOLECULE_ORG_ID is set (no manual config needed).
|
||||
//
|
||||
@@ -241,12 +273,12 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
// runtimeExplicitlyRequested is true when the caller expressed intent for
|
||||
// a SPECIFIC runtime — either by passing `runtime` directly, or by naming
|
||||
// a `template` (a template encodes a runtime). When true, we must NOT
|
||||
// silently fall back to langgraph if that intent can't be honored: that
|
||||
// silently fall back to the default runtime if that intent can't be honored: that
|
||||
// is the molecule-controlplane#188 / #184 contract violation (caller asks
|
||||
// for codex/claude-code, gets a langgraph workspace, 201, no error — a
|
||||
// for codex/hermes/openclaw, gets a default-runtime workspace, 201, no error — a
|
||||
// false success). #188 mandates fail-closed (error+notify) on mismatch,
|
||||
// not an advisory degrade. The legitimate "no template, no runtime →
|
||||
// langgraph default" path (bare {"name":...}) is unaffected.
|
||||
// default-runtime path (bare {"name":...}) is unaffected.
|
||||
runtimeExplicitlyRequested := payload.Runtime != "" || payload.Template != ""
|
||||
templateRuntimeResolved := payload.Runtime != ""
|
||||
if payload.Template != "" && (payload.Runtime == "" || payload.Model == "") {
|
||||
@@ -300,15 +332,15 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
// intent for a specific runtime (passed `runtime`, or named a `template`)
|
||||
// but we could NOT resolve a concrete runtime from it (template's
|
||||
// config.yaml unreadable, or it has no `runtime:` key), DO NOT silently
|
||||
// substitute langgraph and return 201 — that is the silent contract
|
||||
// substitute the default runtime and return 201 — that is the silent contract
|
||||
// violation that produced 5/5 wrong workspaces and a false codex E2E pass.
|
||||
// Return 422 so the caller learns the requested runtime was not honored.
|
||||
// The platform-side CP fix (controlplane#188) is the sibling gate; this
|
||||
// closes the ws-server `Create` boundary the product UI actually hits.
|
||||
if payload.Runtime == "" && runtimeExplicitlyRequested && !templateRuntimeResolved {
|
||||
log.Printf("Create: FAIL-CLOSED (controlplane#188) — template=%q requested but runtime could not be resolved; refusing silent langgraph fallback", payload.Template)
|
||||
log.Printf("Create: FAIL-CLOSED (controlplane#188) — template=%q requested but runtime could not be resolved; refusing silent default-runtime fallback", payload.Template)
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{
|
||||
"error": "runtime could not be resolved from the requested template; refusing to silently provision langgraph (controlplane#188). Pass an explicit \"runtime\", or use a template whose config.yaml declares one.",
|
||||
"error": "runtime could not be resolved from the requested template; refusing to silently provision the default runtime (controlplane#188). Pass an explicit \"runtime\", or use a template whose config.yaml declares one.",
|
||||
"template": payload.Template,
|
||||
"code": "RUNTIME_UNRESOLVED",
|
||||
})
|
||||
@@ -316,8 +348,8 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
}
|
||||
if payload.Runtime == "" {
|
||||
// Legitimate default path: no template AND no runtime requested
|
||||
// (bare {"name":...}) — langgraph is the intended default here.
|
||||
payload.Runtime = "langgraph"
|
||||
// (bare {"name":...}) — claude-code is the intended default here.
|
||||
payload.Runtime = "claude-code"
|
||||
}
|
||||
|
||||
// SSOT (CTO 2026-05-22, feedback_workspace_model_required_no_platform_default_dynamic_credential_intake):
|
||||
@@ -571,7 +603,7 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
|
||||
// Seed initial memories from the create payload (issue #1050).
|
||||
// Non-fatal: failures are logged but don't block workspace creation.
|
||||
seedInitialMemories(ctx, id, payload.InitialMemories)
|
||||
h.seedInitialMemories(ctx, id, payload.InitialMemories)
|
||||
|
||||
// Broadcast provisioning event. Include `runtime` so the canvas can
|
||||
// populate the Runtime pill on the side panel immediately — without it
|
||||
@@ -819,7 +851,7 @@ const workspaceListQuery = `
|
||||
w.parent_id, w.active_tasks, COALESCE(w.max_concurrent_tasks, 1),
|
||||
w.last_error_rate,
|
||||
COALESCE(w.last_sample_error, ''), w.uptime_seconds,
|
||||
COALESCE(w.current_task, ''), COALESCE(w.runtime, 'langgraph'),
|
||||
COALESCE(w.current_task, ''), COALESCE(w.runtime, 'claude-code'),
|
||||
COALESCE(w.workspace_dir, ''),
|
||||
COALESCE(cl.x, 0), COALESCE(cl.y, 0), COALESCE(cl.collapsed, false),
|
||||
w.budget_limit, COALESCE(w.monthly_spend, 0),
|
||||
@@ -881,7 +913,7 @@ func (h *WorkspaceHandler) Get(c *gin.Context) {
|
||||
w.parent_id, w.active_tasks, COALESCE(w.max_concurrent_tasks, 1),
|
||||
w.last_error_rate,
|
||||
COALESCE(w.last_sample_error, ''), w.uptime_seconds,
|
||||
COALESCE(w.current_task, ''), COALESCE(w.runtime, 'langgraph'),
|
||||
COALESCE(w.current_task, ''), COALESCE(w.runtime, 'claude-code'),
|
||||
COALESCE(w.workspace_dir, ''),
|
||||
COALESCE(cl.x, 0), COALESCE(cl.y, 0), COALESCE(cl.collapsed, false),
|
||||
w.budget_limit, COALESCE(w.monthly_spend, 0),
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestWorkspaceBudget_Get_NilLimit(t *testing.T) {
|
||||
WillReturnRows(sqlmock.NewRows(wsColumns).
|
||||
AddRow("dddddddd-0005-0000-0000-000000000000", "Free Agent", "worker", 1, "online",
|
||||
[]byte(`{}`), "http://localhost:9001",
|
||||
nil, 0, 1, 0.0, "", 0, "", "langgraph", "",
|
||||
nil, 0, 1, 0.0, "", 0, "", "claude-code", "",
|
||||
0.0, 0.0, false,
|
||||
nil, // budget_limit NULL
|
||||
0, // monthly_spend 0
|
||||
@@ -97,7 +97,7 @@ func TestWorkspaceBudget_Get_WithLimit(t *testing.T) {
|
||||
WillReturnRows(sqlmock.NewRows(wsColumns).
|
||||
AddRow("dddddddd-0006-0000-0000-000000000000", "Capped Agent", "worker", 1, "online",
|
||||
[]byte(`{}`), "http://localhost:9002",
|
||||
nil, 0, 1, 0.0, "", 0, "", "langgraph", "",
|
||||
nil, 0, 1, 0.0, "", 0, "", "claude-code", "",
|
||||
0.0, 0.0, false,
|
||||
int64(500), // budget_limit = $5.00 in DB
|
||||
int64(123), // monthly_spend = $1.23 in DB
|
||||
@@ -151,7 +151,7 @@ func TestWorkspaceBudget_Create_WithLimit(t *testing.T) {
|
||||
"Budgeted Agent", // name
|
||||
nil, // role
|
||||
3, // tier (default, workspace.go create-handler)
|
||||
"langgraph", // runtime
|
||||
"claude-code", // runtime
|
||||
(*string)(nil), // parent_id
|
||||
nil, // workspace_dir
|
||||
"none", // workspace_access
|
||||
@@ -161,6 +161,8 @@ func TestWorkspaceBudget_Create_WithLimit(t *testing.T) {
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO workspace_secrets").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
WithArgs(sqlmock.AnyArg(), float64(0), float64(0)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
@@ -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",
|
||||
@@ -141,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 +170,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 +203,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 +237,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 +264,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 +278,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 +389,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 +414,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)})
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user