Compare commits

..

6 Commits

Author SHA1 Message Date
Molecule AI Dev Engineer A (Kimi) acde1eb676 fix(github-token): add HTTP client timeout to prevent indefinite blocking
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
sop-checklist / na-declarations (pull_request) N/A: (none)
audit-force-merge / audit (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Waiting to run
Check migration collisions / Migration version collision check (pull_request) Waiting to run
CI / all-required (pull_request) Waiting to run
CI / Python Lint & Test (pull_request) Waiting to run
CI / Detect changes (pull_request) Waiting to run
E2E API Smoke Test / detect-changes (pull_request) Waiting to run
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Waiting to run
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Waiting to run
Handlers Postgres Integration / detect-changes (pull_request) Waiting to run
Harness Replays / detect-changes (pull_request) Waiting to run
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Waiting to run
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Waiting to run
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Waiting to run
E2E Chat / detect-changes (pull_request) Waiting to run
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Waiting to run
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Waiting to run
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Waiting to run
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Waiting to run
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Waiting to run
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Waiting to run
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Waiting to run
lint-required-no-paths / lint-required-no-paths (pull_request) Waiting to run
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Waiting to run
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Waiting to run
Secret scan / Scan diff for credential-shaped strings (pull_request) Waiting to run
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Waiting to run
gate-check-v3 / gate-check (pull_request) Waiting to run
qa-review / approved (pull_request) Waiting to run
security-review / approved (pull_request) Waiting to run
review-check-tests / review-check.sh regression tests (pull_request) Waiting to run
sop-checklist / all-items-acked (pull_request) Waiting to run
sop-checklist / review-refire (pull_request) Waiting to run
sop-tier-check / tier-check (pull_request) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (pull_request) Has been cancelled
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Has been cancelled
CI / Platform (Go) (pull_request) Has been cancelled
CI / Canvas (Next.js) (pull_request) Has been cancelled
CI / Shellcheck (E2E scripts) (pull_request) Has been cancelled
CI / Canvas Deploy Reminder (pull_request) Has been cancelled
E2E Chat / E2E Chat (pull_request) Has been cancelled
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Has been cancelled
Harness Replays / Harness Replays (pull_request) Has been cancelled
http.DefaultClient has no timeout, so a slow/unresponsive GitHub API
could block the handler goroutine forever. Use an http.Client with a
30-second timeout in generateAppInstallationToken.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 05:08:29 +00:00
Molecule AI Dev Engineer A (Kimi) f4b4036a68 fix(migrations): renumber workspace_compute to avoid collision with main
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
CI / Python Lint & Test (pull_request) Successful in 6s
CI / Detect changes (pull_request) Successful in 10s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Has been skipped
E2E API Smoke Test / detect-changes (pull_request) Successful in 11s
E2E Chat / detect-changes (pull_request) Successful in 11s
Check migration collisions / Migration version collision check (pull_request) Successful in 30s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 16s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 9s
Harness Replays / detect-changes (pull_request) Successful in 7s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 4s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 4s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 38s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 8s
qa-review / approved (pull_request) Failing after 8s
gate-check-v3 / gate-check (pull_request) Successful in 8s
security-review / approved (pull_request) Failing after 4s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 57s
sop-checklist / review-refire (pull_request) Has been skipped
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 7s
sop-tier-check / tier-check (pull_request) Successful in 4s
CI / Canvas (Next.js) (pull_request) Successful in 2s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
E2E Chat / E2E Chat (pull_request) Successful in 6s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 5s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m8s
Harness Replays / Harness Replays (pull_request) Successful in 3s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m42s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m18s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m10s
CI / Platform (Go) (pull_request) Successful in 4m43s
CI / all-required (pull_request) Successful in 6m0s
Main already has 20260523000000_schedule_consecutive_sdk_errors.
Renumber 20260523000000_workspace_compute → 20260523010000_workspace_compute.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 04:53:14 +00:00
Molecule AI Dev Engineer A (Kimi) b0f66735c4 fix(lint): move GH_PAT alias from writer side to read side (buildContainerEnv)
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 6s
CI / Detect changes (pull_request) Successful in 7s
CI / Python Lint & Test (pull_request) Successful in 4s
E2E API Smoke Test / detect-changes (pull_request) Successful in 6s
Check migration collisions / Migration version collision check (pull_request) Failing after 16s
E2E Chat / detect-changes (pull_request) Successful in 6s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 8s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 38s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 12s
Harness Replays / detect-changes (pull_request) Successful in 11s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 11s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 3s
gate-check-v3 / gate-check (pull_request) Successful in 6s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
security-review / approved (pull_request) Failing after 7s
qa-review / approved (pull_request) Failing after 8s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 31s
sop-checklist / review-refire (pull_request) Has been skipped
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 5s
sop-tier-check / tier-check (pull_request) Successful in 4s
CI / Canvas (Next.js) (pull_request) Successful in 3s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 1s
E2E Chat / E2E Chat (pull_request) Successful in 5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 12s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m3s
Harness Replays / Harness Replays (pull_request) Successful in 4s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m39s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1m52s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m15s
CI / Platform (Go) (pull_request) Successful in 4m29s
CI / all-required (pull_request) Successful in 5m57s
- Remove applyGitHubTokenAlias from workspace_provision_shared.go
  (writer-side path flagged by lint-no-tenant-gitea-token + lint-forbidden-env-keys)
- Delete github_token_alias_test.go (function removed)
- Add alias to provisioner.buildContainerEnv: reads GH_PAT from cfg.EnvVars
  and injects GH_TOKEN / GITHUB_TOKEN into container env only.
  This is a READ-side operation (container env assembly) that never
  touches tenant-writer surfaces (workspace_secrets, envVars map, etc.).
- provisioner.go is already exempt from both lints (denylist source-of-truth)

Fixes CI lint failures on PR #1697.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 04:11:01 +00:00
Molecule AI Dev Engineer A (Kimi) 69bec10321 fix(test): correct TestWorkspaceCreate_WithComputeOverrides expectations
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
CI / Detect changes (pull_request) Successful in 8s
CI / Python Lint & Test (pull_request) Successful in 6s
E2E Chat / detect-changes (pull_request) Successful in 7s
Check migration collisions / Migration version collision check (pull_request) Failing after 16s
E2E API Smoke Test / detect-changes (pull_request) Successful in 8s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 2s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 9s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 47s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Failing after 5s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 41s
Harness Replays / detect-changes (pull_request) Successful in 5s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 8s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Failing after 5s
qa-review / approved (pull_request) Failing after 4s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 3s
sop-checklist / review-refire (pull_request) Has been skipped
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
gate-check-v3 / gate-check (pull_request) Successful in 6s
security-review / approved (pull_request) Failing after 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m14s
sop-tier-check / tier-check (pull_request) Successful in 5s
CI / Canvas (Next.js) (pull_request) Successful in 5s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
E2E Chat / E2E Chat (pull_request) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 7s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m40s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Harness Replays / Harness Replays (pull_request) Successful in 5s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1m48s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m25s
CI / Platform (Go) (pull_request) Successful in 4m59s
CI / all-required (pull_request) Successful in 6m45s
- Change expected status from 200 to 201 (Create returns StatusCreated)
- Remove workspace_auth_tokens expectation (non-external workspace)
- Reorder sqlmock expectations to match actual handler flow:
  provisioning broadcast → mark-failed broadcast → status UPDATE → config INSERT

Fixes CI failure on PR #1697.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 04:04:18 +00:00
Molecule AI Dev Engineer A (Kimi) 4e84dffd9e fix(workspace-server): #1687 — alias GH_PAT to GH_TOKEN / GITHUB_TOKEN at provision time
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 12s
CI / Detect changes (pull_request) Successful in 10s
CI / Python Lint & Test (pull_request) Successful in 5s
E2E API Smoke Test / detect-changes (pull_request) Successful in 9s
Check migration collisions / Migration version collision check (pull_request) Successful in 24s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 4s
E2E Chat / detect-changes (pull_request) Successful in 10s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 8s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 12s
Harness Replays / detect-changes (pull_request) Successful in 3s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Failing after 5s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Failing after 3s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 33s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 44s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 10s
gate-check-v3 / gate-check (pull_request) Successful in 6s
qa-review / approved (pull_request) Failing after 3s
security-review / approved (pull_request) Failing after 4s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 3s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 3s
CI / Canvas (Next.js) (pull_request) Successful in 2s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m4s
E2E Chat / E2E Chat (pull_request) Successful in 15s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4s
Harness Replays / Harness Replays (pull_request) Successful in 6s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m10s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m15s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m23s
CI / Platform (Go) (pull_request) Failing after 4m48s
CI / all-required (pull_request) Failing after 6m15s
Workspace secrets stored as GH_PAT were invisible to gh CLI and git
credential helpers because both expect GH_TOKEN (or GITHUB_TOKEN).
Agents with private-repo dependencies got auth failures even though
the credential was present under the wrong name.

Fix: after all env mutators run, applyGitHubTokenAlias copies GH_PAT
to GH_TOKEN and GITHUB_TOKEN only when those keys are absent. Explicit
workspace_secrets named GH_TOKEN or GITHUB_TOKEN always win.

- workspace_provision_shared.go: +applyGitHubTokenAlias call after
  plugin env mutators, +helper function (non-destructive).
- github_token_alias_test.go: unit tests covering no-PAT, empty-PAT,
  fills-missing, preserves-explicit, partial-explicit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 01:05:10 +00:00
Molecule AI Dev Engineer A (Kimi) fed6352b58 feat(workspace-server): #1686 Phase 1 — compute schema (instance_type + volume.root_gb) in Create + provisioner
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
CI / Python Lint & Test (pull_request) Successful in 5s
CI / Detect changes (pull_request) Successful in 8s
E2E API Smoke Test / detect-changes (pull_request) Successful in 7s
E2E Chat / detect-changes (pull_request) Successful in 8s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 11s
Check migration collisions / Migration version collision check (pull_request) Successful in 17s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 34s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 6s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 3s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Harness Replays / detect-changes (pull_request) Successful in 4s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 4s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 1m9s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
gate-check-v3 / gate-check (pull_request) Successful in 10s
security-review / approved (pull_request) Failing after 4s
qa-review / approved (pull_request) Failing after 6s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 3s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 5s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m8s
CI / Canvas (Next.js) (pull_request) Successful in 4s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
E2E Chat / E2E Chat (pull_request) Successful in 9s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m34s
Harness Replays / Harness Replays (pull_request) Successful in 3s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1m57s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m15s
CI / Platform (Go) (pull_request) Failing after 4m41s
CI / all-required (pull_request) Failing after 6m29s
- Migration: add compute_instance_type (TEXT) and compute_volume_root_gb (INTEGER)
  to workspaces table with IF NOT EXISTS guards.
- Models: ComputeConfig + ComputeVolume structs, ValidateComputeConfig with
  bounds (instance_type max 64, root_gb 32–2048).
- Handler (Create): validate compute block, extract nullable overrides, pass
  them into the INSERT (14 args now).
- Provisioner config: add InstanceType + VolumeRootGB to WorkspaceConfig.
- CP provisioner: include instance_type + volume_root_gb in cpProvisionRequest
  JSON body with omitempty (nil = CP default).
- Tests:
  • handler tests: updated all sqlmock INSERT WithArgs for 14 args,
    added TestWorkspaceCreate_InvalidCompute and
    TestWorkspaceCreate_WithComputeOverrides.
  • workspace_provision_test: added TestBuildProvisionerConfig_ComputeOverrides
    and TestBuildProvisionerConfig_ComputeNil.
  • cp_provisioner_test: added TestStart_ComputeOverrides and
    TestStart_ComputeOmittedWhenNil.
  • models: new workspace_compute_test.go covering nil, empty, valid,
    and boundary validation.

Backward-compatible: omitted compute block = nil columns = platform-managed
 defaults (no change to existing behaviour).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 00:33:15 +00:00
132 changed files with 3209 additions and 6838 deletions
+3
View File
@@ -50,6 +50,9 @@ 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`.
+4
View File
@@ -21,6 +21,10 @@ 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)",
]
-5
View File
@@ -128,7 +128,6 @@ 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}"
@@ -137,10 +136,6 @@ 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
@@ -1,31 +0,0 @@
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,10 +146,3 @@ 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)",
]
-187
View File
@@ -1,187 +0,0 @@
# ci-arm64-advisory — Mac arm64 self-hosted ADVISORY fast-check lane.
#
# === WHY ===
#
# The amd64 Gitea runner pool (molecule-runner-1..20) is queue-contended
# (internal#418). This lane offloads the *genuinely container-independent*
# fast checks (Go build/vet/lint, shellcheck, Python lint) onto the Mac
# arm64 self-hosted runner so developers get a fast arm64 signal WITHOUT
# adding load to the starved amd64 pool — capability-honestly, as an
# additive pilot. Pilot ② of the Mac-CI strategy (CTO-delegated 2026-05-17).
#
# === NON-NEGOTIABLE SAFETY CONTRACT (the prime directive) ===
#
# This lane is **ADVISORY ONLY**. It is provably incapable of hanging a
# merge. Concretely:
#
# 1. It is a SEPARATE workflow file. `ci.yml` is byte-for-byte
# untouched by this PR. The `CI / all-required` aggregator sentinel
# and the five contexts it polls
# (`CI / Detect changes|Platform (Go)|Canvas (Next.js)|
# Shellcheck (E2E scripts)|Python Lint & Test (pull_request)`)
# are unchanged. The canonical required gate stays 100% on the
# existing amd64 pool.
#
# 2. The context this workflow emits is
# `ci-arm64-advisory / fast-checks (pull_request)`. That string is
# DELIBERATELY NOT present in, and this PR does NOT add it to:
# - branch_protections/{main,staging}.status_check_contexts
# (DB-verified pb 86/75 = exactly
# ["CI / all-required (pull_request)",
# "sop-checklist / all-items-acked (pull_request)"])
# - audit-force-merge.yml REQUIRED_CHECKS env
# - ci.yml `all-required` sentinel's hardcoded `required[]` list
# Branch protection therefore never waits on this context. If the
# Mac runner is absent / offline / removed, this workflow's status
# simply never appears — and because nothing requires it, every
# merge proceeds exactly as it does today. There is no path by
# which a missing/red arm64 status blocks a merge.
#
# 3. `continue-on-error: true` on the job — even a genuine arm64-only
# failure (toolchain drift, arch-specific test flake) is surfaced
# as information, never as a merge blocker, for the duration of
# the pilot.
#
# 4. The job carries a `github.event_name` `if:` gate. Beyond its
# functional purpose this also keeps the job OUT of
# `ci-required-drift.py:ci_job_names()` (which excludes
# `github.event_name`/`github.ref`-gated jobs), so the hourly
# ci-required-drift sentinel's F1 ("job not under sentinel needs")
# cannot ever flag this advisory job. F2/F3 are untouched because
# this context is absent from BP and from REQUIRED_CHECKS.
# `lint-bp-context-emit-match` only fails on BP→emitter gaps; an
# emitter without a BP context is explicitly informational there.
#
# === RUNNER TARGETING ===
#
# The Mac runner is `hongming-pc-runner-1`. The bare `self-hosted`
# label is POLLUTED in this Gitea instance: molecule-runner-1..20
# (the contended amd64 pool) also advertise `self-hosted`. Targeting
# bare `self-hosted` would route back onto the very pool we are trying
# to relieve — and onto amd64 hardware. We therefore require an
# AND-set of labels that ONLY the Mac satisfies. `macos-self-hosted`
# is Mac-exclusive (the amd64 pool does not carry it). Until the
# label-install burst (a10862b2) lands `self-hosted`+`macos-self-hosted`
# on the Mac, the runner's current unique label `hongming-pc-laptop`
# is also listed; AND-semantics over the labels a runner advertises
# means a job requiring [self-hosted, macos-self-hosted] can ONLY be
# claimed once the Mac advertises both. If neither label set is yet
# present on the Mac, the workflow stays queued harmlessly and is
# garbage-collected by the normal stale-run reaper — it blocks nothing
# (see safety contract point 2).
#
# === ROLLBACK ===
#
# Delete this single file (`git rm .gitea/workflows/ci-arm64-advisory.yml`)
# and merge. No branch-protection edit, no ci.yml edit, no
# REQUIRED_CHECKS edit is required to roll back, because none were made
# to roll forward. Zero blast radius either direction.
name: ci-arm64-advisory
on:
push:
branches: [main, staging]
pull_request:
branches: [main, staging]
# Per-ref cancel: a newer commit on the same ref supersedes the older
# advisory run. Distinct from ci.yml's `ci-${ref}` group so this lane
# never cancels (or is cancelled by) the canonical required CI.
concurrency:
group: ci-arm64-advisory-${{ github.ref }}
cancel-in-progress: true
env:
GITHUB_SERVER_URL: https://git.moleculesai.app
jobs:
fast-checks:
name: fast-checks
# AND-set: only the Mac arm64 runner advertises macos-self-hosted.
# See "RUNNER TARGETING" header note for why bare self-hosted is unsafe.
runs-on: [self-hosted, macos-self-hosted]
# ADVISORY: never blocks. See safety contract point 3. mc#774
# internal#418 — tracked: arm64 advisory pilot, non-gating by design.
continue-on-error: true
# event_name gate: functional (only meaningful on push/PR) AND keeps
# this job out of ci-required-drift.py:ci_job_names() so F1 can never
# flag it. See safety contract point 4.
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
timeout-minutes: 20
steps:
- name: Provenance — advisory lane, non-gating
run: |
echo "This is the arm64 ADVISORY fast-check lane."
echo "It does NOT gate merges. Canonical required CI is ci.yml"
echo "on the amd64 pool. Arch: $(uname -m) on $(uname -s)."
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# ---- Go: build + vet + lint (container-independent: needs only the
# Go toolchain; no amd64 ECR image, no docker-in-job). Race-detector
# unit-test + coverage gates are deliberately NOT duplicated here —
# those stay authoritative on amd64 ci.yml `Platform (Go)`. This lane
# is fast-feedback for the compile/vet/lint surface only. ----
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: 'stable'
- name: Go build + vet (workspace-server)
working-directory: workspace-server
run: |
go mod download
go build ./cmd/server
go vet ./...
- name: golangci-lint (workspace-server)
working-directory: workspace-server
run: |
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
"$(go env GOPATH)/bin/golangci-lint" run --timeout 3m ./...
# ---- Shellcheck (container-independent: shellcheck binary only).
# Mirrors ci.yml `Shellcheck (E2E scripts)` bulk pass scope. ----
- name: Install shellcheck (arm64)
run: |
if ! command -v shellcheck >/dev/null 2>&1; then
echo "shellcheck not preinstalled on this self-hosted runner."
echo "Attempting Homebrew install (Mac arm64)."
brew install shellcheck || {
echo "::warning::shellcheck unavailable on runner; advisory shellcheck skipped."
exit 0
}
fi
shellcheck --version
- name: Shellcheck tests/e2e + infra/scripts
run: |
command -v shellcheck >/dev/null 2>&1 || { echo "skip"; exit 0; }
find tests/e2e infra/scripts -type f -name '*.sh' -print0 \
| xargs -0 shellcheck --severity=warning
# ---- Python lint/compile (container-independent: CPython only).
# Lint + import-compile surface; the authoritative pytest + coverage
# floors stay on amd64 ci.yml `Python Lint & Test`. ----
- name: Setup Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.11'
- name: Python byte-compile (workspace)
working-directory: workspace
run: |
python -m pip install --quiet ruff || true
python -m compileall -q .
if command -v ruff >/dev/null 2>&1; then
ruff check . || echo "::warning::ruff findings (advisory only)"
fi
- name: Advisory summary
if: always()
run: |
{
echo "## arm64 advisory fast-checks complete"
echo ""
echo "This lane is **advisory** — it does not gate merges."
echo "Authoritative required CI remains \`CI / all-required\`"
echo "on the amd64 pool (\`ci.yml\`, unchanged by this PR)."
} >> "$GITHUB_STEP_SUMMARY"
+5 -30
View File
@@ -476,11 +476,7 @@ 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. 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.
# or never emit.
#
# canvas-deploy-reminder is intentionally NOT included in all-required.needs.
# It is an informational main-push reminder, not a PR quality gate. Keeping
@@ -488,24 +484,9 @@ jobs:
# sentinel before the `always()` guard can emit a branch-protection status.
#
continue-on-error: false
runs-on: ci-meta
runs-on: ubuntu-latest
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 }}
@@ -513,9 +494,6 @@ 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'
@@ -533,14 +511,11 @@ 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-darwin]
runs-on: [self-hosted, arm64]
# 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,7 +52,6 @@ jobs:
fetch-depth: 1
- name: Install shellcheck (arm64)
continue-on-error: true
run: |
set -eu
if command -v shellcheck >/dev/null 2>&1; then
@@ -72,16 +71,11 @@ 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"
-11
View File
@@ -73,17 +73,6 @@ 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,8 +234,6 @@ 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
+1 -21
View File
@@ -4,7 +4,7 @@
# use this Makefile; CI calls docker compose / go test directly so the
# Makefile can evolve without breaking the build.
.PHONY: help dev up down logs build test e2e-peer-visibility openapi-spec openapi-spec-check
.PHONY: help dev up down logs build test e2e-peer-visibility
help: ## Show this help.
@grep -E '^[a-zA-Z0-9_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-22s\033[0m %s\n", $$1, $$2}'
@@ -36,23 +36,3 @@ test: ## Run Go unit tests in workspace-server/.
# env contract (CLAUDE_CODE_OAUTH_TOKEN / E2E_MINIMAX_API_KEY / etc).
e2e-peer-visibility: ## Run the LOCAL peer-visibility MCP gate vs the running stack (needs `make up` first).
bash tests/e2e/test_peer_visibility_mcp_local.sh
# ─── OpenAPI spec generation (RFC #1706, Phase 1) ─────────────────────
# Regenerate workspace-server/docs/openapi/swagger.{yaml,json} from
# swaggo annotations on the gin handlers. Commit the output. CI runs
# `make openapi-spec-check` to assert no drift between annotations and
# the committed file — if a PR changes a handler but forgets to
# regenerate, CI fails with a diff.
openapi-spec: ## Regenerate OpenAPI spec from workspace-server handler annotations.
@command -v swag >/dev/null 2>&1 || go install github.com/swaggo/swag/cmd/swag@v1.16.4
cd workspace-server && swag init \
--generalInfo cmd/server/main.go \
--output docs/openapi \
--outputTypes yaml,json \
--dir . \
--parseDependency=false \
--parseInternal=true
openapi-spec-check: openapi-spec ## CI gate — fail if openapi-spec produces a diff vs the committed file.
@git diff --exit-code -- workspace-server/docs/openapi/ \
|| (echo "openapi-spec is stale — run 'make openapi-spec' and commit the result" && exit 1)
-7
View File
@@ -8,7 +8,6 @@
"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",
@@ -1111,12 +1110,6 @@
"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",
-1
View File
@@ -11,7 +11,6 @@
"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,8 +33,6 @@ 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
@@ -70,10 +68,6 @@ 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
@@ -229,10 +223,6 @@ export function CreateWorkspaceButton() {
setParentId("");
setBudgetLimit("");
setError(null);
setDisplayEnabled(false);
setDisplayInstanceType("t3.xlarge");
setDisplayRootGB("80");
setDisplayResolution("1920x1080");
setHermesProvider("anthropic");
setExternalRuntime("external");
setHermesApiKey("");
@@ -274,8 +264,6 @@ 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;
@@ -292,21 +280,6 @@ 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
@@ -474,73 +447,6 @@ 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
+1 -56
View File
@@ -24,10 +24,9 @@
* "no memories yet".
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '@/lib/api';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { useSocketEvent } from '@/hooks/useSocketEvent';
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -247,60 +246,6 @@ 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 () => {
-6
View File
@@ -9,8 +9,6 @@ import { DetailsTab } from "./tabs/DetailsTab";
import { SkillsTab } from "./tabs/SkillsTab";
import { ChatTab } from "./tabs/ChatTab";
import { ConfigTab } from "./tabs/ConfigTab";
import { ContainerConfigTab } from "./tabs/ContainerConfigTab";
import { DisplayTab } from "./tabs/DisplayTab";
import { TerminalTab } from "./tabs/TerminalTab";
import { FilesTab } from "./tabs/FilesTab";
import { MemoryInspectorPanel } from "./MemoryInspectorPanel";
@@ -33,8 +31,6 @@ const TABS: { id: PanelTab; label: string; icon: string }[] = [
{ id: "details", label: "Details", icon: "◉" },
{ id: "skills", label: "Plugins", icon: "✦" },
{ id: "terminal", label: "Terminal", icon: "▸" },
{ id: "display", label: "Display", icon: "▣" },
{ id: "container-config", label: "Container", icon: "▤" },
{ id: "config", label: "Config", icon: "⚙" },
{ id: "schedule", label: "Schedule", icon: "⏲" },
{ id: "channels", label: "Channels", icon: "⇌" },
@@ -304,8 +300,6 @@ export function SidePanel() {
{panelTab === "activity" && <ActivityTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "chat" && <ChatTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
{panelTab === "terminal" && <TerminalTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
{panelTab === "display" && <DisplayTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "container-config" && <ContainerConfigTab key={selectedNodeId} data={node.data} />}
{panelTab === "config" && <ConfigTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "schedule" && <ScheduleTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "channels" && <ChannelsTab key={selectedNodeId} workspaceId={selectedNodeId} />}
@@ -123,46 +123,6 @@ 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 and ScheduleTab tests.
* the pattern established in ApprovalBanner, MemoryTab, and ScheduleTab tests.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
@@ -0,0 +1,93 @@
// @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,17 +31,6 @@ 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,
@@ -527,156 +516,3 @@ 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();
});
});
@@ -11,8 +11,6 @@ vi.mock("../tabs/DetailsTab", () => ({ DetailsTab: () => null }));
vi.mock("../tabs/SkillsTab", () => ({ SkillsTab: () => null }));
vi.mock("../tabs/ChatTab", () => ({ ChatTab: () => null }));
vi.mock("../tabs/ConfigTab", () => ({ ConfigTab: () => null }));
vi.mock("../tabs/ContainerConfigTab", () => ({ ContainerConfigTab: () => null }));
vi.mock("../tabs/DisplayTab", () => ({ DisplayTab: () => null }));
vi.mock("../tabs/TerminalTab", () => ({ TerminalTab: () => null }));
vi.mock("../tabs/FilesTab", () => ({ FilesTab: () => null }));
vi.mock("../MemoryInspectorPanel", () => ({ MemoryInspectorPanel: () => null }));
@@ -76,7 +74,7 @@ import { SidePanel } from "../SidePanel";
const TABS = [
"chat", "activity", "details", "skills", "terminal",
"display", "container-config", "config", "schedule", "channels", "files", "memory", "traces", "events", "audit",
"config", "schedule", "channels", "files", "memory", "traces", "events", "audit",
];
describe("SidePanel — ARIA tablist pattern", () => {
@@ -87,20 +85,10 @@ describe("SidePanel — ARIA tablist pattern", () => {
expect(tablist.getAttribute("aria-label")).toBe("Workspace panel tabs");
});
it("renders exactly 15 tab buttons", () => {
it("renders exactly 13 tab buttons", () => {
render(<SidePanel />);
const tabs = screen.getAllByRole("tab");
expect(tabs.length).toBe(15);
});
it("renders the Display tab", () => {
render(<SidePanel />);
expect(document.getElementById("tab-display")).toBeTruthy();
});
it("renders the Container Config tab", () => {
render(<SidePanel />);
expect(document.getElementById("tab-container-config")).toBeTruthy();
expect(tabs.length).toBe(13);
});
it("active tab (chat) has aria-selected='true'", () => {
@@ -111,11 +99,11 @@ describe("SidePanel — ARIA tablist pattern", () => {
expect(chatTab?.getAttribute("aria-selected")).toBe("true");
});
it("all other 14 tabs have aria-selected='false'", () => {
it("all other 12 tabs have aria-selected='false'", () => {
render(<SidePanel />);
const tabs = screen.getAllByRole("tab");
const inactive = tabs.filter((t) => t.id !== "tab-chat");
expect(inactive.length).toBe(14);
expect(inactive.length).toBe(12);
for (const tab of inactive) {
expect(tab.getAttribute("aria-selected")).toBe("false");
}
@@ -128,7 +116,7 @@ describe("SidePanel — ARIA tablist pattern", () => {
const minusOnes = tabs.filter((t) => t.getAttribute("tabindex") === "-1");
expect(zeros.length).toBe(1);
expect(zeros[0].id).toBe("tab-chat");
expect(minusOnes.length).toBe(14);
expect(minusOnes.length).toBe(12);
});
it("active tab has aria-controls='panel-chat' and id='tab-chat'", () => {
+1 -1
View File
@@ -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/OnboardingWizard.
// ScheduleTab/MemoryTab/OnboardingWizard.
className="w-full text-xs py-1.5 rounded bg-accent hover:bg-accent-strong text-white transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
>
Connect Channel
@@ -1,96 +0,0 @@
"use client";
import { runtimeDisplayName } from "@/lib/runtime-names";
import type { WorkspaceNodeData } from "@/store/canvas";
type Props = {
data: Pick<
WorkspaceNodeData,
"runtime" | "status" | "needsRestart" | "activeTasks" | "deliveryMode"
| "workspaceAccess" | "maxConcurrentTasks"
>;
};
export function ContainerConfigTab({ data }: Props) {
const runtime = data.runtime || "unknown";
const workspaceAccess = formatAccess(data.workspaceAccess);
const maxConcurrentTasks = data.maxConcurrentTasks ? String(data.maxConcurrentTasks) : "platform-managed";
const mountedPath = "/workspace";
const privilegeStatus = "standard";
const deliveryMode = data.deliveryMode || "push";
return (
<div className="p-4 space-y-4">
<section className="rounded-lg border border-line/50 bg-surface-card/40 p-4">
<div className="mb-3">
<h3 className="text-sm font-semibold text-ink">Container Config</h3>
</div>
<dl className="grid grid-cols-1 gap-2 text-[11px]">
<ConfigRow label="Runtime image" value={runtimeDisplayName(runtime)} detail={runtime} />
<ConfigRow label="Workspace access" value={workspaceAccess} />
<ConfigRow label="Max concurrent tasks" value={maxConcurrentTasks} />
<ConfigRow label="Mounted workspace path" value={mountedPath} />
<ConfigRow label="Container privileges" value={privilegeStatus} />
<ConfigRow label="Delivery mode" value={deliveryMode} />
</dl>
</section>
<section className="rounded-lg border border-line/50 bg-surface-card/40 p-4">
<h3 className="mb-3 text-sm font-semibold text-ink">Session Controls</h3>
<div className="grid grid-cols-2 gap-2">
<ReadOnlyAction label={data.needsRestart ? "Restart required" : "Restart"} />
<ReadOnlyAction label="Reset session" />
</div>
</section>
<section className="rounded-lg border border-line/50 bg-surface-card/40 p-4">
<h3 className="mb-3 text-sm font-semibold text-ink">Status</h3>
<dl className="grid grid-cols-1 gap-2 text-[11px]">
<ConfigRow label="Container status" value={data.status} />
<ConfigRow label="Active tasks" value={String(data.activeTasks ?? 0)} />
<ConfigRow label="Mounted path access" value="available" />
</dl>
</section>
</div>
);
}
function formatAccess(value: string | null | undefined): string {
if (!value) return "none";
return value.replace(/_/g, "-");
}
function ConfigRow({
label,
value,
detail,
}: {
label: string;
value: string;
detail?: string;
}) {
return (
<div className="flex items-start justify-between gap-3 rounded-md bg-surface-sunken/40 px-3 py-2">
<dt className="text-ink-mid">{label}</dt>
<dd className="min-w-0 text-right">
<div className="font-mono text-ink break-words">{value}</div>
{detail && detail !== value && (
<div className="mt-0.5 font-mono text-[10px] text-ink-mid break-words">{detail}</div>
)}
</dd>
</div>
);
}
function ReadOnlyAction({ label }: { label: string }) {
return (
<button
type="button"
disabled
className="rounded-md border border-line/50 bg-surface-sunken/40 px-3 py-2 text-[11px] text-ink-mid disabled:cursor-not-allowed disabled:opacity-70"
>
{label}
</button>
);
}
-372
View File
@@ -1,372 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import type RFB from "@novnc/novnc";
interface DisplayStatus {
available: boolean;
reason?: string;
mode?: string;
status?: string;
protocol?: string;
width?: number;
height?: number;
}
interface DisplayControlStatus {
controller: "none" | "user" | "agent";
controlled_by?: string;
expires_at?: string;
session_url?: string;
}
interface Props {
workspaceId: string;
}
export function DisplayTab({ workspaceId }: Props) {
const [status, setStatus] = useState<DisplayStatus | null>(null);
const [control, setControl] = useState<DisplayControlStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [controlError, setControlError] = useState<string | null>(null);
const [controlBusy, setControlBusy] = useState(false);
const [sessionUrl, setSessionUrl] = useState<string | null>(null);
const requestGeneration = useRef(0);
useEffect(() => {
const generation = requestGeneration.current + 1;
requestGeneration.current = generation;
let cancelled = false;
setStatus(null);
setControl(null);
setSessionUrl(null);
setError(null);
setControlError(null);
setControlBusy(false);
async function load() {
try {
const displayStatus = await api.get<DisplayStatus>(`/workspaces/${workspaceId}/display`);
if (cancelled || requestGeneration.current !== generation) return;
setStatus(displayStatus);
if (displayStatus.reason === "display_not_enabled") return;
try {
const displayControl = await api.get<DisplayControlStatus>(`/workspaces/${workspaceId}/display/control`);
if (!cancelled && requestGeneration.current === generation) setControl(displayControl);
} catch (err) {
if (!cancelled && requestGeneration.current === generation) {
setControl(null);
setControlError("Display control unavailable");
}
}
} catch (err) {
if (!cancelled && requestGeneration.current === generation) setError("The display status could not be loaded.");
}
}
load();
return () => {
cancelled = true;
};
}, [workspaceId]);
const acquireControl = async () => {
const generation = requestGeneration.current;
const controlPath = `/workspaces/${workspaceId}/display/control`;
setControlBusy(true);
setControlError(null);
try {
const next = await api.post<DisplayControlStatus>(`${controlPath}/acquire`, {
controller: "user",
ttl_seconds: 300,
});
if (requestGeneration.current !== generation) return;
setControl(next);
setSessionUrl(next.session_url || null);
} catch (err) {
if (requestGeneration.current !== generation) return;
setControlError("Failed to take control");
try {
const latest = await api.get<DisplayControlStatus>(controlPath);
if (requestGeneration.current !== generation) return;
setControl(latest);
} catch {
if (requestGeneration.current !== generation) return;
setControl(null);
}
} finally {
if (requestGeneration.current === generation) setControlBusy(false);
}
};
const releaseControl = async () => {
const generation = requestGeneration.current;
const controlPath = `/workspaces/${workspaceId}/display/control`;
setControlBusy(true);
setControlError(null);
try {
const next = await api.post<DisplayControlStatus>(`${controlPath}/release`, {});
if (requestGeneration.current !== generation) return;
setControl(next);
setSessionUrl(null);
} catch (err) {
if (requestGeneration.current !== generation) return;
setControlError("Failed to release control");
try {
const latest = await api.get<DisplayControlStatus>(controlPath);
if (requestGeneration.current !== generation) return;
setControl(latest);
} catch {
if (requestGeneration.current !== generation) return;
setControl(null);
}
} finally {
if (requestGeneration.current === generation) setControlBusy(false);
}
};
if (error) {
return (
<div className="p-5">
<div className="rounded-lg border border-red-500/20 bg-red-950/20 p-4">
<h3 className="text-sm font-medium text-red-200">Display status unavailable</h3>
<p className="mt-2 text-[11px] leading-relaxed text-red-200/75">{error}</p>
</div>
</div>
);
}
if (!status) {
return (
<div className="p-5">
<div className="h-24 rounded-lg border border-line/40 bg-surface-sunken/30 motion-safe:animate-pulse" />
</div>
);
}
if (!status.available) {
const isNotEnabled = status.reason === "display_not_enabled";
return (
<div className="flex min-h-full flex-col items-center justify-center bg-surface-sunken/30 p-8 text-center">
<svg
width="72"
height="72"
viewBox="0 0 72 72"
fill="none"
aria-hidden="true"
className="mb-4 text-ink-mid"
>
<rect x="12" y="14" width="48" height="36" rx="4" stroke="currentColor" strokeWidth="2.5" opacity="0.65" />
<path d="M28 58h16M36 50v8M16 16l40 40" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
<h3 className="mb-1.5 text-sm font-medium text-ink">
{isNotEnabled ? "Display is not enabled for this workspace." : "Display session is not ready."}
</h3>
<p className="max-w-xs text-[11px] leading-relaxed text-ink-mid">
{isNotEnabled
? "Recreate this workspace with display enabled to view and take over its desktop."
: "This workspace has display configuration, but the desktop session infrastructure is not configured yet."}
</p>
{!isNotEnabled && (
<>
<dl className="mt-5 grid grid-cols-2 gap-x-4 gap-y-2 text-left text-[11px]">
<dt className="text-ink-mid">Mode</dt>
<dd className="font-mono text-ink">{status.mode || "unknown"}</dd>
<dt className="text-ink-mid">Status</dt>
<dd className="font-mono text-ink">{status.status || "unknown"}</dd>
</dl>
<div className="mt-5 w-full max-w-xs border-t border-line/50 pt-4">
{control ? (
<div className="flex items-center justify-between gap-3 text-left">
<div className="min-w-0">
<p className="text-[11px] font-medium text-ink">
{control.controller === "none"
? "No active controller"
: `Controlled by ${displayControlActorLabel(control)}`}
</p>
{control.expires_at && (
<p className="mt-1 truncate font-mono text-[10px] text-ink-mid">
Until {new Date(control.expires_at).toLocaleTimeString()}
</p>
)}
{controlError && <p className="mt-1 text-[10px] leading-snug text-red-200">{controlError}</p>}
</div>
{control.controller === "none" && (
<button
type="button"
onClick={acquireControl}
disabled={controlBusy}
className="h-8 shrink-0 rounded border border-line bg-surface px-3 text-[11px] font-medium text-ink hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-60"
>
Take control
</button>
)}
</div>
) : (
<div className="text-left">
{!controlError && (
<div className="h-8 rounded border border-line/40 bg-surface-sunken/30 motion-safe:animate-pulse" />
)}
{controlError && <p className="mt-2 text-[10px] leading-snug text-red-200">{controlError}</p>}
</div>
)}
</div>
</>
)}
</div>
);
}
return (
<div className="flex h-full min-h-[360px] flex-col bg-surface-sunken/30">
<div className="flex items-center justify-between gap-3 border-b border-line/50 px-4 py-3">
<div className="min-w-0">
<h3 className="text-sm font-medium text-ink">Desktop</h3>
<p className="mt-0.5 font-mono text-[10px] text-ink-mid">
{status.mode || "desktop-control"} · {status.protocol || "display"}
</p>
</div>
<DisplayControlBar
control={control}
controlBusy={controlBusy}
controlError={controlError}
hasSession={!!sessionUrl}
onAcquire={acquireControl}
onRelease={releaseControl}
/>
</div>
{sessionUrl ? (
<DesktopStream sessionUrl={sessionUrl} />
) : (
<div className="flex flex-1 items-center justify-center p-8 text-center">
<div>
<h3 className="mb-1.5 text-sm font-medium text-ink">Take control to open the desktop.</h3>
<p className="max-w-xs text-[11px] leading-relaxed text-ink-mid">
The display service is ready. Control access opens a short-lived desktop stream.
</p>
</div>
</div>
)}
</div>
);
}
function DisplayControlBar({
control,
controlBusy,
controlError,
hasSession,
onAcquire,
onRelease,
}: {
control: DisplayControlStatus | null;
controlBusy: boolean;
controlError: string | null;
hasSession: boolean;
onAcquire: () => void;
onRelease: () => void;
}) {
return (
<div className="flex min-w-0 items-center gap-3">
{control && (
<div className="min-w-0 text-right">
<p className="truncate text-[11px] font-medium text-ink">
{control.controller === "none"
? "No active controller"
: `Controlled by ${displayControlActorLabel(control)}`}
</p>
{control.expires_at && (
<p className="mt-0.5 truncate font-mono text-[10px] text-ink-mid">
Until {new Date(control.expires_at).toLocaleTimeString()}
</p>
)}
{controlError && <p className="mt-0.5 text-[10px] text-red-200">{controlError}</p>}
</div>
)}
{(control?.controller === "none" ||
(control?.controller === "user" && control.controlled_by === "admin-token" && !hasSession)) && (
<button
type="button"
onClick={onAcquire}
disabled={controlBusy}
className="h-8 shrink-0 rounded border border-line bg-surface px-3 text-[11px] font-medium text-ink hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-60"
>
Take control
</button>
)}
{control?.controller === "user" && control.controlled_by === "admin-token" && (
<button
type="button"
onClick={onRelease}
disabled={controlBusy}
className="h-8 shrink-0 rounded border border-line bg-surface px-3 text-[11px] font-medium text-ink hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-60"
>
Release
</button>
)}
</div>
);
}
function DesktopStream({ sessionUrl }: { sessionUrl: string }) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [streamError, setStreamError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
let rfb: RFB | null = null;
async function connect() {
setStreamError(null);
try {
const mod = await import("@novnc/novnc");
if (cancelled || !containerRef.current) return;
const stream = displayWebSocketConnection(sessionUrl);
rfb = new mod.default(containerRef.current, stream.url, {
wsProtocols: ["binary", `molecule-display-token.${stream.token}`],
});
rfb.scaleViewport = true;
rfb.resizeSession = true;
rfb.focusOnClick = true;
rfb.addEventListener("disconnect", (event: Event) => {
const detail = (event as CustomEvent<{ clean?: boolean }>).detail;
if (!cancelled && !detail?.clean) setStreamError("Desktop stream disconnected.");
});
} catch {
if (!cancelled) setStreamError("Desktop stream could not be opened.");
}
}
connect();
return () => {
cancelled = true;
rfb?.disconnect();
};
}, [sessionUrl]);
return (
<div className="relative min-h-0 flex-1 bg-black">
<div ref={containerRef} title="Workspace desktop" className="h-full w-full overflow-hidden bg-black" />
{streamError && (
<div className="absolute inset-x-4 top-4 rounded border border-red-500/30 bg-red-950/80 px-3 py-2 text-[11px] text-red-100">
{streamError}
</div>
)}
</div>
);
}
function displayWebSocketConnection(sessionUrl: string): { url: string; token: string } {
const url = new URL(sessionUrl, window.location.href);
const token = new URLSearchParams(url.hash.replace(/^#/, "")).get("token") ?? "";
if (!token) throw new Error("display session token missing");
url.hash = "";
url.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return { url: url.toString(), token };
}
function displayControlActorLabel(control: DisplayControlStatus): string {
if (control.controller === "agent") return "Agent";
if (control.controlled_by === "admin-token") return "Admin";
if (control.controlled_by?.startsWith("org-token:")) return "Automation";
return "User";
}
+471
View File
@@ -0,0 +1,471 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api";
interface Props {
workspaceId: string;
}
interface MemoryEntry {
key: string;
value: unknown;
version?: number;
expires_at: string | null;
updated_at: string;
}
const AWARENESS_BASE_URL =
process.env.NEXT_PUBLIC_AWARENESS_URL || "http://localhost:37800";
export function MemoryTab({ workspaceId }: Props) {
const [entries, setEntries] = useState<MemoryEntry[]>([]);
const [loading, setLoading] = useState(true);
const [showAwareness, setShowAwareness] = useState(true);
const [showAdvanced, setShowAdvanced] = useState(false);
const [expanded, setExpanded] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [newKey, setNewKey] = useState("");
const [newValue, setNewValue] = useState("");
const [newTTL, setNewTTL] = useState("");
const [error, setError] = useState<string | null>(null);
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const [editTTL, setEditTTL] = useState("");
const [editError, setEditError] = useState<string | null>(null);
const awarenessUrl = useMemo(() => {
try {
const url = new URL(AWARENESS_BASE_URL);
url.searchParams.set("workspaceId", workspaceId);
return url.toString();
} catch {
return AWARENESS_BASE_URL;
}
}, [workspaceId]);
const awarenessStatus = useMemo(() => {
try {
const url = new URL(AWARENESS_BASE_URL);
return url.origin.includes("localhost") ? "local" : url.hostname;
} catch {
return "unavailable";
}
}, []);
const loadMemory = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await api.get<MemoryEntry[]>(`/workspaces/${workspaceId}/memory`);
setEntries(data);
} catch (e) {
setEntries([]);
setError(e instanceof Error ? e.message : "Failed to load memory");
} finally {
setLoading(false);
}
}, [workspaceId]);
useEffect(() => {
loadMemory();
}, [loadMemory]);
const handleAdd = async () => {
setError(null);
if (!newKey.trim()) {
setError("Key is required");
return;
}
let parsedValue: unknown;
try {
parsedValue = JSON.parse(newValue);
} catch {
parsedValue = newValue;
}
const body: Record<string, unknown> = { key: newKey, value: parsedValue };
if (newTTL) {
const ttl = parseInt(newTTL);
if (!Number.isNaN(ttl) && ttl > 0) body.ttl_seconds = ttl;
}
try {
await api.post(`/workspaces/${workspaceId}/memory`, body);
setNewKey("");
setNewValue("");
setNewTTL("");
setShowAdd(false);
loadMemory();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to add");
}
};
const handleDelete = async (key: string) => {
setError(null);
try {
await api.del(`/workspaces/${workspaceId}/memory/${encodeURIComponent(key)}`);
setEntries((prev) => prev.filter((e) => e.key !== key));
if (expanded === key) setExpanded(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to delete entry");
}
};
const beginEdit = (entry: MemoryEntry) => {
setEditError(null);
setEditingKey(entry.key);
// Stringify objects/arrays as pretty JSON; render plain strings raw so the
// editor doesn't surprise users with surrounding quotes.
setEditValue(
typeof entry.value === "string"
? entry.value
: JSON.stringify(entry.value, null, 2),
);
if (entry.expires_at) {
const remainingMs = new Date(entry.expires_at).getTime() - Date.now();
const ttl = Math.max(0, Math.floor(remainingMs / 1000));
setEditTTL(ttl > 0 ? String(ttl) : "");
} else {
setEditTTL("");
}
};
const cancelEdit = () => {
setEditingKey(null);
setEditValue("");
setEditTTL("");
setEditError(null);
};
const handleEditSave = async (entry: MemoryEntry) => {
setEditError(null);
let parsedValue: unknown;
try {
parsedValue = JSON.parse(editValue);
} catch {
parsedValue = editValue;
}
// if_match_version closes the silent-overwrite hole when two writers
// race. The handler returns 409 with the current version on mismatch
// — surface that as a retry hint and reload to pick up the new state.
const body: Record<string, unknown> = { key: entry.key, value: parsedValue };
if (typeof entry.version === "number") {
body.if_match_version = entry.version;
}
if (editTTL) {
const ttl = parseInt(editTTL);
if (!Number.isNaN(ttl) && ttl > 0) body.ttl_seconds = ttl;
}
try {
await api.post(`/workspaces/${workspaceId}/memory`, body);
cancelEdit();
loadMemory();
} catch (e) {
const message = e instanceof Error ? e.message : "Failed to save";
if (message.includes("409") || /if_match_version mismatch/i.test(message)) {
setEditError("This entry changed since you opened it. Reloading.");
loadMemory();
} else {
setEditError(message);
}
}
};
const openAwareness = () => {
window.open(awarenessUrl, "_blank", "noopener,noreferrer");
};
if (loading) {
return <div className="p-4 text-xs text-ink-mid">Loading memory...</div>;
}
return (
<div className="p-4 space-y-4">
{error && !showAdd && (
<div role="alert" className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
{error}
</div>
)}
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-xs font-medium text-ink">Awareness dashboard</div>
<p className="text-[10px] text-ink-mid">
Embedded view for the local Awareness memory UI. The current workspace id is appended to the URL for workspace-scoped routing or future filtering.
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setShowAwareness((prev) => !prev)}
className="shrink-0 px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
{showAwareness ? "Collapse" : "Expand"}
</button>
<button
type="button"
onClick={openAwareness}
className="shrink-0 px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
Open
</button>
</div>
</div>
{showAwareness ? (
AWARENESS_BASE_URL ? (
<div className="overflow-hidden rounded-xl border border-line bg-surface-sunken/70 shadow-[0_0_0_1px_rgba(255,255,255,0.02)]">
<iframe
title="Awareness dashboard"
src={awarenessUrl}
className="h-[520px] w-full border-0"
loading="lazy"
/>
</div>
) : (
<div className="rounded-xl border border-dashed border-line bg-surface-sunken/40 p-4 text-xs text-ink-mid">
Set <code className="font-mono text-ink-mid">NEXT_PUBLIC_AWARENESS_URL</code> to embed the Awareness dashboard here.
</div>
)
) : (
<div className="rounded-xl border border-line bg-surface-sunken/50 px-4 py-3 flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-xs text-ink">Awareness dashboard is collapsed</p>
<p className="text-[10px] text-ink-mid truncate">
Workspace context stays linked through <span className="font-mono text-ink-mid">{workspaceId}</span>.
</p>
</div>
<button
type="button"
onClick={() => setShowAwareness(true)}
className="shrink-0 px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
Expand
</button>
</div>
)}
<div className="grid gap-2 rounded-xl border border-line bg-surface/40 px-3 py-2 text-[10px] text-ink-mid sm:grid-cols-3">
<div className="flex items-center justify-between gap-2">
<span className="uppercase tracking-[0.18em] text-ink-mid">Status</span>
<span className="font-medium text-good">Connected</span>
</div>
<div className="flex items-center justify-between gap-2">
<span className="uppercase tracking-[0.18em] text-ink-mid">Mode</span>
<span className="font-medium text-ink">{awarenessStatus}</span>
</div>
<div className="flex items-center justify-between gap-2 min-w-0">
<span className="uppercase tracking-[0.18em] text-ink-mid">Workspace</span>
<span className="font-mono text-ink-mid truncate">{workspaceId}</span>
</div>
</div>
</section>
<section className="space-y-3 border-t border-line/60 pt-4">
<div className="flex items-center justify-between">
<div>
<div className="text-xs font-medium text-ink">Workspace KV memory</div>
<p className="text-[10px] text-ink-mid">
Native platform key-value memory for workspace <span className="font-mono text-ink-mid">{workspaceId}</span>.
</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => setShowAdvanced((prev) => !prev)}
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
{showAdvanced ? "Hide Advanced" : "Advanced"}
</button>
<button
type="button"
onClick={loadMemory}
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
Refresh
</button>
<button
type="button"
onClick={() => { setShowAdd(!showAdd); if (!showAdd) setShowAdvanced(true); }}
className="px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
+ Add
</button>
</div>
</div>
{showAdvanced && showAdd && (
<div className="bg-surface-card rounded p-3 space-y-2 border border-line">
<input
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
placeholder="Key"
aria-label="Memory key"
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-xs text-ink focus:outline-none focus:border-accent"
/>
<textarea
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
placeholder='Value (JSON or plain text)'
rows={3}
aria-label="Memory value (JSON or plain text)"
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-xs font-mono text-ink focus:outline-none focus:border-accent resize-none"
/>
<input
value={newTTL}
onChange={(e) => setNewTTL(e.target.value)}
placeholder="TTL in seconds (optional)"
aria-label="TTL in seconds (optional)"
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-xs text-ink focus:outline-none focus:border-accent"
/>
{error && <div role="alert" className="text-xs text-bad">{error}</div>}
<div className="flex gap-2">
<button
type="button"
onClick={handleAdd}
className="px-3 py-1 bg-accent hover:bg-accent-strong text-xs rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
Save
</button>
<button
type="button"
onClick={() => {
setShowAdd(false);
setError(null);
}}
className="px-3 py-1 bg-surface-card hover:bg-surface-elevated text-xs rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
Cancel
</button>
</div>
</div>
)}
{showAdvanced ? (
entries.length === 0 ? (
<p className="text-xs text-ink-mid text-center py-4">No memory entries</p>
) : (
<div className="space-y-1">
{entries.map((entry) => (
<div key={entry.key} className="bg-surface-card rounded border border-line">
<button
type="button"
onClick={() => setExpanded(expanded === entry.key ? null : entry.key)}
className="w-full flex items-center justify-between px-3 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
aria-expanded={expanded === entry.key}
>
<span className="text-xs font-mono text-accent">{entry.key}</span>
<div className="flex items-center gap-2">
{entry.expires_at && (
<span className="text-[9px] text-ink-mid">
TTL {new Date(entry.expires_at).toLocaleString()}
</span>
)}
<span className="text-[10px] text-ink-mid">
{expanded === entry.key ? "▼" : "▶"}
</span>
</div>
</button>
{expanded === entry.key && (
<div className="px-3 pb-2 space-y-2">
{editingKey === entry.key ? (
<div className="space-y-2">
<textarea
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
rows={4}
aria-label={`Edit value for ${entry.key}`}
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-xs font-mono text-ink focus:outline-none focus:border-accent resize-none"
/>
<input
value={editTTL}
onChange={(e) => setEditTTL(e.target.value)}
placeholder="TTL in seconds (blank = no expiry)"
aria-label={`Edit TTL for ${entry.key}`}
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-xs text-ink focus:outline-none focus:border-accent"
/>
{editError && (
<div role="alert" className="text-[10px] text-bad">
{editError}
</div>
)}
<div className="flex gap-2">
<button
type="button"
onClick={() => handleEditSave(entry)}
className="px-3 py-1 bg-accent hover:bg-accent-strong text-xs rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
Save
</button>
<button
type="button"
onClick={cancelEdit}
className="px-3 py-1 bg-surface-card hover:bg-surface-elevated text-xs rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
Cancel
</button>
</div>
</div>
) : (
<pre className="text-[10px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-40">
{JSON.stringify(entry.value, null, 2)}
</pre>
)}
<div className="flex items-center justify-between">
<span className="text-[9px] text-ink-mid">
Updated: {new Date(entry.updated_at).toLocaleString()}
</span>
<div className="flex items-center gap-2">
{editingKey !== entry.key && (
<button
type="button"
onClick={() => beginEdit(entry)}
className="text-[10px] text-ink-mid hover:bg-surface-elevated rounded px-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
Edit
</button>
)}
<button
type="button"
onClick={() => handleDelete(entry.key)}
className="text-[10px] text-bad hover:bg-red-950/40 rounded px-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
))}
</div>
)
) : (
<div className="rounded-xl border border-line bg-surface/30 px-4 py-3 flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-xs text-ink">Advanced workspace memory is hidden</p>
<p className="text-[10px] text-ink-mid truncate">
KV entries remain available if you need the raw platform store.
</p>
</div>
<button
type="button"
onClick={() => setShowAdvanced(true)}
className="shrink-0 px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
Show
</button>
</div>
)}
</section>
</div>
);
}
@@ -1,42 +0,0 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/runtime-names", () => ({
runtimeDisplayName: (runtime: string) => runtime,
}));
import { ContainerConfigTab } from "../ContainerConfigTab";
afterEach(() => {
cleanup();
});
describe("ContainerConfigTab", () => {
it("renders read-only runtime and container settings separate from compute shape", () => {
render(
<ContainerConfigTab
data={{
runtime: "claude-code",
status: "online",
needsRestart: false,
activeTasks: 2,
maxConcurrentTasks: 3,
workspaceAccess: "read_write",
deliveryMode: "poll",
}}
/>,
);
expect(screen.getByText("Runtime image")).toBeTruthy();
expect(screen.getByText("claude-code")).toBeTruthy();
expect(screen.getByText("Workspace access")).toBeTruthy();
expect(screen.getByText("read-write")).toBeTruthy();
expect(screen.getByText("Max concurrent tasks")).toBeTruthy();
expect(screen.getByText("3")).toBeTruthy();
expect(screen.getByText("/workspace")).toBeTruthy();
expect(screen.getByText("Container privileges")).toBeTruthy();
expect(screen.queryByText("Instance type")).toBeNull();
expect(screen.queryByText("Root volume")).toBeNull();
});
});
@@ -1,366 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
const { mockGet, mockPost, mockRFBConstructor } = vi.hoisted(() => ({
mockGet: vi.fn(),
mockPost: vi.fn(),
mockRFBConstructor: vi.fn(),
}));
vi.mock("@/lib/api", () => ({
api: {
get: mockGet,
post: mockPost,
},
}));
vi.mock("@novnc/novnc", () => ({
default: class MockRFB extends EventTarget {
scaleViewport = false;
resizeSession = false;
focusOnClick = false;
target: HTMLElement;
url: string;
options?: { wsProtocols?: string[] };
constructor(target: HTMLElement, url: string, options?: { wsProtocols?: string[] }) {
super();
this.target = target;
this.url = url;
this.options = options;
mockRFBConstructor(target, url, options);
}
disconnect() {}
},
}));
import { DisplayTab } from "../DisplayTab";
describe("DisplayTab", () => {
beforeEach(() => {
cleanup();
mockGet.mockReset();
mockPost.mockReset();
mockRFBConstructor.mockReset();
});
it("renders unavailable state for non-display workspaces", async () => {
mockGet.mockResolvedValueOnce({
available: false,
reason: "display_not_enabled",
});
render(<DisplayTab workspaceId="ws-no-display" />);
await waitFor(() => {
expect(screen.getByText("Display is not enabled for this workspace.")).toBeTruthy();
});
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-no-display/display");
expect(mockGet).not.toHaveBeenCalledWith("/workspaces/ws-no-display/display/control");
});
it("renders control acquisition for display-configured workspaces", async () => {
mockGet
.mockResolvedValueOnce({
available: false,
reason: "display_session_unavailable",
mode: "desktop-control",
status: "not_configured",
})
.mockResolvedValueOnce({
controller: "none",
});
mockPost.mockResolvedValueOnce({
controller: "user",
controlled_by: "admin-token",
expires_at: "2026-05-23T08:48:27Z",
});
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
});
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-display/display");
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-display/display/control");
fireEvent.click(screen.getByRole("button", { name: "Take control" }));
await waitFor(() => {
expect(screen.getByText("Controlled by Admin")).toBeTruthy();
});
expect(mockPost).toHaveBeenCalledWith("/workspaces/ws-display/display/control/acquire", {
controller: "user",
ttl_seconds: 300,
});
});
it("waits for takeover before opening a ready display stream", async () => {
mockGet
.mockResolvedValueOnce({
available: true,
mode: "desktop-control",
protocol: "novnc",
width: 1920,
height: 1080,
})
.mockResolvedValueOnce({
controller: "none",
});
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByText("Take control to open the desktop.")).toBeTruthy();
});
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
});
it("opens the trusted noVNC client after takeover returns a stream URL", async () => {
mockGet
.mockResolvedValueOnce({
available: true,
mode: "desktop-control",
protocol: "novnc",
width: 1920,
height: 1080,
})
.mockResolvedValueOnce({
controller: "none",
});
mockPost.mockResolvedValueOnce({
controller: "user",
controlled_by: "admin-token",
expires_at: "2026-05-23T08:48:27Z",
session_url: "/workspaces/ws-display/display/session/websockify#token=signed",
});
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Take control" }));
await waitFor(() => {
expect(screen.getByTitle("Workspace desktop")).toBeTruthy();
});
expect(mockPost).toHaveBeenCalledWith("/workspaces/ws-display/display/control/acquire", {
controller: "user",
ttl_seconds: 300,
});
expect(mockRFBConstructor).toHaveBeenCalledWith(
expect.any(HTMLElement),
expect.stringContaining("/workspaces/ws-display/display/session/websockify"),
{ wsProtocols: ["binary", "molecule-display-token.signed"] },
);
expect(mockRFBConstructor.mock.calls[0][1]).not.toContain("token=");
});
it("releases user display control", async () => {
mockGet
.mockResolvedValueOnce({
available: true,
mode: "desktop-control",
protocol: "novnc",
})
.mockResolvedValueOnce({
controller: "user",
controlled_by: "admin-token",
expires_at: "2026-05-23T08:48:27Z",
});
mockPost.mockResolvedValueOnce({
controller: "none",
});
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Release" })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Release" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
});
expect(mockPost).toHaveBeenCalledWith("/workspaces/ws-display/display/control/release", {});
});
it("renders active display control locks as observe-only", async () => {
mockGet
.mockResolvedValueOnce({
available: false,
reason: "display_session_unavailable",
mode: "desktop-control",
status: "not_configured",
})
.mockResolvedValueOnce({
controller: "agent",
controlled_by: "sidecar",
expires_at: "2026-05-23T08:48:27Z",
});
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByText("Controlled by Agent")).toBeTruthy();
});
expect(screen.queryByRole("button", { name: "Release" })).toBeNull();
expect(screen.queryByRole("button", { name: "Take control" })).toBeNull();
expect(mockPost).not.toHaveBeenCalled();
});
it("labels org-token display control locks as automation", async () => {
mockGet
.mockResolvedValueOnce({
available: false,
reason: "display_session_unavailable",
mode: "desktop-control",
status: "not_configured",
})
.mockResolvedValueOnce({
controller: "user",
controlled_by: "org-token:abc123",
expires_at: "2026-05-23T08:48:27Z",
});
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByText("Controlled by Automation")).toBeTruthy();
});
expect(screen.queryByText("org-token:abc123")).toBeNull();
expect(screen.queryByRole("button", { name: "Take control" })).toBeNull();
});
it("refreshes display control state after failed acquisition", async () => {
mockGet
.mockResolvedValueOnce({
available: false,
reason: "display_session_unavailable",
mode: "desktop-control",
status: "not_configured",
})
.mockResolvedValueOnce({
controller: "none",
})
.mockResolvedValueOnce({
controller: "agent",
controlled_by: "sidecar",
expires_at: "2026-05-23T08:48:27Z",
});
mockPost.mockRejectedValueOnce(new Error("API POST /workspaces/ws-display/display/control/acquire: 409 conflict"));
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Take control" }));
await waitFor(() => {
expect(screen.getByText("Controlled by Agent")).toBeTruthy();
});
expect(screen.getByText("Failed to take control")).toBeTruthy();
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-display/display/control");
expect(mockGet).toHaveBeenCalledTimes(3);
expect(mockPost).toHaveBeenCalledWith("/workspaces/ws-display/display/control/acquire", {
controller: "user",
ttl_seconds: 300,
});
});
it("keeps display status visible without takeover actions when control status fails", async () => {
mockGet
.mockResolvedValueOnce({
available: false,
reason: "display_session_unavailable",
mode: "desktop-control",
status: "not_configured",
})
.mockRejectedValueOnce(new Error("API GET /workspaces/ws-display/display/control: 401 unauthorized"));
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByText("Display session is not ready.")).toBeTruthy();
});
expect(screen.queryByRole("button", { name: "Take control" })).toBeNull();
expect(screen.getByText("Display control unavailable")).toBeTruthy();
});
it("does not render raw display status errors", async () => {
mockGet.mockRejectedValueOnce(new Error("API GET /workspaces/ws-display/display: 500 secret backend details"));
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByText("Display status unavailable")).toBeTruthy();
});
expect(screen.queryByText(/secret backend details/)).toBeNull();
});
it("ignores stale acquire responses after workspace changes", async () => {
const acquire = deferred<{ controller: "user"; controlled_by: string; expires_at: string }>();
mockGet
.mockResolvedValueOnce({
available: false,
reason: "display_session_unavailable",
mode: "desktop-control",
status: "not_configured",
})
.mockResolvedValueOnce({
controller: "none",
})
.mockResolvedValueOnce({
available: false,
reason: "display_session_unavailable",
mode: "desktop-control",
status: "not_configured",
})
.mockResolvedValueOnce({
controller: "none",
});
mockPost.mockReturnValueOnce(acquire.promise);
const { rerender } = render(<DisplayTab workspaceId="ws-a" />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Take control" }));
rerender(<DisplayTab workspaceId="ws-b" />);
await waitFor(() => {
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-b/display/control");
});
await waitFor(() => {
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
});
acquire.resolve({
controller: "user",
controlled_by: "admin-token",
expires_at: "2026-05-23T08:48:27Z",
});
await acquire.promise;
await waitFor(() => {
expect(screen.queryByText("Controlled by Admin")).toBeNull();
});
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
});
});
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
@@ -0,0 +1,632 @@
// @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");
});
});
-2
View File
@@ -513,8 +513,6 @@ export function buildNodesAndEdges(
parentId: ws.parent_id,
currentTask: ws.current_task || "",
runtime: ws.runtime || "",
workspaceAccess: ws.workspace_access,
maxConcurrentTasks: ws.max_concurrent_tasks ?? null,
needsRestart: false,
budgetLimit: ws.budget_limit ?? null,
budgetUsed: ws.budget_used ?? null,
+1 -3
View File
@@ -88,8 +88,6 @@ export interface WorkspaceNodeData extends Record<string, unknown> {
parentId: string | null;
currentTask: string;
runtime: string;
workspaceAccess?: string | null;
maxConcurrentTasks?: number | null;
needsRestart: boolean;
/** USD spend ceiling set by the user; null = unlimited. Added by issue #541. */
budgetLimit: number | null;
@@ -132,7 +130,7 @@ export interface WorkspaceNodeData extends Record<string, unknown> {
deliveryMode?: string;
}
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "display" | "container-config" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit";
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit";
export interface ContextMenuState {
x: number;
-2
View File
@@ -320,13 +320,11 @@ export interface WorkspaceData {
url: string;
parent_id: string | null;
active_tasks: number;
max_concurrent_tasks?: number | null;
last_error_rate: number;
last_sample_error: string;
uptime_seconds: number;
current_task: string;
runtime: string;
workspace_access?: string | null;
x: number;
y: number;
collapsed: boolean;
-9
View File
@@ -1,9 +0,0 @@
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
View File
@@ -90,6 +90,8 @@ 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 |
+1 -1
View File
@@ -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`, `workspace_dir` |
| `workspaces` | Core entity — status, runtime, `agent_card` JSONB, heartbeat columns, `current_task`, `awareness_namespace`, `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. |
+3 -1
View File
@@ -28,7 +28,9 @@
{"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": "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"}
],
"org_templates": [
{"name": "molecule-dev", "repo": "molecule-ai/molecule-ai-org-template-molecule-dev", "ref": "main"},
+2 -5
View File
@@ -50,16 +50,13 @@ docker rm $(docker ps -aq --filter "name=ws-") 2>/dev/null || true
echo ""
echo "--- Create Workspaces ---"
# model is required at the Create boundary (CTO 2026-05-22 SSOT —
# feedback_workspace_model_required_no_platform_default_dynamic_credential_intake).
# Pass the same value the deleted DefaultModel("claude-code") returned.
ROOT=$(curl -s -X POST $PLATFORM/workspaces -H "Content-Type: application/json" \
-d '{"name":"Root Agent","role":"Company coordinator","runtime":"claude-code","model":"sonnet","tier":3}' \
-d '{"name":"Root Agent","role":"Company coordinator","runtime":"claude-code","tier":3}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
check_contains "Create root workspace" "-" "$ROOT"
CHILD=$(curl -s -X POST $PLATFORM/workspaces -H "Content-Type: application/json" \
-d "{\"name\":\"Child Agent\",\"role\":\"Sub-team member\",\"runtime\":\"claude-code\",\"model\":\"sonnet\",\"tier\":2,\"parent_id\":\"$ROOT\"}" \
-d "{\"name\":\"Child Agent\",\"role\":\"Sub-team member\",\"runtime\":\"claude-code\",\"tier\":2,\"parent_id\":\"$ROOT\"}" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
check_contains "Create child workspace" "-" "$CHILD"
+1 -5
View File
@@ -92,12 +92,8 @@ for _wid in $PRIOR; do
curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" > /dev/null || true
done
# model is required at the Create boundary (CTO 2026-05-22 SSOT — see
# feedback_workspace_model_required_no_platform_default_dynamic_credential_intake).
# Body had no runtime → defaults to langgraph; pass the langgraph-compatible
# default that the deleted DefaultModel("") would have returned.
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d '{"name":"Notify E2E","tier":1,"model":"anthropic:claude-opus-4-7"}')
-d '{"name":"Notify E2E","tier":1}')
WSID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
[ -n "$WSID" ] || { echo "Failed to create workspace: $R"; exit 1; }
echo "Created workspace $WSID"
+22 -51
View File
@@ -24,12 +24,14 @@
#
# Only PROVISIONING differs from staging:
# - staging: POST /cp/admin/orgs (cold EC2 tenant) + per-tenant admin
# token + each workspace's MCP bearer from the POST /workspaces
# create response.
# token + each workspace's MCP bearer from create response or an admin
# token-mint fallback.
# - local: POST /workspaces directly against the local stack
# (BASE, default http://localhost:8080), MCP bearer consumed inline
# from the create response (auth_token field). Same model every
# other local E2E uses; no new credential/provision flow.
# (BASE, default http://localhost:8080), MCP bearer minted via
# GET /admin/workspaces/:id/test-token (e2e_mint_test_token —
# deterministic, gated by MOLECULE_ENV != production). Same model
# every other local E2E (test_priority_runtimes_e2e.sh,
# test_api.sh) already uses; no new credential/provision flow.
#
# By default the local backend creates external-mode workspace rows and
# drives the literal MCP path directly. That keeps the local peer-visibility
@@ -79,17 +81,6 @@ NAME_PREFIX="PV-Local-$$-$(date +%H%M%S)"
log() { echo "[$(date +%H:%M:%S)] $*"; }
ok() { echo "[$(date +%H:%M:%S)] ✅ $*"; }
extract_auth_token() {
python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
except Exception:
print(''); sys.exit(0)
print(d.get('auth_token') or d.get('connection', {}).get('auth_token') or '')
" 2>/dev/null
}
CREATED_WSIDS=()
ADMIN_BEARER="${MOLECULE_ADMIN_TOKEN:-${ADMIN_TOKEN:-}}"
ADMIN_AUTH=()
@@ -140,6 +131,17 @@ if ! curl -fsS "$BASE/health" -m 5 >/dev/null 2>&1; then
echo "::error::Local stack not healthy at $BASE/health — bring it up (make up) before this gate. Infra, not a workspace bug (feedback_fix_root_not_symptom)." >&2
exit 1
fi
# admin/test-token is the local MCP-bearer mint path; it 404s in
# production. If it is off, this gate cannot drive the literal call.
if ! curl -fsS "$BASE/admin/workspaces/preflight-probe/test-token" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} -m 5 >/dev/null 2>&1; then
# A 404 here is EITHER "no such ws" (fine — endpoint is enabled) OR the
# endpoint is disabled (MOLECULE_ENV=production). Distinguish by body.
PROBE=$(curl -s "$BASE/admin/workspaces/preflight-probe/test-token" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} -m 5 2>/dev/null)
if echo "$PROBE" | grep -qi 'production\|disabled\|not found.*endpoint'; then
echo "::error::GET /admin/workspaces/:id/test-token disabled (MOLECULE_ENV=production?). Cannot mint a local MCP bearer." >&2
exit 1
fi
fi
ok " local stack healthy"
# ─── Resolve per-runtime provisioning secrets ──────────────────────────
@@ -239,31 +241,9 @@ else
fi
log "1/5 provisioning parent ($PARENT_RUNTIME, mode=$PV_LOCAL_PROVISION_MODE) + one sibling per runtime under test..."
# Map runtime → model per the CTO 2026-05-22 SSOT directive (model is
# required, no platform default). External runtimes are exempt by the
# Create-handler gate — for them the URL is the contract — but we still
# pass model="external:custom" defensively in case a downstream consumer
# of the create body asserts presence.
_model_for_runtime() {
case "$1" in
claude-code) echo "sonnet" ;;
codex) echo "gpt-5.5" ;;
kimi) echo "kimi-coding/kimi-k2-coding-6" ;;
minimax) echo "minimax/MiniMax-M2.7" ;;
external) echo "external:custom" ;;
*) echo "anthropic:claude-opus-4-7" ;;
esac
}
PARENT_MODEL=$(_model_for_runtime "$PARENT_RUNTIME")
P_RESP=$(curl -s -X POST "$BASE/workspaces" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} -H "Content-Type: application/json" \
-d "{\"name\":\"${NAME_PREFIX}-parent\",\"runtime\":\"$PARENT_RUNTIME\",\"model\":\"$PARENT_MODEL\",\"tier\":3$PARENT_EXTRA,\"secrets\":$PARENT_SECRETS}")
-d "{\"name\":\"${NAME_PREFIX}-parent\",\"runtime\":\"$PARENT_RUNTIME\",\"tier\":3$PARENT_EXTRA,\"secrets\":$PARENT_SECRETS}")
PARENT_ID=$(echo "$P_RESP" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))' 2>/dev/null)
# PARENT_TOKEN captured for symmetry with the per-sibling auth-token
# capture in the runtime loop below + reserved for follow-up steps
# that need parent-side auth. Current downstream steps reach the parent
# via admin token, so the variable isn't dereferenced — SC2034.
# shellcheck disable=SC2034 # captured for downstream parent-auth use; see #1644 follow-up
PARENT_TOKEN=$(echo "$P_RESP" | extract_auth_token)
if [ -z "$PARENT_ID" ]; then
echo "::error::parent create failed: $(echo "$P_RESP" | head -c 300)" >&2
exit 1
@@ -279,8 +259,6 @@ log " PARENT_ID=$PARENT_ID runtime=$PARENT_RUNTIME"
WS_IDS_MAP=""
# shellcheck disable=SC2034 # map values are updated through portable eval-based helpers.
VERDICT_MAP=""
# shellcheck disable=SC2034 # map values are updated through portable eval-based helpers.
WS_TOKENS_MAP=""
_map_set() { # _map_set <mapvarname> <key> <value>
local __m="$1" __k="$2" __v="$3" __cur
eval "__cur=\$$__m"
@@ -313,21 +291,14 @@ for rt in $PV_RUNTIMES; do
CREATE_RUNTIME="$rt"
CREATE_EXTRA=""
fi
CREATE_MODEL=$(_model_for_runtime "$CREATE_RUNTIME")
R=$(curl -s -X POST "$BASE/workspaces" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} -H "Content-Type: application/json" \
-d "{\"name\":\"${NAME_PREFIX}-$rt\",\"runtime\":\"$CREATE_RUNTIME\",\"model\":\"$CREATE_MODEL\",\"tier\":2,\"parent_id\":\"$PARENT_ID\"$CREATE_EXTRA,\"secrets\":$SEC}")
-d "{\"name\":\"${NAME_PREFIX}-$rt\",\"runtime\":\"$CREATE_RUNTIME\",\"tier\":2,\"parent_id\":\"$PARENT_ID\"$CREATE_EXTRA,\"secrets\":$SEC}")
WID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))' 2>/dev/null)
WTOK=$(echo "$R" | extract_auth_token)
if [ -z "$WID" ]; then
echo "::error::$rt workspace create failed: $(echo "$R" | head -c 300)" >&2
exit 1
fi
if [ -z "$WTOK" ]; then
echo "::error::$rt workspace create did not return an auth_token — cannot drive the literal MCP call" >&2
exit 1
fi
_map_set WS_IDS_MAP "$rt" "$WID"
_map_set WS_TOKENS_MAP "$rt" "$WTOK"
CREATED_WSIDS+=("$WID")
ALL_WS_IDS="$ALL_WS_IDS $WID"
ACTIVE_RUNTIMES="$ACTIVE_RUNTIMES $rt"
@@ -385,10 +356,10 @@ log "4/5 driving the LITERAL list_peers MCP call per online runtime..."
echo ""
for rt in $ONLINE_RUNTIMES; do
wid="$(_map_get WS_IDS_MAP "$rt")"
WTOK="$(_map_get WS_TOKENS_MAP "$rt")"
WTOK=$(e2e_mint_test_token "$wid" 2>/dev/null || true)
if [ -z "$WTOK" ]; then
echo "--- $rt (ws=$wid) ---"
echo "$rt: workspace create did not return an auth_token — cannot drive the literal call"
echo "$rt: could not mint a local MCP bearer (admin/test-token) — cannot drive the literal call"
_map_set VERDICT_MAP "$rt" "FAIL(no-bearer)"
REGRESSED=1
echo ""
+31 -6
View File
@@ -40,10 +40,10 @@
# drives: POST /cp/admin/orgs (provision), GET
# /cp/admin/orgs/:slug/admin-token (per-tenant token), DELETE
# /cp/admin/tenants/:slug (teardown). The per-tenant admin token drives
# tenant workspace creation; each workspace's OWN auth_token is consumed
# inline from the POST /workspaces 201 response to drive its MCP call.
# No dev-only admin token-mint routes are used in this E2E
# (feedback_no_dev_only_routes_in_e2e).
# tenant workspace creation; each workspace's OWN auth_token drives its
# MCP call. External-like runtimes may return the token in POST
# /workspaces; managed container runtimes usually require the admin token
# mint fallback below.
#
# Required env:
# MOLECULE_ADMIN_TOKEN CP admin bearer — Railway staging CP_ADMIN_API_TOKEN
@@ -265,19 +265,44 @@ log " PARENT_ID=$PARENT_ID"
# WS_IDS[runtime]=id ; WS_TOKENS[runtime]=auth_token (the MCP bearer)
declare -A WS_IDS WS_TOKENS
ALL_WS_IDS="$PARENT_ID"
TOKEN_ERRORS=0
TOKEN_ERROR_SUMMARY=""
for rt in $PV_RUNTIMES; do
R=$(tenant_call POST /workspaces \
-d "{\"name\":\"pv-$rt\",\"runtime\":\"$rt\",\"tier\":2,\"parent_id\":\"$PARENT_ID\",\"secrets\":$SECRETS_JSON}")
WID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
# External-like runtimes may return connection.auth_token on create.
# Managed container runtimes usually return only id/status here, then
# receive their bearer through registry/bootstrap; for this literal MCP
# driver we mint through the production-safe admin token route below.
WTOK=$(echo "$R" | extract_auth_token)
[ -n "$WID" ] || fail "$rt workspace create failed: $(echo \"$R\" | head -c 300)"
[ -n "$WTOK" ] || fail "$rt workspace create did not return an auth_token — cannot drive its MCP call (workspace_id=$WID; create_resp: $(echo \"$R\" | redact_token_body))"
[ -n "$WID" ] || fail "$rt workspace create failed: $(echo "$R" | head -c 300)"
TOKEN_DIAG=""
if [ -z "$WTOK" ]; then
TTOK_FILE=$(mktemp)
TTOK_CODE=$(tenant_call_capture POST "/admin/workspaces/$WID/tokens" "$TTOK_FILE" 2>/dev/null || echo "curl_error")
TTOK_RESP=$(cat "$TTOK_FILE" 2>/dev/null || true)
WTOK=$(echo "$TTOK_RESP" | extract_auth_token)
TOKEN_DIAG="POST /admin/workspaces/$WID/tokens -> HTTP $TTOK_CODE body: $(echo "$TTOK_RESP" | redact_token_body)"
rm -f "$TTOK_FILE"
fi
WS_IDS[$rt]="$WID"
if [ -z "$WTOK" ]; then
TOKEN_ERRORS=$((TOKEN_ERRORS + 1))
TOKEN_ERROR_SUMMARY="${TOKEN_ERROR_SUMMARY}
[$rt] workspace did not return or mint an auth_token — cannot drive its MCP call (workspace_id=$WID; create_resp: $(echo "$R" | redact_token_body); token_fallbacks: $TOKEN_DIAG)"
log " $rt$WID (token acquisition failed; continuing to classify other runtimes)"
continue
fi
WS_TOKENS[$rt]="$WTOK"
ALL_WS_IDS="$ALL_WS_IDS $WID"
log " $rt$WID"
done
if [ "$TOKEN_ERRORS" -gt 0 ]; then
fail "token acquisition failed for $TOKEN_ERRORS runtime(s):$TOKEN_ERROR_SUMMARY"
fi
if [ "${PV_TOKEN_DIAGNOSTIC_ONLY:-0}" = "1" ]; then
ok "token diagnostic passed for runtimes: $PV_RUNTIMES"
exit 0
+2 -4
View File
@@ -188,9 +188,8 @@ import json, os
print(json.dumps({'CLAUDE_CODE_OAUTH_TOKEN': os.environ['CLAUDE_CODE_OAUTH_TOKEN']}))
")
local resp wsid
# model required (CTO 2026-05-22 SSOT) — pass the deleted DefaultModel("claude-code") value.
resp=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d "{\"name\":\"Priority E2E (claude-code)\",\"runtime\":\"claude-code\",\"model\":\"sonnet\",\"tier\":1,\"secrets\":$secrets}")
-d "{\"name\":\"Priority E2E (claude-code)\",\"runtime\":\"claude-code\",\"tier\":1,\"secrets\":$secrets}")
wsid=$(echo "$resp" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))') || true
if [ -z "$wsid" ]; then
fail "create claude-code workspace" "$resp"
@@ -381,9 +380,8 @@ import json, os
print(json.dumps({'GEMINI_API_KEY': os.environ['E2E_GEMINI_API_KEY']}))
")
local resp wsid
# model required (CTO 2026-05-22 SSOT) — gemini-cli routes via the gemini provider.
resp=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d "{\"name\":\"Priority E2E (gemini-cli)\",\"runtime\":\"gemini-cli\",\"model\":\"gemini-2.0-flash\",\"tier\":1,\"secrets\":$secrets}")
-d "{\"name\":\"Priority E2E (gemini-cli)\",\"runtime\":\"gemini-cli\",\"tier\":1,\"secrets\":$secrets}")
wsid=$(echo "$resp" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))') || true
if [ -z "$wsid" ]; then fail "create gemini-cli workspace" "$resp"; return 0; fi
CREATED_WSIDS+=("$wsid")
@@ -1,9 +0,0 @@
-- 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;
@@ -1,31 +0,0 @@
-- 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,49 +34,13 @@ func TestMigrationsEmbedded_ContainsCreateTable(t *testing.T) {
t.Errorf("read embedded %q: %v", e.Name(), err)
continue
}
// 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 !strings.Contains(string(data), "CREATE TABLE") {
t.Errorf("embedded %q has no CREATE TABLE — wrong file embedded?", 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","DISPLAY_SESSION_SIGNING_SECRET":"display-secret"}`)
fmt.Fprint(w, `{"MOLECULE_CP_SHARED_SECRET":"new-secret","MOLECULE_CP_URL":"https://api.moleculesai.app"}`)
}))
defer srv.Close()
@@ -45,9 +45,6 @@ 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
+2 -26
View File
@@ -1,26 +1,3 @@
// Package main runs the per-tenant workspace-server.
//
// @title Molecule AI Workspace Server API
// @version 1.0
// @description The per-tenant workspace-server HTTP API. Single source of truth for workspace/schedule/agent/secrets/files/memory CRUD. Hand-written clients (canvas, molecule-mcp-server, molecule-cli, molecule-sdk-python) should be replaced by clients generated from this spec — see RFC #1706.
// @host api.moleculesai.app
// @BasePath /
// @schemes https
//
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @description Bearer token issued by Gitea (org-admin or persona PAT) or by the platform's signup/SSO flow.
//
// @securityDefinitions.apikey OrgSlugAuth
// @in header
// @name X-Molecule-Org-Slug
// @description Tenant routing header — required on every /workspaces/{id}/* request so the platform edge can route to the correct per-tenant workspace-server. Either X-Molecule-Org-Slug (human-readable, e.g. "agents-team") or X-Molecule-Org-Id (UUID) must be sent; slug is preferred for client code.
//
// @securityDefinitions.apikey OrgIdAuth
// @in header
// @name X-Molecule-Org-Id
// @description Tenant routing header (UUID form). Alternative to X-Molecule-Org-Slug. At least one of OrgSlugAuth or OrgIdAuth must be sent alongside BearerAuth.
package main
import (
@@ -393,9 +370,8 @@ func main() {
// See molecule-core#7.
bindHost := resolveBindHost()
srv := &http.Server{
Addr: fmt.Sprintf("%s:%s", bindHost, port),
Handler: r,
ReadHeaderTimeout: 5 * time.Second,
Addr: fmt.Sprintf("%s:%s", bindHost, port),
Handler: r,
}
// Start server in goroutine
-521
View File
@@ -1,521 +0,0 @@
{
"schemes": [
"https"
],
"swagger": "2.0",
"info": {
"description": "The per-tenant workspace-server HTTP API. Single source of truth for workspace/schedule/agent/secrets/files/memory CRUD. Hand-written clients (canvas, molecule-mcp-server, molecule-cli, molecule-sdk-python) should be replaced by clients generated from this spec — see RFC #1706.",
"title": "Molecule AI Workspace Server API",
"contact": {},
"version": "1.0"
},
"host": "api.moleculesai.app",
"basePath": "/",
"paths": {
"/workspaces/{id}/schedules": {
"get": {
"security": [
{
"BearerAuth": [],
"OrgSlugAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"schedules"
],
"summary": "List schedules for a workspace",
"parameters": [
{
"type": "string",
"description": "Workspace ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/handlers.ScheduleResponse"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
},
"post": {
"security": [
{
"BearerAuth": [],
"OrgSlugAuth": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"schedules"
],
"summary": "Create a schedule",
"parameters": [
{
"type": "string",
"description": "Workspace ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Schedule fields",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handlers.CreateScheduleRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/handlers.CreateScheduleResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/workspaces/{id}/schedules/{scheduleId}": {
"delete": {
"security": [
{
"BearerAuth": [],
"OrgSlugAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"schedules"
],
"summary": "Delete a schedule",
"parameters": [
{
"type": "string",
"description": "Workspace ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Schedule ID",
"name": "scheduleId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.StatusResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
},
"patch": {
"security": [
{
"BearerAuth": [],
"OrgSlugAuth": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"schedules"
],
"summary": "Update a schedule",
"parameters": [
{
"type": "string",
"description": "Workspace ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Schedule ID",
"name": "scheduleId",
"in": "path",
"required": true
},
{
"description": "Partial schedule fields (only provided keys are updated)",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handlers.UpdateScheduleRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.ScheduleResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/workspaces/{id}/schedules/{scheduleId}/history": {
"get": {
"security": [
{
"BearerAuth": [],
"OrgSlugAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"schedules"
],
"summary": "Get past runs of a schedule",
"parameters": [
{
"type": "string",
"description": "Workspace ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Schedule ID",
"name": "scheduleId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/handlers.HistoryEntry"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/workspaces/{id}/schedules/{scheduleId}/run": {
"post": {
"security": [
{
"BearerAuth": [],
"OrgSlugAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"schedules"
],
"summary": "Fire a schedule manually",
"parameters": [
{
"type": "string",
"description": "Workspace ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Schedule ID",
"name": "scheduleId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.RunNowResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
}
},
"definitions": {
"handlers.CreateScheduleRequest": {
"type": "object",
"required": [
"cron_expr",
"prompt"
],
"properties": {
"cron_expr": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"name": {
"type": "string"
},
"prompt": {
"type": "string"
},
"timezone": {
"type": "string"
}
}
},
"handlers.CreateScheduleResponse": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"next_run_at": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"handlers.ErrorResponse": {
"type": "object",
"properties": {
"error": {
"type": "string"
}
}
},
"handlers.HistoryEntry": {
"type": "object",
"properties": {
"duration_ms": {
"type": "integer"
},
"error_detail": {
"type": "string"
},
"request": {
"type": "object"
},
"status": {
"type": "string"
},
"timestamp": {
"type": "string"
}
}
},
"handlers.RunNowResponse": {
"type": "object",
"properties": {
"prompt": {
"type": "string"
},
"status": {
"type": "string"
},
"workspace_id": {
"type": "string"
}
}
},
"handlers.ScheduleResponse": {
"type": "object",
"properties": {
"created_at": {
"type": "string"
},
"cron_expr": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"id": {
"type": "string"
},
"last_error": {
"type": "string"
},
"last_run_at": {
"type": "string"
},
"last_status": {
"type": "string"
},
"name": {
"type": "string"
},
"next_run_at": {
"type": "string"
},
"prompt": {
"type": "string"
},
"run_count": {
"type": "integer"
},
"source": {
"description": "'template' (seeded by org/import) | 'runtime' (created via Canvas/API). Issue #24.",
"type": "string"
},
"timezone": {
"type": "string"
},
"updated_at": {
"type": "string"
},
"workspace_id": {
"type": "string"
}
}
},
"handlers.StatusResponse": {
"type": "object",
"properties": {
"status": {
"type": "string"
}
}
},
"handlers.UpdateScheduleRequest": {
"type": "object",
"properties": {
"cron_expr": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"name": {
"type": "string"
},
"prompt": {
"type": "string"
},
"timezone": {
"type": "string"
}
}
}
},
"securityDefinitions": {
"BearerAuth": {
"description": "Bearer token issued by Gitea (org-admin or persona PAT) or by the platform's signup/SSO flow.",
"type": "apiKey",
"name": "Authorization",
"in": "header"
},
"OrgIdAuth": {
"description": "Tenant routing header (UUID form). Alternative to X-Molecule-Org-Slug. At least one of OrgSlugAuth or OrgIdAuth must be sent alongside BearerAuth.",
"type": "apiKey",
"name": "X-Molecule-Org-Id",
"in": "header"
},
"OrgSlugAuth": {
"description": "Tenant routing header — required on every /workspaces/{id}/* request so the platform edge can route to the correct per-tenant workspace-server. Either X-Molecule-Org-Slug (human-readable, e.g. \"agents-team\") or X-Molecule-Org-Id (UUID) must be sent; slug is preferred for client code.",
"type": "apiKey",
"name": "X-Molecule-Org-Slug",
"in": "header"
}
}
}
-349
View File
@@ -1,349 +0,0 @@
basePath: /
definitions:
handlers.CreateScheduleRequest:
properties:
cron_expr:
type: string
enabled:
type: boolean
name:
type: string
prompt:
type: string
timezone:
type: string
required:
- cron_expr
- prompt
type: object
handlers.CreateScheduleResponse:
properties:
id:
type: string
next_run_at:
type: string
status:
type: string
type: object
handlers.ErrorResponse:
properties:
error:
type: string
type: object
handlers.HistoryEntry:
properties:
duration_ms:
type: integer
error_detail:
type: string
request:
type: object
status:
type: string
timestamp:
type: string
type: object
handlers.RunNowResponse:
properties:
prompt:
type: string
status:
type: string
workspace_id:
type: string
type: object
handlers.ScheduleResponse:
properties:
created_at:
type: string
cron_expr:
type: string
enabled:
type: boolean
id:
type: string
last_error:
type: string
last_run_at:
type: string
last_status:
type: string
name:
type: string
next_run_at:
type: string
prompt:
type: string
run_count:
type: integer
source:
description: '''template'' (seeded by org/import) | ''runtime'' (created via
Canvas/API). Issue #24.'
type: string
timezone:
type: string
updated_at:
type: string
workspace_id:
type: string
type: object
handlers.StatusResponse:
properties:
status:
type: string
type: object
handlers.UpdateScheduleRequest:
properties:
cron_expr:
type: string
enabled:
type: boolean
name:
type: string
prompt:
type: string
timezone:
type: string
type: object
host: api.moleculesai.app
info:
contact: {}
description: 'The per-tenant workspace-server HTTP API. Single source of truth for
workspace/schedule/agent/secrets/files/memory CRUD. Hand-written clients (canvas,
molecule-mcp-server, molecule-cli, molecule-sdk-python) should be replaced by
clients generated from this spec — see RFC #1706.'
title: Molecule AI Workspace Server API
version: "1.0"
paths:
/workspaces/{id}/schedules:
get:
parameters:
- description: Workspace ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/handlers.ScheduleResponse'
type: array
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
security:
- BearerAuth: []
OrgSlugAuth: []
summary: List schedules for a workspace
tags:
- schedules
post:
consumes:
- application/json
parameters:
- description: Workspace ID
in: path
name: id
required: true
type: string
- description: Schedule fields
in: body
name: body
required: true
schema:
$ref: '#/definitions/handlers.CreateScheduleRequest'
produces:
- application/json
responses:
"201":
description: Created
schema:
$ref: '#/definitions/handlers.CreateScheduleResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
security:
- BearerAuth: []
OrgSlugAuth: []
summary: Create a schedule
tags:
- schedules
/workspaces/{id}/schedules/{scheduleId}:
delete:
parameters:
- description: Workspace ID
in: path
name: id
required: true
type: string
- description: Schedule ID
in: path
name: scheduleId
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.StatusResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
security:
- BearerAuth: []
OrgSlugAuth: []
summary: Delete a schedule
tags:
- schedules
patch:
consumes:
- application/json
parameters:
- description: Workspace ID
in: path
name: id
required: true
type: string
- description: Schedule ID
in: path
name: scheduleId
required: true
type: string
- description: Partial schedule fields (only provided keys are updated)
in: body
name: body
required: true
schema:
$ref: '#/definitions/handlers.UpdateScheduleRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.ScheduleResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
security:
- BearerAuth: []
OrgSlugAuth: []
summary: Update a schedule
tags:
- schedules
/workspaces/{id}/schedules/{scheduleId}/history:
get:
parameters:
- description: Workspace ID
in: path
name: id
required: true
type: string
- description: Schedule ID
in: path
name: scheduleId
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/handlers.HistoryEntry'
type: array
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
security:
- BearerAuth: []
OrgSlugAuth: []
summary: Get past runs of a schedule
tags:
- schedules
/workspaces/{id}/schedules/{scheduleId}/run:
post:
parameters:
- description: Workspace ID
in: path
name: id
required: true
type: string
- description: Schedule ID
in: path
name: scheduleId
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.RunNowResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
security:
- BearerAuth: []
OrgSlugAuth: []
summary: Fire a schedule manually
tags:
- schedules
schemes:
- https
securityDefinitions:
BearerAuth:
description: Bearer token issued by Gitea (org-admin or persona PAT) or by the
platform's signup/SSO flow.
in: header
name: Authorization
type: apiKey
OrgIdAuth:
description: Tenant routing header (UUID form). Alternative to X-Molecule-Org-Slug.
At least one of OrgSlugAuth or OrgIdAuth must be sent alongside BearerAuth.
in: header
name: X-Molecule-Org-Id
type: apiKey
OrgSlugAuth:
description: Tenant routing header — required on every /workspaces/{id}/* request
so the platform edge can route to the correct per-tenant workspace-server. Either
X-Molecule-Org-Slug (human-readable, e.g. "agents-team") or X-Molecule-Org-Id
(UUID) must be sent; slug is preferred for client code.
in: header
name: X-Molecule-Org-Slug
type: apiKey
swagger: "2.0"
+6 -41
View File
@@ -23,57 +23,22 @@ CANVAS_PID=$!
# Memory v2 sidecar (built-in postgres plugin). See Dockerfile entrypoint
# comment for rationale.
#
# 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
# 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
# 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 [ -n "$MEMORY_PLUGIN_URL" ]; then
if [ "$MEMORY_V2_CUTOVER" = "true" ] || [ -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
# 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_DATABASE_URL:=$DATABASE_URL}"
: "${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
@@ -116,11 +116,8 @@ func (d *DiscordAdapter) SendMessage(ctx context.Context, config map[string]inte
// would propagate that token into logs and error responses (#659).
return fmt.Errorf("discord: HTTP request failed")
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
_ = resp.Body.Close()
if readErr != nil {
return fmt.Errorf("discord: read response body: %w", readErr)
}
// Discord returns 204 No Content on success.
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
+1 -4
View File
@@ -119,10 +119,7 @@ func (l *LarkAdapter) SendMessage(ctx context.Context, config map[string]interfa
}
defer func() { _ = resp.Body.Close() }()
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return fmt.Errorf("lark: read response body: %w", readErr)
}
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("lark: webhook returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
+4 -27
View File
@@ -156,9 +156,6 @@ func (m *Manager) PausePollersForToken(workspaceID, botToken string) func() {
}
}
}
if err := rows.Err(); err != nil {
log.Printf("Channels: pause-pollers rows.Err: %v", err)
}
m.mu.Unlock()
if len(pausedIDs) == 0 {
@@ -207,16 +204,8 @@ func (m *Manager) Reload(ctx context.Context) {
log.Printf("Channels: reload scan error: %v", err)
continue
}
if err := json.Unmarshal(configJSON, &ch.Config); err != nil {
log.Printf("Channels: reload config unmarshal error for %s: %v", truncID(ch.ID), err)
continue
}
if len(allowedJSON) > 0 {
if err := json.Unmarshal(allowedJSON, &ch.AllowedUsers); err != nil {
log.Printf("Channels: reload allowed_users unmarshal error for %s: %v", truncID(ch.ID), err)
continue
}
}
_ = json.Unmarshal(configJSON, &ch.Config)
_ = json.Unmarshal(allowedJSON, &ch.AllowedUsers)
// #319: decrypt at the boundary between DB (ciphertext) and the
// in-memory config adapters consume. A decrypt failure logs and
// skips the channel — downstream getUpdates would fail anyway
@@ -227,9 +216,6 @@ func (m *Manager) Reload(ctx context.Context) {
}
desired[ch.ID] = ch
}
if err := rows.Err(); err != nil {
log.Printf("Channels: reload rows.Err: %v", err)
}
m.mu.Lock()
defer m.mu.Unlock()
@@ -487,9 +473,6 @@ func (m *Manager) BroadcastToWorkspaceChannels(ctx context.Context, workspaceID,
}
}
}
if err := rows.Err(); err != nil {
log.Printf("Channels: broadcast rows.Err: %v", err)
}
}
// FetchWorkspaceChannelContext returns recent Slack channel messages formatted
@@ -572,14 +555,8 @@ func (m *Manager) loadChannel(ctx context.Context, channelID string) (ChannelRow
if err != nil {
return ch, fmt.Errorf("channel %s not found: %w", channelID, err)
}
if err := json.Unmarshal(configJSON, &ch.Config); err != nil {
return ch, fmt.Errorf("channel %s config unmarshal: %w", channelID, err)
}
if len(allowedJSON) > 0 {
if err := json.Unmarshal(allowedJSON, &ch.AllowedUsers); err != nil {
return ch, fmt.Errorf("channel %s allowed_users unmarshal: %w", channelID, err)
}
}
json.Unmarshal(configJSON, &ch.Config)
json.Unmarshal(allowedJSON, &ch.AllowedUsers)
// #319: decrypt bot_token / webhook_secret — SendOutbound and adapter
// methods downstream read them as plaintext strings.
if err := DecryptSensitiveFields(ch.Config); err != nil {
+3 -13
View File
@@ -171,11 +171,8 @@ func (s *SlackAdapter) sendBotMessage(ctx context.Context, config map[string]int
if err != nil {
return fmt.Errorf("slack: send: %w", err)
}
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
_ = resp.Body.Close()
if readErr != nil {
return fmt.Errorf("slack: read response body: %w", readErr)
}
var result struct {
OK bool `json:"ok"`
Error string `json:"error"`
@@ -211,13 +208,9 @@ func (s *SlackAdapter) sendWebhookMessage(ctx context.Context, config map[string
if err != nil {
return fmt.Errorf("slack: send: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return fmt.Errorf("slack: webhook returned %d (read body failed: %v)", resp.StatusCode, readErr)
}
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("slack: webhook returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
@@ -531,11 +524,8 @@ func FetchChannelHistory(ctx context.Context, botToken, channelID string, limit
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 65536))
body, _ := io.ReadAll(io.LimitReader(resp.Body, 65536))
_ = resp.Body.Close()
if readErr != nil {
return nil, fmt.Errorf("slack: read history response: %w", readErr)
}
var result struct {
OK bool `json:"ok"`
+10 -21
View File
@@ -111,13 +111,12 @@ 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 — 5min default. From request-body-end
// 3. Transport.ResponseHeaderTimeout — 180s 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 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
// turns (big context + internal delegate_task round-trips routinely exceed
// the old 60s ceiling). Body streaming after headers is governed by the
// per-request context deadline, NOT this timeout — so multi-minute agent
// responses still work fine.
//
@@ -132,7 +131,7 @@ var a2aClient = &http.Client{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
ResponseHeaderTimeout: envx.Duration("A2A_PROXY_RESPONSE_HEADER_TIMEOUT", 5*time.Minute),
ResponseHeaderTimeout: envx.Duration("A2A_PROXY_RESPONSE_HEADER_TIMEOUT", 180*time.Second),
TLSHandshakeTimeout: 10 * time.Second,
// MaxIdleConns / IdleConnTimeout: stdlib defaults are fine; agent
// fan-in is bounded by the platform's broadcaster fan-out, not by
@@ -229,7 +228,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, false)
status, resp, proxyErr := h.proxyA2ARequest(ctx, workspaceID, body, callerID, logActivity)
if proxyErr != nil {
return status, resp, proxyErr
}
@@ -308,21 +307,13 @@ 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.
//
// 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 {
if callerID != "" && callerID != workspaceID {
if err := validateCallerToken(ctx, c, callerID); err != nil {
return // response already written with 401
}
}
status, respBody, proxyErr := h.proxyA2ARequest(ctx, workspaceID, body, callerID, true, isCanvasUser)
status, respBody, proxyErr := h.proxyA2ARequest(ctx, workspaceID, body, callerID, true)
if proxyErr != nil {
for k, v := range proxyErr.Headers {
c.Header(k, v)
@@ -361,13 +352,11 @@ 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, isCanvasUser bool) (int, []byte, *proxyA2AError) {
func (h *WorkspaceHandler) proxyA2ARequest(ctx context.Context, workspaceID string, body []byte, callerID string, logActivity 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.
// 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 callerID != "" && callerID != workspaceID && !isSystemCaller(callerID) {
if !registry.CanCommunicate(callerID, workspaceID) {
log.Printf("ProxyA2A: access denied %s → %s", callerID, workspaceID)
return 0, nil, &proxyA2AError{
@@ -5,21 +5,17 @@ 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"
)
@@ -32,8 +28,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 records the outcome. Busy
// targets that are successfully queued are logged as queued, not failed.
// runs the reactive container-health check, and (when `logActivity` is true)
// schedules a detached LogActivity goroutine for the failed attempt.
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.
@@ -49,10 +45,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},
@@ -75,30 +71,35 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
// with 202 status here was the original cycle 53 bug — callers saw
// proxyErr != nil and logged "delegation failed: proxy a2a error".
if isUpstreamBusyError(err) {
// #1684 / Reno Stars: native_session adapters previously took a
// 503-no-enqueue path here, on the assumption that the SDK owned
// an inbound queue and the platform a2a_queue would double-buffer.
// In practice, the common native_session SDKs (claude-agent-sdk,
// codex app-server, hermes-agent) do NOT have an inbound queue —
// new turns can only be pushed via the same HTTP POST that just
// returned busy. So cron fires (and any A2A retry) bounce 503
// every tick until the SDK voluntarily yields. Reno Stars #1684
// observed 12 consecutive `*/30` cron fires lost over 6h while a
// single native_session held the slot.
// Capability primitive #5 — see project memory
// `project_runtime_native_pluggable.md`. When the target workspace's
// adapter has declared provides_native_session=True, the SDK
// owns its own queue/session state (claude-agent-sdk's streaming
// session, hermes-agent's in-container event log, etc.). Adding
// the platform's a2a_queue layer on top would double-buffer the
// same in-flight state — and worse, the platform queue's drain
// timing has no relationship to the SDK's actual readiness, so
// the queued request might dispatch while the SDK is STILL busy.
//
// The original concern — "drain timing has no relationship to SDK
// readiness" — turns out to be unfounded: heartbeat→drain is
// gated by `payload.ActiveTasks < maxConcurrent` in
// registry.go:Heartbeat, so drain only fires when the workspace
// itself reports spare capacity. That IS the session-ended
// signal. The native_session SDK reports ActiveTasks=1 while in a
// turn, ActiveTasks=0 when idle, and the next heartbeat after
// idle triggers DrainQueueForWorkspace.
//
// So we collapse the two branches: both native_session and
// non-native callers enqueue here. The native_session SDK's own
// in-flight POST stays unaffected; the queued item drains on the
// next post-idle heartbeat.
// For native_session targets, return 503 + Retry-After directly.
// The caller's adapter handles retry on its own schedule, and
// the SDK's own queue absorbs the in-flight request when it does.
// Observability is preserved: logA2AFailure already ran above;
// activity_logs records the busy event; the broadcaster fires.
if runtimeOverrides.HasCapability(workspaceID, "session") {
log.Printf("ProxyA2A: target %s busy and declares native_session — skip enqueue, return 503", workspaceID)
return 0, nil, &proxyA2AError{
Status: http.StatusServiceUnavailable,
Headers: map[string]string{"Retry-After": strconv.Itoa(busyRetryAfterSeconds)},
Response: gin.H{
"error": "workspace agent busy — adapter handles retry (native_session)",
"busy": true,
"retry_after": busyRetryAfterSeconds,
"native_session": true,
},
}
}
idempotencyKey := extractIdempotencyKey(body)
// Honor params.expires_in_seconds when the caller specifies one. Zero
// (the unset default) → expiresAt = nil → infinite TTL preserved by
@@ -112,9 +113,6 @@ 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,
@@ -128,9 +126,6 @@ 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)},
@@ -141,9 +136,6 @@ 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"},
@@ -324,33 +316,6 @@ 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.
@@ -423,53 +388,31 @@ 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) (isCanvasUser bool, err error) {
hasLive, dbErr := wsauth.HasAnyLiveToken(ctx, db.DB, callerID)
if dbErr != nil {
func validateCallerToken(ctx context.Context, c *gin.Context, callerID string) error {
hasLive, err := wsauth.HasAnyLiveToken(ctx, db.DB, callerID)
if err != 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, dbErr)
return false, nil
log.Printf("wsauth: caller HasAnyLiveToken(%s) failed: %v — allowing A2A", callerID, err)
return nil
}
if !hasLive {
// 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
return 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 false, errInvalidCallerToken
return errInvalidCallerToken
}
if err := wsauth.ValidateToken(ctx, db.DB, callerID, tok); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid caller auth token"})
return false, err
return err
}
return false, nil
return nil
}
// errInvalidCallerToken is a sentinel for validateCallerToken's "missing
@@ -1112,13 +1112,9 @@ func TestValidateCallerToken_LegacyCallerGrandfathered(t *testing.T) {
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/workspaces/x/a2a", bytes.NewBufferString("{}"))
isCanvasUser, err := validateCallerToken(context.Background(), c, "ws-legacy")
if err != nil {
if err := validateCallerToken(context.Background(), c, "ws-legacy"); 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 {
@@ -1140,13 +1136,10 @@ func TestValidateCallerToken_MissingTokenWhenOnFile(t *testing.T) {
c.Request = httptest.NewRequest("POST", "/workspaces/x/a2a", bytes.NewBufferString("{}"))
// No Authorization header set
isCanvasUser, err := validateCallerToken(context.Background(), c, "ws-authed")
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)
}
@@ -1171,13 +1164,9 @@ func TestValidateCallerToken_InvalidToken(t *testing.T) {
req.Header.Set("Authorization", "Bearer wrong")
c.Request = req
isCanvasUser, err := validateCallerToken(context.Background(), c, "ws-authed")
if err == nil {
if err := validateCallerToken(context.Background(), c, "ws-authed"); 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)
}
@@ -1203,13 +1192,9 @@ func TestValidateCallerToken_ValidToken(t *testing.T) {
req.Header.Set("Authorization", "Bearer goodtok")
c.Request = req
isCanvasUser, err := validateCallerToken(context.Background(), c, "ws-authed")
if err != nil {
if err := validateCallerToken(context.Background(), c, "ws-authed"); 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) {
@@ -1231,86 +1216,14 @@ func TestValidateCallerToken_WrongWorkspaceBindingRejected(t *testing.T) {
req.Header.Set("Authorization", "Bearer tok-for-A")
c.Request = req
isCanvasUser, err := validateCallerToken(context.Background(), c, "ws-b-attacker")
if err == nil {
if err := validateCallerToken(context.Background(), c, "ws-b-attacker"); 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) {
@@ -1866,58 +1779,6 @@ 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)
@@ -2493,7 +2354,7 @@ func TestLookupDeliveryMode_ContextCanceled_FailsClosed(t *testing.T) {
// ==================== a2aClient ResponseHeaderTimeout config ====================
func TestA2AClientResponseHeaderTimeout(t *testing.T) {
const defaultTimeout = 5 * time.Minute
const defaultTimeout = 180 * time.Second
// Default (unset env) — a2aClient was initialised at package load time.
if a2aClient.Transport.(*http.Transport).ResponseHeaderTimeout != defaultTimeout {
@@ -2517,7 +2378,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 = 5 * time.Minute
var fallback = 180 * time.Second
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, false)
status, respBody, proxyErr := h.proxyA2ARequest(ctx, workspaceID, item.Body, callerID, 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,6 +5,7 @@ import (
"database/sql"
"log"
"net/http"
"os"
"strings"
"time"
@@ -15,12 +16,19 @@ 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).
//
// 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.
// 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.
type AdminMemoriesHandler struct {
plugin adminMemoriesPlugin
resolver adminMemoriesResolver
@@ -61,12 +69,12 @@ func (h *AdminMemoriesHandler) withMemoryV2APIs(plugin adminMemoriesPlugin, reso
return h
}
// 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 {
// 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
}
return h.plugin != nil && h.resolver != nil
}
@@ -89,19 +97,48 @@ 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.
//
// 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.
// CUTOVER (PR-8 / RFC #2728): when MEMORY_V2_CUTOVER=true and the v2
// plugin is wired, reads from the plugin instead of agent_memories.
func (h *AdminMemoriesHandler) Export(c *gin.Context) {
ctx := c.Request.Context()
if !h.memoryV2Wired() {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "memory plugin is not configured (set MEMORY_PLUGIN_URL)",
})
if h.cutoverActive() {
h.exportViaPlugin(c, ctx)
return
}
h.exportViaPlugin(c, ctx)
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)
}
// memoryImportEntry is the JSON shape accepted on import. Matches export format.
@@ -123,9 +160,8 @@ type memoryImportEntry struct {
// with embedded credentials cannot land unredacted in agent_memories (SAFE-T1201
// parity with the commit_memory MCP bridge path).
//
// 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.
// CUTOVER (PR-8 / RFC #2728): when MEMORY_V2_CUTOVER=true and the v2
// plugin is wired, writes through the plugin instead of agent_memories.
func (h *AdminMemoriesHandler) Import(c *gin.Context) {
ctx := c.Request.Context()
@@ -135,13 +171,85 @@ func (h *AdminMemoriesHandler) Import(c *gin.Context) {
return
}
if !h.memoryV2Wired() {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "memory plugin is not configured (set MEMORY_PLUGIN_URL)",
})
if h.cutoverActive() {
h.importViaPlugin(c, ctx, entries)
return
}
h.importViaPlugin(c, ctx, entries)
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),
})
}
// exportViaPlugin reads memories from the v2 plugin and emits them in
@@ -101,24 +101,26 @@ func installMockDB(t *testing.T) sqlmock.Sqlmock {
return mock
}
// --- memoryV2Wired ---
// --- cutoverActive ---
func TestMemoryV2Wired(t *testing.T) {
func TestCutoverActive(t *testing.T) {
cases := []struct {
name string
envVal string
plugin adminMemoriesPlugin
resolver adminMemoriesResolver
want bool
}{
{"both nil", nil, nil, false},
{"plugin only", &stubAdminPlugin{}, nil, false},
{"resolver only", nil, adminRootResolver(), false},
{"both wired", &stubAdminPlugin{}, adminRootResolver(), true},
{"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},
}
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.memoryV2Wired(); got != tc.want {
if got := h.cutoverActive(); got != tc.want {
t.Errorf("got %v, want %v", got, tc.want)
}
})
@@ -145,6 +147,7 @@ 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").
@@ -188,6 +191,7 @@ 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.
@@ -218,6 +222,7 @@ 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"}).
@@ -239,6 +244,7 @@ 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"}).
@@ -262,6 +268,7 @@ 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"))
@@ -280,6 +287,7 @@ 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"}).
@@ -301,6 +309,7 @@ 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"}).
@@ -328,6 +337,7 @@ 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").
@@ -358,6 +368,7 @@ 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").
@@ -384,6 +395,7 @@ 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"))
@@ -413,6 +425,7 @@ 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"))
@@ -442,6 +455,7 @@ 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"))
@@ -468,6 +482,7 @@ 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"))
@@ -493,6 +508,7 @@ 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"))
@@ -529,6 +545,7 @@ 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").
@@ -588,6 +605,7 @@ 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").
@@ -757,43 +775,25 @@ func TestSkipImport_ErrorMessage(t *testing.T) {
}
}
// --- 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.
// --- Confirm legacy paths still work when env is unset ---
func TestExport_503WhenPluginNotWired(t *testing.T) {
installMockDB(t)
h := NewAdminMemoriesHandler() // no WithMemoryV2 → plugin nil
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())
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.StatusServiceUnavailable {
t.Fatalf("code = %d body=%s", w.Code, w.Body.String())
if w.Code != http.StatusOK {
t.Errorf("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())
}
}
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())
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("legacy SQL path not exercised: %v", err)
}
}
@@ -2,46 +2,220 @@ 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"
)
// 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.
// 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)
}
}
// 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)
@@ -53,3 +227,175 @@ func TestAdminMemories_Import_InvalidJSON(t *testing.T) {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestAdminMemories_Import_WorkspaceNotFound_SkipsEntry(t *testing.T) {
mock := setupTestDB(t)
h := newAdminMemoriesHandler()
// Workspace lookup returns no rows.
mock.ExpectQuery("SELECT id FROM workspaces WHERE name = \\$1").
WithArgs("ghost-workspace").
WillReturnError(sql.ErrNoRows)
w := adminPost(t, h, []map[string]interface{}{
{
"content": "some fact",
"scope": "LOCAL",
"workspace_name": "ghost-workspace",
},
})
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["skipped"].(float64) != 1 {
t.Errorf("skipped = %v, want 1 (workspace not found)", resp["skipped"])
}
if resp["imported"].(float64) != 0 {
t.Errorf("imported = %v, want 0", resp["imported"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestAdminMemories_Import_DuplicateSkipped(t *testing.T) {
mock := setupTestDB(t)
h := newAdminMemoriesHandler()
// Workspace lookup succeeds.
mock.ExpectQuery("SELECT id FROM workspaces WHERE name = \\$1").
WithArgs("my-workspace").
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-uuid-1"))
// Duplicate check returns true → entry is skipped.
mock.ExpectQuery("SELECT EXISTS").
WithArgs("ws-uuid-1", sqlmock.AnyArg(), "LOCAL").
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
w := adminPost(t, h, []map[string]interface{}{
{
"content": "already stored fact",
"scope": "LOCAL",
"workspace_name": "my-workspace",
},
})
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["skipped"].(float64) != 1 {
t.Errorf("skipped = %v, want 1 (duplicate)", resp["skipped"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// TestAdminMemories_Import_RedactsSecretsBeforeDedup verifies F1085 (#1132):
// redactSecrets is called BEFORE the deduplication check so that two backups
// with the same original secret each get the same placeholder and dedup works.
// The DB dedup query must receive the REDACTED content, not the raw credential.
func TestAdminMemories_Import_RedactsSecretsBeforeDedup(t *testing.T) {
mock := setupTestDB(t)
h := newAdminMemoriesHandler()
rawContent := "the key is OPENAI_API_KEY=sk-1234567890abcdefgh"
redacted, changed := redactSecrets("my-workspace", rawContent)
if !changed {
t.Fatalf("precondition: redactSecrets must change the test content")
}
// Workspace lookup.
mock.ExpectQuery("SELECT id FROM workspaces WHERE name = \\$1").
WithArgs("my-workspace").
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-uuid-1"))
// Dedup check — the sqlmock must be set up for the REDACTED content,
// because Import calls redactSecrets before running the dedup query.
// If redactSecrets is not called, the mock would match on rawContent instead.
mock.ExpectQuery("SELECT EXISTS").
WithArgs("ws-uuid-1", redacted, "LOCAL").
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
// Insert — receives the redacted content (not raw). Handler uses the
// 4-arg INSERT when created_at is absent from the payload.
mock.ExpectExec("INSERT INTO agent_memories").
WithArgs("ws-uuid-1", redacted, "LOCAL", "general").
WillReturnResult(sqlmock.NewResult(1, 1))
w := adminPost(t, h, []map[string]interface{}{
{
"content": rawContent,
"scope": "LOCAL",
"workspace_name": "my-workspace",
},
})
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["imported"].(float64) != 1 {
t.Errorf("imported = %v, want 1", resp["imported"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v (F1085 regression: redactSecrets not called before dedup)", err)
}
}
func TestAdminMemories_Import_PreservesCreatedAt(t *testing.T) {
mock := setupTestDB(t)
h := newAdminMemoriesHandler()
origTime := "2026-01-15T10:30:00Z"
// Workspace lookup.
mock.ExpectQuery("SELECT id FROM workspaces WHERE name = \\$1").
WithArgs("my-workspace").
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-uuid-1"))
// Dedup check.
mock.ExpectQuery("SELECT EXISTS").
WithArgs("ws-uuid-1", sqlmock.AnyArg(), "LOCAL").
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
// Insert with created_at — must use the 5-arg INSERT.
mock.ExpectExec("INSERT INTO agent_memories").
WithArgs("ws-uuid-1", sqlmock.AnyArg(), "LOCAL", "general", origTime).
WillReturnResult(sqlmock.NewResult(1, 1))
w := adminPost(t, h, []map[string]interface{}{
{
"content": "a fact",
"scope": "LOCAL",
"workspace_name": "my-workspace",
"created_at": origTime,
},
})
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["imported"].(float64) != 1 {
t.Errorf("imported = %v, want 1", resp["imported"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
@@ -39,7 +39,6 @@ func TestAdminTestToken_EnabledViaFlagEvenInProd(t *testing.T) {
mock := setupTestDB(t)
t.Setenv("MOLECULE_ENV", "production")
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1")
t.Setenv("ADMIN_TOKEN", "")
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
WithArgs("ws-1").
@@ -59,7 +58,6 @@ func TestAdminTestToken_EnabledViaFlagEvenInProd(t *testing.T) {
func TestAdminTestToken_WorkspaceNotFound(t *testing.T) {
mock := setupTestDB(t)
t.Setenv("MOLECULE_ENV", "development")
t.Setenv("ADMIN_TOKEN", "")
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
WithArgs("missing").
@@ -77,7 +75,6 @@ func TestAdminTestToken_WorkspaceNotFound(t *testing.T) {
func TestAdminTestToken_HappyPath_TokenValidates(t *testing.T) {
mock := setupTestDB(t)
t.Setenv("MOLECULE_ENV", "development")
t.Setenv("ADMIN_TOKEN", "")
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
WithArgs("ws-1").
@@ -44,7 +44,8 @@ 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", "codex", "hermes", "openclaw",
"claude-code", "langgraph", "autogen",
"hermes", "openclaw",
}
// RefreshResult is the per-call outcome surfaced to HTTP callers AND logged
@@ -104,9 +104,6 @@ func (h *ChannelHandler) List(c *gin.Context) {
}
result = append(result, entry)
}
if err := rows.Err(); err != nil {
log.Printf("Channels: list rows.Err: %v", err)
}
c.JSON(http.StatusOK, result)
}
@@ -517,9 +514,6 @@ func (h *ChannelHandler) Webhook(c *gin.Context) {
candidates = append(candidates, row)
}
}
if err := rows.Err(); err != nil {
log.Printf("Channels: telegram webhook rows.Err: %v", err)
}
if targetSlug != "" {
// [slug] routing — match against config username (lowercased)
@@ -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, false)
status, respBody, proxyErr := h.workspace.proxyA2ARequest(ctx, targetID, a2aBody, sourceID, true)
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, false)
status, respBody, proxyErr = h.workspace.proxyA2ARequest(ctx, targetID, a2aBody, sourceID, true)
}
}
@@ -393,9 +393,6 @@ func queryPeerMaps(query string, args ...interface{}) ([]map[string]interface{},
result = append(result, peer)
}
if err := rows.Err(); err != nil {
log.Printf("queryPeerMaps rows.Err: %v", err)
}
return result, nil
}
@@ -49,9 +49,6 @@ func (h *EventsHandler) List(c *gin.Context) {
"created_at": createdAt,
})
}
if err := rows.Err(); err != nil {
log.Printf("Events list rows error: %v", err)
}
c.JSON(http.StatusOK, events)
}
@@ -90,8 +87,5 @@ func (h *EventsHandler) ListByWorkspace(c *gin.Context) {
"created_at": createdAt,
})
}
if err := rows.Err(); err != nil {
log.Printf("WorkspaceEvents list rows error: %v", err)
}
c.JSON(http.StatusOK, events)
}
@@ -33,7 +33,7 @@ func TestWorkspaceCreate_WithParentID(t *testing.T) {
// Default tier is 3 (Privileged) — see workspace.go create-handler comment.
// delivery_mode defaults to "push" when payload omits it (#2339).
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(sqlmock.AnyArg(), "Child Agent", nil, 3, "langgraph", &parentID, nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WithArgs(sqlmock.AnyArg(), "Child Agent", nil, 3, "langgraph", sqlmock.AnyArg(), &parentID, nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectExec("INSERT INTO canvas_layouts").
@@ -44,7 +44,7 @@ func TestWorkspaceCreate_WithParentID(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Child Agent","model":"anthropic:claude-opus-4-7","parent_id":"parent-ws-123"}`
body := `{"name":"Child Agent","parent_id":"parent-ws-123"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -69,7 +69,7 @@ func TestWorkspaceCreate_ExplicitClaudeCodeRuntime(t *testing.T) {
mock.ExpectBegin()
// delivery_mode defaults to "push" when payload omits it (#2339).
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(sqlmock.AnyArg(), "CC Agent", nil, 2, "claude-code", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WithArgs(sqlmock.AnyArg(), "CC Agent", nil, 2, "claude-code", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectExec("INSERT INTO canvas_layouts").
@@ -80,7 +80,7 @@ func TestWorkspaceCreate_ExplicitClaudeCodeRuntime(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"CC Agent","tier":2,"runtime":"claude-code","model":"sonnet","canvas":{"x":10,"y":20}}`
body := `{"name":"CC Agent","tier":2,"runtime":"claude-code","canvas":{"x":10,"y":20}}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -230,7 +230,7 @@ func TestWorkspaceList_WithData(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
// 24 cols — compute added after talk_to_user_enabled.
// 23 cols — broadcast_enabled + talk_to_user_enabled added after monthly_spend
// (migration 20260514). Column order must match scanWorkspaceRow exactly.
columns := []string{
"id", "name", "role", "tier", "status", "agent_card", "url",
@@ -238,13 +238,13 @@ func TestWorkspaceList_WithData(t *testing.T) {
"last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled", "compute",
"broadcast_enabled", "talk_to_user_enabled",
}
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", "langgraph", "", 10.0, 20.0, false, nil, int64(0), false, true).
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(`{}`))
nil, 0, 1, 0.6, "timeout", 100, "", "claude-code", "", 50.0, 60.0, true, nil, int64(0), false, true)
mock.ExpectQuery("SELECT w.id, w.name").
WillReturnRows(rows)
@@ -291,7 +291,7 @@ func TestWorkspaceCreate_MaxConcurrentTasksOverride(t *testing.T) {
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(sqlmock.AnyArg(), "Leader Agent", nil, 3, "claude-code", (*string)(nil), nil, "none", (*int64)(nil), 3, "push").
WithArgs(sqlmock.AnyArg(), "Leader Agent", nil, 3, "claude-code", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), 3, "push", (*string)(nil), (*int)(nil)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectExec("INSERT INTO canvas_layouts").
@@ -301,7 +301,7 @@ func TestWorkspaceCreate_MaxConcurrentTasksOverride(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Leader Agent","runtime":"claude-code","model":"sonnet","max_concurrent_tasks":3}`
body := `{"name":"Leader Agent","runtime":"claude-code","max_concurrent_tasks":3}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -777,103 +777,6 @@ func TestCreate_FieldValidation_Returns400(t *testing.T) {
}
}
// TestCreate_ModelRequired_Returns422 pins the CTO 2026-05-22 SSOT
// directive (feedback_workspace_model_required_no_platform_default_dynamic_credential_intake):
// model is required user input; the platform must not supply a default,
// the runtime must not fall back. Empirical trigger: Code Reviewer
// 5ba15d7e was created with `{"name":..., "runtime":"codex", ...}` (no
// model). The legacy DefaultModel fallback returned "anthropic:claude-opus-4-7"
// and codex adapter wedged forever — `picks provider='anthropic' but it
// is not in the providers registry`. The gate at the Create boundary
// turns that silent stuck-workspace failure into an immediate 422 the
// caller can react to.
//
// Three shapes covered:
// 1. bare name (no template, no runtime, no model) — formerly defaulted
// to langgraph + anthropic; now 422 because model is unspecified.
// 2. explicit runtime, no model — the Code Reviewer repro shape.
// 3. explicit runtime+template path, but template (when missing on
// disk or unreadable) would leave model empty — exercised here by
// pointing at a non-existent template under /tmp/configs.
func TestCreate_ModelRequired_Returns422(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", "/tmp/configs")
cases := []struct{ label, body string }{
{"bare_name_no_runtime_no_model", `{"name":"x"}`},
{"explicit_codex_no_model", `{"name":"Code Reviewer","role":"code reviewer","runtime":"codex","tier":4,"max_concurrent_tasks":1}`},
{"explicit_hermes_no_model", `{"name":"researcher","runtime":"hermes"}`},
}
for _, tc := range cases {
t.Run(tc.label, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(tc.body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusUnprocessableEntity {
t.Errorf("Create(%s): want 422 MODEL_REQUIRED, got %d: %s", tc.label, w.Code, w.Body.String())
return
}
if !bytes.Contains(w.Body.Bytes(), []byte(`"code":"MODEL_REQUIRED"`)) {
t.Errorf("Create(%s): want body containing code=MODEL_REQUIRED, got %s", tc.label, w.Body.String())
}
})
}
}
// TestCreate_ExternalRuntime_NoModel_OK pins the external-runtime
// exemption from the MODEL_REQUIRED gate. External workspaces
// intentionally do not spawn a Docker container or run an adapter;
// they delegate to a registered URL (workspace_provision.go:497-498:
// "external is a first-class runtime that intentionally does NOT
// spawn a Docker container"). The model field has no meaning for
// them — the URL is the contract, and the gate would 422 every
// legitimate "register my agent at https://..." flow.
//
// Both spellings count as external:
// 1. payload.External == true (the canonical flag, e.g. with any runtime)
// 2. payload.Runtime == "external" (legacy shape some E2E scripts still use)
//
// The isExternalLikeRuntime() helper catches both "external" and any
// future external-like runtime alias.
func TestCreate_ExternalRuntime_NoModel_OK(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
// External=true with explicit runtime — the test_api.sh / Echo Agent shape.
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectExec("INSERT INTO canvas_layouts").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO structure_events").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`UPDATE workspaces SET status =`).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO structure_events").
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Echo Agent","tier":1,"runtime":"external","external":true}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusCreated {
t.Fatalf("external workspace without model: want 201, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations not met: %v", err)
}
}
func TestUpdate_FieldValidation_Returns400(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
@@ -364,11 +364,11 @@ func TestWorkspaceCreate(t *testing.T) {
// Expect transaction begin for atomic workspace+secrets creation
mock.ExpectBegin()
// Expect workspace INSERT (uuid is dynamic, use AnyArg for id, runtime).
// Expect workspace INSERT (uuid is dynamic, use AnyArg for id, runtime, awareness_namespace).
// Default tier is 3 (Privileged) — see workspace.go create-handler comment.
// delivery_mode defaults to "push" when payload omits it (#2339).
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(sqlmock.AnyArg(), "Test Agent", nil, 3, "langgraph", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WithArgs(sqlmock.AnyArg(), "Test Agent", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)).
WillReturnResult(sqlmock.NewResult(0, 1))
// Expect transaction commit (no secrets in this payload)
@@ -386,13 +386,7 @@ func TestWorkspaceCreate(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
// Note: model is now required at the Create boundary (CTO 2026-05-22
// SSOT directive — see feedback_workspace_model_required_no_platform_default_dynamic_credential_intake
// and TestCreate_ModelRequired_Returns422). This test happens to take
// the bare-defaults path (no template, no runtime → langgraph), so
// the body must declare an explicit model. Using a langgraph-compatible
// id; the test doesn't exercise model semantics beyond presence.
body := `{"name":"Test Agent","model":"anthropic:claude-opus-4-7","canvas":{"x":100,"y":200}}`
body := `{"name":"Test Agent","canvas":{"x":100,"y":200}}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -412,17 +406,24 @@ func TestWorkspaceCreate(t *testing.T) {
if resp["id"] == nil || resp["id"] == "" {
t.Error("expected non-empty id in response")
}
if resp["awareness_namespace"] != "workspace:"+resp["id"].(string) {
t.Errorf("expected awareness namespace derived from workspace id, got %v", resp["awareness_namespace"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestBuildProvisionerConfig_WorkspacePathFromPayload(t *testing.T) {
func TestBuildProvisionerConfig_IncludesAwarenessSettings(t *testing.T) {
setupTestDB(t)
// runtime_image_pins reader removed by RFC internal#617 / task #335
// — CP is the SSOT for runtime image pins. No DB lookup here anymore.
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", "/tmp/configs")
t.Setenv("AWARENESS_URL", "http://awareness:37800")
t.Setenv("WORKSPACE_DIR", "/tmp/workspace")
cfg := handler.buildProvisionerConfig(
@@ -433,10 +434,17 @@ func TestBuildProvisionerConfig_WorkspacePathFromPayload(t *testing.T) {
models.CreateWorkspacePayload{Tier: 2, Runtime: "claude-code", WorkspaceDir: "/tmp/workspace", WorkspaceAccess: "read_write"},
map[string]string{"OPENAI_API_KEY": "sk-test"},
"/tmp/plugins",
"workspace:ws-123",
)
if cfg.AwarenessURL != "http://awareness:37800" {
t.Fatalf("expected awareness URL to be injected, got %q", cfg.AwarenessURL)
}
if cfg.AwarenessNamespace != "workspace:ws-123" {
t.Fatalf("expected awareness namespace to be injected, got %q", cfg.AwarenessNamespace)
}
if cfg.WorkspacePath != "/tmp/workspace" {
t.Fatalf("expected workspace path from payload, got %q", cfg.WorkspacePath)
t.Fatalf("expected workspace path from env, got %q", cfg.WorkspacePath)
}
}
@@ -448,7 +456,7 @@ func TestWorkspaceList(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", "/tmp/configs")
// 24 cols: compute added after talk_to_user_enabled.
// 23 cols: broadcast_enabled + talk_to_user_enabled added after monthly_spend
// (migration 20260514). Column order must match scanWorkspaceRow exactly.
columns := []string{
"id", "name", "role", "tier", "status", "agent_card", "url",
@@ -456,13 +464,13 @@ func TestWorkspaceList(t *testing.T) {
"last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled", "compute",
"broadcast_enabled", "talk_to_user_enabled",
}
rows := sqlmock.NewRows(columns).
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(`{}`)).
nil, 0, 1, 0.0, "", 100, "", "claude-code", "", 10.0, 20.0, false, nil, int64(0), false, true).
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, "", "langgraph", "", 50.0, 60.0, false, nil, int64(0), false, true)
mock.ExpectQuery("SELECT w.id, w.name").
WillReturnRows(rows)
@@ -1176,14 +1184,14 @@ func TestWorkspaceGet_CurrentTask(t *testing.T) {
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled", "compute",
"broadcast_enabled", "talk_to_user_enabled",
}
mock.ExpectQuery("SELECT w.id, w.name").
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, int64(0), false, true, []byte(`{}`),
nil, int64(0), false, true,
))
w := httptest.NewRecorder()
+168 -115
View File
@@ -16,7 +16,6 @@ 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"
)
@@ -486,45 +485,65 @@ func TestMCPHandler_ListPeers_ReturnsSiblings(t *testing.T) {
// tools/call — commit_memory
// ─────────────────────────────────────────────────────────────────────────────
// 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) {
func TestMCPHandler_CommitMemory_LocalScope_Success(t *testing.T) {
h, mock := newMCPHandler(t)
// 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())
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).
w := mcpPost(t, h, "ws-1", map[string]interface{}{
"jsonrpc": "2.0",
@@ -566,7 +585,7 @@ func TestMCPHandler_CommitMemory_GlobalScope_ScrubsInternalError(t *testing.T) {
}
// (3) OFFSEC-001 negative assertions — the internal err.Error() text
// from scopeToWritableNamespace ("GLOBAL scope is not permitted via the MCP
// from toolCommitMemory ("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
@@ -591,43 +610,41 @@ func TestMCPHandler_CommitMemory_GlobalScope_ScrubsInternalError(t *testing.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())
// 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)
// 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"
wantRedacted, changed := redactSecrets("root-1", rawContent)
// 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)
if !changed {
t.Fatalf("precondition failed — redactSecrets must change the test content; got %q", wantRedacted)
t.Fatalf("precondition failed — redactSecrets must change the test content; got unchanged %q", expected)
}
if bytes.Contains([]byte(wantRedacted), []byte("sk-ant-xxxxxxxxxxxxxxxx")) {
t.Fatalf("precondition failed — redacted content still contains raw secret: %s", wantRedacted)
if bytes.Contains([]byte(expected), []byte("sk-ant-xxxxxxxxxxxxxxxx")) {
t.Fatalf("precondition failed — redacted content still contains raw secret: %s", expected)
}
w := mcpPost(t, h, "root-1", map[string]interface{}{
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{}{
"jsonrpc": "2.0",
"id": 99,
"method": "tools/call",
@@ -639,32 +656,52 @@ func TestMCPHandler_CommitMemory_LegacyName_RedactionAtPlugin(t *testing.T) {
},
},
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp mcpResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("response is not valid JSON: %v", err)
}
json.Unmarshal(w.Body.Bytes(), &resp)
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 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)
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("clean content should pass through unchanged: %v", err)
}
}
@@ -672,17 +709,14 @@ func TestMCPHandler_CommitMemory_LegacyName_RedactionAtPlugin(t *testing.T) {
// tools/call — recall_memory
// ─────────────────────────────────────────────────────────────────────────────
// 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) {
// 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) {
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{}{
@@ -736,11 +770,42 @@ func TestMCPHandler_RecallMemory_GlobalScope_ScrubsInternalError(t *testing.T) {
}
}
// 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.
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)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// tools/call — send_message_to_user
@@ -1076,8 +1141,6 @@ func TestIsSafeURL_Blocks169_254_Metadata(t *testing.T) {
}
func TestIsSafeURL_Blocks10xPrivate(t *testing.T) {
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
err := isSafeURL("http://10.0.0.1/agent")
if err == nil {
t.Errorf("isSafeURL: expected 10.x.x.x to be blocked, got nil")
@@ -1085,8 +1148,6 @@ func TestIsSafeURL_Blocks10xPrivate(t *testing.T) {
}
func TestIsSafeURL_Blocks172Private(t *testing.T) {
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
err := isSafeURL("http://172.16.0.1/agent")
if err == nil {
t.Errorf("isSafeURL: expected 172.16.0.0/12 to be blocked, got nil")
@@ -1094,8 +1155,6 @@ func TestIsSafeURL_Blocks172Private(t *testing.T) {
}
func TestIsSafeURL_Blocks192_168Private(t *testing.T) {
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
err := isSafeURL("http://192.168.1.100/agent")
if err == nil {
t.Errorf("isSafeURL: expected 192.168.x.x to be blocked, got nil")
@@ -1119,8 +1178,6 @@ func TestIsSafeURL_BlocksInvalidURL(t *testing.T) {
// ==================== SSRF Defence — isPrivateOrMetadataIP ====================
func TestIsPrivateOrMetadataIP_10Range(t *testing.T) {
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
tests := []string{"10.0.0.0", "10.255.255.255", "10.1.2.3"}
for _, ip := range tests {
if !isPrivateOrMetadataIP(net.ParseIP(ip)) {
@@ -1130,8 +1187,6 @@ func TestIsPrivateOrMetadataIP_10Range(t *testing.T) {
}
func TestIsPrivateOrMetadataIP_172Range(t *testing.T) {
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
tests := []string{"172.16.0.0", "172.31.255.255", "172.20.1.1"}
for _, ip := range tests {
if !isPrivateOrMetadataIP(net.ParseIP(ip)) {
@@ -1141,8 +1196,6 @@ func TestIsPrivateOrMetadataIP_172Range(t *testing.T) {
}
func TestIsPrivateOrMetadataIP_192_168Range(t *testing.T) {
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
tests := []string{"192.168.0.0", "192.168.255.255", "192.168.1.1"}
for _, ip := range tests {
if !isPrivateOrMetadataIP(net.ParseIP(ip)) {
+120 -25
View File
@@ -95,18 +95,14 @@ func (h *MCPHandler) toolListPeers(ctx context.Context, workspaceID string) (str
cols+` FROM workspaces w WHERE w.parent_id = $1 AND w.id != $2 AND w.status != 'removed'`,
parentID.String, workspaceID)
if err == nil {
if scanErr := scanPeers(rows); scanErr != nil {
log.Printf("MCP toolListPeers: sibling scan error: %v", scanErr)
}
_ = scanPeers(rows)
}
} else {
rows, err := h.database.QueryContext(ctx,
cols+` FROM workspaces w WHERE w.parent_id IS NULL AND w.id != $1 AND w.status != 'removed'`,
workspaceID)
if err == nil {
if scanErr := scanPeers(rows); scanErr != nil {
log.Printf("MCP toolListPeers: sibling scan error: %v", scanErr)
}
_ = scanPeers(rows)
}
}
@@ -116,9 +112,7 @@ func (h *MCPHandler) toolListPeers(ctx context.Context, workspaceID string) (str
cols+` FROM workspaces w WHERE w.parent_id = $1 AND w.status != 'removed'`,
workspaceID)
if err == nil {
if scanErr := scanPeers(rows); scanErr != nil {
log.Printf("MCP toolListPeers: children scan error: %v", scanErr)
}
_ = scanPeers(rows)
}
}
@@ -128,9 +122,7 @@ func (h *MCPHandler) toolListPeers(ctx context.Context, workspaceID string) (str
cols+` FROM workspaces w WHERE w.id = $1 AND w.status != 'removed'`,
parentID.String)
if err == nil {
if scanErr := scanPeers(rows); scanErr != nil {
log.Printf("MCP toolListPeers: parent scan error: %v", scanErr)
}
_ = scanPeers(rows)
}
}
@@ -363,24 +355,127 @@ func (h *MCPHandler) toolSendMessageToUser(ctx context.Context, workspaceID stri
}
func (h *MCPHandler) toolCommitMemory(ctx context.Context, workspaceID string, args map[string]interface{}) (string, error) {
// 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
// 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)
}
return h.commitMemoryLegacyShim(ctx, workspaceID, args)
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
}
func (h *MCPHandler) toolRecallMemory(ctx context.Context, workspaceID string, args map[string]interface{}) (string, error) {
// 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
// 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)
}
return h.recallMemoryLegacyShim(ctx, workspaceID, args)
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
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -2,13 +2,14 @@ package handlers
// mcp_tools_memory_legacy_shim.go — translates legacy commit_memory /
// recall_memory calls (scope-based) into the v2 plugin path
// (namespace-based).
// (namespace-based) when the v2 plugin is wired.
//
// 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.
// 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).
//
// Translation:
// commit: LOCAL → workspace:<self>
@@ -512,38 +512,41 @@ func TestToolRecallMemory_RoutesThroughV2WhenWired(t *testing.T) {
}
}
// 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()
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()
defer db.Close()
h := &MCPHandler{database: db} // no withMemoryV2APIs → memv2 nil
mock.ExpectExec("INSERT INTO agent_memories").
WillReturnResult(sqlmock.NewResult(0, 1))
h := &MCPHandler{database: db}
_, err := h.toolCommitMemory(context.Background(), "root-1", map[string]interface{}{
"content": "x",
"scope": "LOCAL",
})
if err == nil {
t.Fatal("expected error when v2 unwired, got nil")
if err != nil {
t.Fatalf("err: %v", err)
}
if !strings.Contains(err.Error(), "MEMORY_PLUGIN_URL") {
t.Errorf("error must hint at MEMORY_PLUGIN_URL: %v", err)
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("legacy SQL path not exercised: %v", err)
}
}
func TestToolRecallMemory_ErrorsWhenV2Unwired(t *testing.T) {
db, _, _ := sqlmock.New()
func TestToolRecallMemory_FallsThroughToLegacyWhenV2Unwired(t *testing.T) {
db, mock, _ := 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.Fatal("expected error when v2 unwired, got nil")
if err != nil {
t.Fatalf("err: %v", err)
}
if !strings.Contains(err.Error(), "MEMORY_PLUGIN_URL") {
t.Errorf("error must hint at MEMORY_PLUGIN_URL: %v", err)
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("legacy SQL path not exercised: %v", err)
}
}
@@ -54,11 +54,6 @@ 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,7 +4,6 @@ import (
"bytes"
"database/sql"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
@@ -75,34 +74,6 @@ 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) {
@@ -6,26 +6,18 @@ import (
"testing"
)
// TestHandleA2ADispatchError_NativeSession_NowEnqueues validates the #1684
// fix: native_session adapters used to short-circuit to 503-no-queue here,
// on the assumption that the SDK owned an inbound queue. In practice the
// common native_session SDKs (claude-agent-sdk, codex app-server, hermes)
// don't — new turns arrive only via the same HTTP POST that returns busy.
// So cron fires bounced 503 every tick until the SDK voluntarily yielded;
// Reno Stars #1684 observed 12 consecutive `*/30` cron fires lost over 6h.
// TestHandleA2ADispatchError_NativeSession_SkipsEnqueue validates capability
// primitive #5: when the target workspace has declared
// provides_native_session=True, a busy-shaped dispatch error MUST short-
// circuit straight to 503 + Retry-After. The platform's a2a_queue is
// skipped because the SDK owns its own queue/session state — double-
// buffering would cause spurious dispatches when the SDK is still busy.
//
// Post-fix: native_session and non-native both enqueue. Drain timing is
// gated by registry.go:Heartbeat (`payload.ActiveTasks < maxConcurrent`)
// so the queued item only dispatches when the SDK reports spare capacity
// — i.e. the next heartbeat after the in-flight turn returns.
//
// This test pins the new behavior: native_session capability DOES NOT
// bypass EnqueueA2A. We expect the INSERT INTO a2a_queue query to fire,
// here arranged to fail so we can observe the legacy 503 fallback (and
// thereby confirm the INSERT was attempted; sqlmock fails the test if
// the expected query never runs).
func TestHandleA2ADispatchError_NativeSession_NowEnqueues(t *testing.T) {
mock := setupTestDB(t)
// Pin via sqlmock: we deliberately do NOT expect any INSERT INTO a2a_queue.
// If a future refactor re-introduces enqueueing under native_session,
// sqlmock fails the test on the unexpected query.
func TestHandleA2ADispatchError_NativeSession_SkipsEnqueue(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
@@ -33,15 +25,10 @@ func TestHandleA2ADispatchError_NativeSession_NowEnqueues(t *testing.T) {
runtimeOverrides.SetCapabilities("ws-native", map[string]bool{"session": true})
defer runtimeOverrides.Reset()
// We now EXPECT the INSERT to fire even with native_session=true. Make
// it fail so the handler falls through to the legacy 503 path — that
// lets us assert (1) enqueue was attempted, (2) the response on
// queue-failure does NOT carry native_session=true marker (that field
// was removed alongside the gate).
mock.ExpectQuery(`INSERT INTO a2a_queue`).
WithArgs("ws-native", nil, PriorityTask, "{}", "message/send", nil).
WillReturnError(errTestQueueUnavailable)
// DeadlineExceeded triggers isUpstreamBusyError. Without the native
// gate, this would fire EnqueueA2A → INSERT INTO a2a_queue. With
// the gate, it short-circuits to 503. We expect ZERO queue queries;
// sqlmock's ExpectationsWereMet implicitly enforces that on teardown.
_, _, perr := handler.handleA2ADispatchError(
context.Background(), "ws-native", "", []byte("{}"), "message/send",
context.DeadlineExceeded, 1, false,
@@ -50,27 +37,28 @@ func TestHandleA2ADispatchError_NativeSession_NowEnqueues(t *testing.T) {
t.Fatal("expected proxy error, got nil")
}
if perr.Status != http.StatusServiceUnavailable {
t.Errorf("got status %d, want 503 (enqueue failed → legacy 503 fallback)", perr.Status)
t.Errorf("got status %d, want 503 (native_session bypasses queue but still 503s)", perr.Status)
}
if perr.Headers["Retry-After"] == "" {
t.Error("expected Retry-After header on busy-503")
t.Error("expected Retry-After header on native-session 503")
}
// The native_session marker was removed from the response body — the
// platform queues both kinds now, callers no longer distinguish. Pin
// its absence so a future revert is caught.
if got, ok := perr.Response["native_session"].(bool); ok && got {
t.Errorf("native_session marker should be gone after #1684 fix; got %+v", perr.Response)
// Pin the marker so callers' adapters can distinguish this from a
// queue-failure 503: the body has native_session=true.
if got, _ := perr.Response["native_session"].(bool); !got {
t.Errorf("expected native_session=true in response body; got %+v", perr.Response)
}
// And busy=true stays so existing busy-handling code paths still trigger.
if got, _ := perr.Response["busy"].(bool); !got {
t.Errorf("expected busy=true; got %+v", perr.Response)
t.Errorf("expected busy=true in response body; got %+v", perr.Response)
}
}
// TestHandleA2ADispatchError_NoNativeSession_StillEnqueues — non-native
// behavior is unchanged: enqueue is attempted, fail-fallback to 503. This
// negative pin guards against accidentally reverting the unification by
// re-introducing a `if HasCapability(...)` gate that would short-circuit
// the enqueue path.
// TestHandleA2ADispatchError_NoNativeSession_StillEnqueues is the negative
// pin: a workspace WITHOUT the capability flag falls through to the
// existing EnqueueA2A path (and 503 if that fails). Same shape as
// TestHandleA2ADispatchError_ContextDeadline; we duplicate it here so
// the native_session gate change is bracketed by both positive and
// negative tests in the same file.
func TestHandleA2ADispatchError_NoNativeSession_StillEnqueues(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
@@ -91,11 +79,13 @@ func TestHandleA2ADispatchError_NoNativeSession_StillEnqueues(t *testing.T) {
if perr == nil {
t.Fatal("expected proxy error, got nil")
}
// Queue insert failed → falls through to legacy 503 (without
// native_session marker).
if perr.Status != http.StatusServiceUnavailable {
t.Errorf("got status %d, want 503", perr.Status)
}
if got, _ := perr.Response["native_session"].(bool); got {
t.Errorf("non-native workspace should NOT carry native_session=true; got %+v", perr.Response)
t.Errorf("non-native workspace should NOT carry native_session=true in response; got %+v", perr.Response)
}
}
+2 -1
View File
@@ -799,12 +799,13 @@ func (h *OrgHandler) Import(c *gin.Context) {
if len(tmpl.GlobalMemories) > 0 && len(results) > 0 {
rootID, _ := results[0]["id"].(string)
if rootID != "" {
rootNS := workspaceAwarenessNamespace(rootID)
// Force scope to GLOBAL regardless of what the YAML says.
globalSeeds := make([]models.MemorySeed, len(tmpl.GlobalMemories))
for i, gm := range tmpl.GlobalMemories {
globalSeeds[i] = models.MemorySeed{Content: gm.Content, Scope: "GLOBAL"}
}
seedInitialMemories(context.Background(), rootID, globalSeeds)
seedInitialMemories(context.Background(), rootID, globalSeeds, rootNS)
log.Printf("Org import: seeded %d global memories on root workspace %s", len(globalSeeds), rootID)
}
}
@@ -69,15 +69,10 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
model = defaults.Model
}
if model == "" {
// SSOT (CTO 2026-05-22, feedback_workspace_model_required_no_platform_default_dynamic_credential_intake):
// model is REQUIRED. The org-import template MUST declare a
// model — either per-workspace (`ws.Model`) or via the org
// defaults block (`defaults.Model`). If neither is present
// the template is malformed and the import must fail-closed
// rather than silently provisioning a workspace with a
// runtime-incompatible default (the prior `anthropic:claude-opus-4-7`
// fallback wedged every codex workspace at adapter init).
return fmt.Errorf("org import: workspace %q has no model and the org defaults block does not provide one (runtime=%s) — model is a required field per the workspace-creation contract; either set `model:` on the workspace or under `defaults:`", ws.Name, runtime)
// SSOT: per-runtime defaults live in models/runtime_defaults.go
// (see RFC #2873). Consolidated from a duplicate of the same
// branch in workspace_provision.go.
model = models.DefaultModel(runtime)
}
tier := ws.Tier
if tier == 0 {
@@ -102,6 +97,7 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
}
id := uuid.New().String()
awarenessNS := workspaceAwarenessNamespace(id)
var role interface{}
if ws.Role != "" {
@@ -167,13 +163,13 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
// EXACTLY for Postgres to consider the index applicable.
var insertedID string
err := db.DB.QueryRowContext(ctx, `
INSERT INTO workspaces (id, name, role, tier, runtime, status, parent_id, workspace_dir, workspace_access, max_concurrent_tasks)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
INSERT INTO workspaces (id, name, role, tier, runtime, awareness_namespace, status, parent_id, workspace_dir, workspace_access, max_concurrent_tasks)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), name)
WHERE status != 'removed'
DO NOTHING
RETURNING id
`, id, ws.Name, role, tier, runtime, "provisioning", parentID, workspaceDir, workspaceAccess, maxConcurrent).Scan(&insertedID)
`, id, ws.Name, role, tier, runtime, awarenessNS, "provisioning", parentID, workspaceDir, workspaceAccess, maxConcurrent).Scan(&insertedID)
if errors.Is(err, sql.ErrNoRows) {
// Skip path — a non-removed row already exists for
// (parent_id, name). Re-select its id; idempotency-friendly
@@ -258,7 +254,7 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
if len(wsMemories) == 0 {
wsMemories = defaults.InitialMemories
}
seedInitialMemories(ctx, id, wsMemories)
seedInitialMemories(ctx, id, wsMemories, awarenessNS)
// Handle external workspaces
if ws.External {
@@ -712,8 +712,6 @@ func TestHeartbeat_SkipsRemovedRows(t *testing.T) {
// ------------------------------------------------------------
func TestValidateAgentURL(t *testing.T) {
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
cases := []struct {
name string
url string
@@ -133,30 +133,24 @@ func loadRestartContextData(ctx context.Context, workspaceID string) restartCont
// message bus.
keySet := map[string]struct{}{}
if rows, err := db.DB.QueryContext(ctx, `SELECT key FROM global_secrets`); err == nil {
defer rows.Close()
for rows.Next() {
var k string
if rows.Scan(&k) == nil {
keySet[k] = struct{}{}
}
}
if err := rows.Err(); err != nil {
log.Printf("loadRestartContextData: global_secrets rows.Err: %v", err)
}
rows.Close()
}
if rows, err := db.DB.QueryContext(ctx,
`SELECT key FROM workspace_secrets WHERE workspace_id = $1`, workspaceID,
); err == nil {
defer rows.Close()
for rows.Next() {
var k string
if rows.Scan(&k) == nil {
keySet[k] = struct{}{}
}
}
if err := rows.Err(); err != nil {
log.Printf("loadRestartContextData: workspace_secrets rows.Err: %v", err)
}
rows.Close()
}
for k := range keySet {
d.EnvKeys = append(d.EnvKeys, k)
@@ -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": "codex", "repo": "org/t-codex"},
{"name": "langgraph", "repo": "org/t-lg"},
{"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", "codex", "hermes", "external", "kimi", "kimi-cli"}
want := []string{"claude-code", "langgraph", "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":"codex","repo":"org/t"}]}`), 0600)
_ = os.WriteFile(path, []byte(`{"workspace_templates":[{"name":"langgraph","repo":"org/t"}]}`), 0600)
got, err := loadRuntimesFromManifest(path)
if err != nil {
@@ -97,16 +97,11 @@ func TestRealManifestParses(t *testing.T) {
t.Fatalf("real manifest load: %v", err)
}
// Core runtimes we always expect to ship.
for _, must := range []string{"codex", "hermes", "openclaw", "claude-code", "external", "kimi", "kimi-cli"} {
for _, must := range []string{"langgraph", "hermes", "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 {
+23 -123
View File
@@ -15,46 +15,13 @@ import (
"github.com/Molecule-AI/molecule-monorepo/platform/internal/scheduler"
)
// ErrorResponse is returned for 4xx/5xx errors. (OpenAPI doc shape — used by swaggo.)
type ErrorResponse struct {
Error string `json:"error"`
}
// StatusResponse is returned by mutating endpoints that only echo a status verb.
type StatusResponse struct {
Status string `json:"status"`
}
// CreateScheduleResponse is returned by POST /workspaces/{id}/schedules.
type CreateScheduleResponse struct {
ID string `json:"id"`
Status string `json:"status"`
NextRunAt time.Time `json:"next_run_at"`
}
// RunNowResponse is returned by POST /workspaces/{id}/schedules/{scheduleId}/run.
type RunNowResponse struct {
Status string `json:"status"`
WorkspaceID string `json:"workspace_id"`
Prompt string `json:"prompt"`
}
// HistoryEntry is one row of /workspaces/{id}/schedules/{scheduleId}/history.
type HistoryEntry struct {
Timestamp time.Time `json:"timestamp"`
DurationMs *int `json:"duration_ms"`
Status *string `json:"status"`
ErrorDetail string `json:"error_detail"`
Request json.RawMessage `json:"request" swaggertype:"object"`
}
type ScheduleHandler struct{}
func NewScheduleHandler() *ScheduleHandler {
return &ScheduleHandler{}
}
type ScheduleResponse struct {
type scheduleResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
@@ -73,15 +40,6 @@ type ScheduleResponse struct {
}
// List returns all schedules for a workspace.
//
// @Summary List schedules for a workspace
// @Tags schedules
// @Produce json
// @Param id path string true "Workspace ID"
// @Success 200 {array} ScheduleResponse
// @Failure 500 {object} ErrorResponse
// @Router /workspaces/{id}/schedules [get]
// @Security BearerAuth && OrgSlugAuth
func (h *ScheduleHandler) List(c *gin.Context) {
workspaceID := c.Param("id")
ctx := c.Request.Context()
@@ -100,9 +58,9 @@ func (h *ScheduleHandler) List(c *gin.Context) {
}
defer rows.Close()
schedules := make([]ScheduleResponse, 0)
schedules := make([]scheduleResponse, 0)
for rows.Next() {
var s ScheduleResponse
var s scheduleResponse
if err := rows.Scan(
&s.ID, &s.WorkspaceID, &s.Name, &s.CronExpr, &s.Timezone,
&s.Prompt, &s.Enabled, &s.LastRunAt, &s.NextRunAt, &s.RunCount,
@@ -120,7 +78,7 @@ func (h *ScheduleHandler) List(c *gin.Context) {
c.JSON(http.StatusOK, schedules)
}
type CreateScheduleRequest struct {
type createScheduleRequest struct {
Name string `json:"name"`
CronExpr string `json:"cron_expr" binding:"required"`
Timezone string `json:"timezone"`
@@ -129,23 +87,11 @@ type CreateScheduleRequest struct {
}
// Create adds a new schedule for a workspace.
//
// @Summary Create a schedule
// @Tags schedules
// @Accept json
// @Produce json
// @Param id path string true "Workspace ID"
// @Param body body CreateScheduleRequest true "Schedule fields"
// @Success 201 {object} CreateScheduleResponse
// @Failure 400 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /workspaces/{id}/schedules [post]
// @Security BearerAuth && OrgSlugAuth
func (h *ScheduleHandler) Create(c *gin.Context) {
workspaceID := c.Param("id")
ctx := c.Request.Context()
var body CreateScheduleRequest
var body createScheduleRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "cron_expr and prompt are required"})
return
@@ -199,7 +145,7 @@ func (h *ScheduleHandler) Create(c *gin.Context) {
})
}
type UpdateScheduleRequest struct {
type updateScheduleRequest struct {
Name *string `json:"name"`
CronExpr *string `json:"cron_expr"`
Timezone *string `json:"timezone"`
@@ -209,26 +155,12 @@ type UpdateScheduleRequest struct {
// Update modifies a schedule. Uses a fixed UPDATE with COALESCE so only
// provided fields are changed — no dynamic SQL construction.
//
// @Summary Update a schedule
// @Tags schedules
// @Accept json
// @Produce json
// @Param id path string true "Workspace ID"
// @Param scheduleId path string true "Schedule ID"
// @Param body body UpdateScheduleRequest true "Partial schedule fields (only provided keys are updated)"
// @Success 200 {object} ScheduleResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /workspaces/{id}/schedules/{scheduleId} [patch]
// @Security BearerAuth && OrgSlugAuth
func (h *ScheduleHandler) Update(c *gin.Context) {
scheduleID := c.Param("scheduleId")
workspaceID := c.Param("id") // #113: bind to owning workspace to prevent IDOR
ctx := c.Request.Context()
var body UpdateScheduleRequest
var body updateScheduleRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"})
return
@@ -298,17 +230,6 @@ func (h *ScheduleHandler) Update(c *gin.Context) {
}
// Delete removes a schedule.
//
// @Summary Delete a schedule
// @Tags schedules
// @Produce json
// @Param id path string true "Workspace ID"
// @Param scheduleId path string true "Schedule ID"
// @Success 200 {object} StatusResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /workspaces/{id}/schedules/{scheduleId} [delete]
// @Security BearerAuth && OrgSlugAuth
func (h *ScheduleHandler) Delete(c *gin.Context) {
scheduleID := c.Param("scheduleId")
workspaceID := c.Param("id") // #113: bind to owning workspace to prevent IDOR
@@ -331,17 +252,6 @@ func (h *ScheduleHandler) Delete(c *gin.Context) {
}
// RunNow manually fires a schedule immediately.
//
// @Summary Fire a schedule manually
// @Tags schedules
// @Produce json
// @Param id path string true "Workspace ID"
// @Param scheduleId path string true "Schedule ID"
// @Success 200 {object} RunNowResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /workspaces/{id}/schedules/{scheduleId}/run [post]
// @Security BearerAuth && OrgSlugAuth
func (h *ScheduleHandler) RunNow(c *gin.Context) {
scheduleID := c.Param("scheduleId")
workspaceID := c.Param("id")
@@ -372,16 +282,6 @@ func (h *ScheduleHandler) RunNow(c *gin.Context) {
}
// History returns recent runs for a schedule from activity_logs.
//
// @Summary Get past runs of a schedule
// @Tags schedules
// @Produce json
// @Param id path string true "Workspace ID"
// @Param scheduleId path string true "Schedule ID"
// @Success 200 {array} HistoryEntry
// @Failure 500 {object} ErrorResponse
// @Router /workspaces/{id}/schedules/{scheduleId}/history [get]
// @Security BearerAuth && OrgSlugAuth
func (h *ScheduleHandler) History(c *gin.Context) {
scheduleID := c.Param("scheduleId")
workspaceID := c.Param("id")
@@ -407,9 +307,17 @@ func (h *ScheduleHandler) History(c *gin.Context) {
}
defer rows.Close()
entries := make([]HistoryEntry, 0)
type historyEntry struct {
Timestamp time.Time `json:"timestamp"`
DurationMs *int `json:"duration_ms"`
Status *string `json:"status"`
ErrorDetail string `json:"error_detail"`
Request json.RawMessage `json:"request"`
}
entries := make([]historyEntry, 0)
for rows.Next() {
var e HistoryEntry
var e historyEntry
var reqStr string
if err := rows.Scan(&e.Timestamp, &e.DurationMs, &e.Status, &e.ErrorDetail, &reqStr); err != nil {
continue
@@ -417,18 +325,15 @@ func (h *ScheduleHandler) History(c *gin.Context) {
e.Request = json.RawMessage(reqStr)
entries = append(entries, e)
}
if err := rows.Err(); err != nil {
log.Printf("ScheduleHistory: rows error: %v", err)
}
c.JSON(http.StatusOK, entries)
}
// ScheduleHealthResponse is the read-only health view of a schedule.
// scheduleHealthResponse is the read-only health view of a schedule.
// It deliberately omits prompt and cron_expr so sensitive task content is
// never exposed to peer workspaces — only execution-state fields needed to
// detect silent cron failures are returned (issue #249).
type ScheduleHealthResponse struct {
type scheduleHealthResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
@@ -470,19 +375,14 @@ 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 {
var err error
isCanvasUser, err = validateCallerToken(ctx, c, callerID)
if err != nil {
if err := validateCallerToken(ctx, c, callerID); err != nil {
return // response already written with 401
}
}
// CanCommunicate gate — only peers in the org hierarchy may read health.
// Canvas users (human operators) bypass this gate.
if callerID != workspaceID && !isSystemCaller(callerID) && !isCanvasUser {
if callerID != workspaceID && !isSystemCaller(callerID) {
if !registry.CanCommunicate(callerID, workspaceID) {
log.Printf("ScheduleHealth: access denied %s → %s", callerID, workspaceID)
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
@@ -502,9 +402,9 @@ func (h *ScheduleHandler) Health(c *gin.Context) {
}
defer rows.Close()
schedules := make([]ScheduleHealthResponse, 0)
schedules := make([]scheduleHealthResponse, 0)
for rows.Next() {
var s ScheduleHealthResponse
var s scheduleHealthResponse
if err := rows.Scan(
&s.ID, &s.Name, &s.Enabled, &s.LastRunAt, &s.NextRunAt,
&s.RunCount, &s.LastStatus, &s.LastError,
@@ -234,7 +234,7 @@ func TestScheduleHealth_SelfCall_Allowed(t *testing.T) {
t.Fatalf("expected 200 for self-call, got %d: %s", w.Code, w.Body.String())
}
var resp []ScheduleHealthResponse
var resp []scheduleHealthResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
@@ -284,7 +284,7 @@ func TestScheduleHealth_CanCommunicatePeer_LegacyNoToken(t *testing.T) {
t.Fatalf("expected 200 for peer with no tokens, got %d: %s", w.Code, w.Body.String())
}
var resp []ScheduleHealthResponse
var resp []scheduleHealthResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
@@ -95,7 +95,6 @@ func TestSecurity_GetTemplates_NoAuth_Returns401(t *testing.T) {
func TestSecurity_GetTemplates_FreshInstall_FailsOpen(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
t.Setenv("ADMIN_TOKEN", "")
authDB, authMock := newFreshInstallAuthDB(t)
tmpDir := t.TempDir()
@@ -153,7 +152,6 @@ func TestSecurity_GetOrgTemplates_NoAuth_Returns401(t *testing.T) {
func TestSecurity_GetOrgTemplates_FreshInstall_FailsOpen(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
t.Setenv("ADMIN_TOKEN", "")
authDB, authMock := newFreshInstallAuthDB(t)
tmpDir := t.TempDir()
@@ -203,11 +203,6 @@ 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
+1 -5
View File
@@ -51,10 +51,6 @@ func (h *TracesHandler) List(c *gin.Context) {
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read response body"})
return
}
body, _ := io.ReadAll(resp.Body)
c.Data(resp.StatusCode, "application/json", body)
}
@@ -107,7 +107,6 @@ func (h *WebhookHandler) GitHub(c *gin.Context) {
forwardBody,
"webhook:github",
true,
false,
)
if proxyErr != nil {
c.JSON(proxyErr.Status, proxyErr.Response)
+28 -85
View File
@@ -214,8 +214,14 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace fields"})
return
}
// #1686 Phase 1: validate per-workspace compute overrides.
if err := models.ValidateComputeConfig(payload.Compute); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
id := uuid.New().String()
awarenessNamespace := workspaceAwarenessNamespace(id)
if h.IsSaaS() {
// SaaS hard gate: every hosted workspace gets its own sibling
// EC2 instance, so T4 is the only meaningful runtime boundary.
@@ -320,51 +326,6 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
payload.Runtime = "langgraph"
}
// SSOT (CTO 2026-05-22, feedback_workspace_model_required_no_platform_default_dynamic_credential_intake):
// model is REQUIRED user input for SPAWNED-runtime workspaces. The
// platform must not provide a default; the runtime must not fall back.
// The decision belongs to the user (or to the agent acting on the
// user's behalf), never to the platform.
//
// Empirical trigger: Code Reviewer 5ba15d7e was created with
// `{"name":"Code Reviewer","role":"...","runtime":"codex",...}` (no
// model). The legacy `DefaultModel(runtime)` fallback in
// provisionWorkspace returned `"anthropic:claude-opus-4-7"`. Codex
// adapter only supports openai-* providers — it wedged forever with
// `codex adapter: workspace config picks provider='anthropic' but
// it is not in the providers registry`. PATCH /workspaces/:id
// explicitly disallows updating model (the comment literally reads
// `model not patchable`), so the only recovery path was SQL UPDATE
// or delete+recreate.
//
// External workspaces are EXEMPT — they intentionally do not spawn
// a Docker container or run an adapter; they delegate to a registered
// URL (see provision.go: "external is a first-class runtime that
// intentionally does NOT spawn a Docker container"). The MODEL_REQUIRED
// gate is meaningful for spawned-runtime workspaces where the model
// id drives provider selection at adapter init. For external workspaces
// the contract is the URL, not the model — requiring it would be
// ceremony with no payoff, and would 422 every legitimate "register
// my agent at https://..." flow. The SSOT directive concerns
// platform-side defaults; an external workspace genuinely has no
// "model decision" for the user to make.
//
// Fail-closed at the Create boundary so the caller learns the
// contract immediately — same shape as the controlplane#188
// runtime-unresolved gate above. Caller fixes the request, no
// EC2 launched, no stuck workspace, no operator paging.
isExternal := payload.External || isExternalLikeRuntime(payload.Runtime)
if payload.Model == "" && !isExternal {
log.Printf("Create: FAIL-CLOSED — model is required (runtime=%q template=%q); refusing the silent DefaultModel fallback per CTO 2026-05-22 SSOT directive", payload.Runtime, payload.Template)
c.JSON(http.StatusUnprocessableEntity, gin.H{
"error": "model is required and has no platform-side default — pass an explicit \"model\" in the request body, or use a \"template\" whose config.yaml declares one. See feedback_workspace_model_required_no_platform_default_dynamic_credential_intake for the contract.",
"runtime": payload.Runtime,
"template": payload.Template,
"code": "MODEL_REQUIRED",
})
return
}
ctx := c.Request.Context()
// Convert empty role to NULL
@@ -392,10 +353,6 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace access"})
return
}
if err := validateWorkspaceCompute(payload.Compute); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Begin a transaction so the workspace row and any initial secrets are
// committed atomically. A secret-encrypt or DB error rolls back the
@@ -446,11 +403,22 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
// double-click. Helper retries with " (2)", " (3)", … up to maxNameSuffix,
// returns the actually-persisted name (which we MUST thread back into
// payload + broadcast so the canvas displays what the DB has).
var computeInstanceType *string
var computeVolumeRootGB *int
if payload.Compute != nil {
if payload.Compute.InstanceType != "" {
computeInstanceType = &payload.Compute.InstanceType
}
if payload.Compute.Volume.RootGB != 0 {
computeVolumeRootGB = &payload.Compute.Volume.RootGB
}
}
const insertWorkspaceSQL = `
INSERT INTO workspaces (id, name, role, tier, runtime, status, parent_id, workspace_dir, workspace_access, budget_limit, max_concurrent_tasks, delivery_mode)
VALUES ($1, $2, $3, $4, $5, 'provisioning', $6, $7, $8, $9, $10, $11)
INSERT INTO workspaces (id, name, role, tier, runtime, awareness_namespace, status, parent_id, workspace_dir, workspace_access, budget_limit, max_concurrent_tasks, delivery_mode, compute_instance_type, compute_volume_root_gb)
VALUES ($1, $2, $3, $4, $5, $6, 'provisioning', $7, $8, $9, $10, $11, $12, $13, $14)
`
insertArgs := []any{id, payload.Name, role, payload.Tier, payload.Runtime, payload.ParentID, workspaceDir, workspaceAccess, payload.BudgetLimit, maxConcurrent, deliveryMode}
insertArgs := []any{id, payload.Name, role, payload.Tier, payload.Runtime, awarenessNamespace, payload.ParentID, workspaceDir, workspaceAccess, payload.BudgetLimit, maxConcurrent, deliveryMode, computeInstanceType, computeVolumeRootGB}
persistedName, currentTx, err := insertWorkspaceWithNameRetry(
ctx,
tx,
@@ -483,24 +451,6 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
payload.Name = persistedName
}
if !workspaceComputeIsZero(payload.Compute) {
computeJSON, encErr := workspaceComputeJSON(payload.Compute)
if encErr != nil {
tx.Rollback() //nolint:errcheck
log.Printf("Create workspace %s: failed to encode compute config: %v", id, encErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode compute config"})
return
}
if _, dbErr := tx.ExecContext(ctx,
`UPDATE workspaces SET compute = $2::jsonb, updated_at = now() WHERE id = $1`,
id, computeJSON); dbErr != nil {
tx.Rollback() //nolint:errcheck
log.Printf("Create workspace %s: failed to persist compute config: %v", id, dbErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save compute config"})
return
}
}
// Persist initial secrets from the create payload (inside same transaction).
// nil/empty map is a no-op. Any failure rolls back the workspace insert
// so we never have a workspace row without its intended secrets.
@@ -571,7 +521,7 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
// Seed initial memories from the create payload (issue #1050).
// Non-fatal: failures are logged but don't block workspace creation.
seedInitialMemories(ctx, id, payload.InitialMemories)
seedInitialMemories(ctx, id, payload.InitialMemories, awarenessNamespace)
// Broadcast provisioning event. Include `runtime` so the canvas can
// populate the Runtime pill on the side panel immediately — without it
@@ -706,9 +656,10 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
}
c.JSON(http.StatusCreated, gin.H{
"id": id,
"status": "provisioning",
"workspace_access": workspaceAccess,
"id": id,
"status": "provisioning",
"awareness_namespace": awarenessNamespace,
"workspace_access": workspaceAccess,
})
}
@@ -744,7 +695,6 @@ func scanWorkspaceRow(rows interface {
Scan(dest ...interface{}) error
}) (map[string]interface{}, error) {
var id, name, role, status, url, sampleError, currentTask, runtime, workspaceDir string
var computeRaw []byte
var tier, activeTasks, maxConcurrentTasks, uptimeSeconds int
var errorRate, x, y float64
var collapsed, broadcastEnabled, talkToUserEnabled bool
@@ -756,7 +706,7 @@ func scanWorkspaceRow(rows interface {
err := rows.Scan(&id, &name, &role, &tier, &status, &agentCard, &url,
&parentID, &activeTasks, &maxConcurrentTasks, &errorRate, &sampleError, &uptimeSeconds,
&currentTask, &runtime, &workspaceDir, &x, &y, &collapsed,
&budgetLimit, &monthlySpend, &broadcastEnabled, &talkToUserEnabled, &computeRaw)
&budgetLimit, &monthlySpend, &broadcastEnabled, &talkToUserEnabled)
if err != nil {
return nil, err
}
@@ -783,11 +733,6 @@ func scanWorkspaceRow(rows interface {
"broadcast_enabled": broadcastEnabled,
"talk_to_user_enabled": talkToUserEnabled,
}
if len(computeRaw) > 0 && string(computeRaw) != "null" {
ws["compute"] = json.RawMessage(computeRaw)
} else {
ws["compute"] = json.RawMessage(`{}`)
}
// budget_limit: nil when no limit set, int64 otherwise
if budgetLimit.Valid {
@@ -823,8 +768,7 @@ const workspaceListQuery = `
COALESCE(w.workspace_dir, ''),
COALESCE(cl.x, 0), COALESCE(cl.y, 0), COALESCE(cl.collapsed, false),
w.budget_limit, COALESCE(w.monthly_spend, 0),
w.broadcast_enabled, w.talk_to_user_enabled,
COALESCE(w.compute, '{}'::jsonb)
w.broadcast_enabled, w.talk_to_user_enabled
FROM workspaces w
LEFT JOIN canvas_layouts cl ON cl.workspace_id = w.id
WHERE w.status != 'removed'
@@ -885,8 +829,7 @@ func (h *WorkspaceHandler) Get(c *gin.Context) {
COALESCE(w.workspace_dir, ''),
COALESCE(cl.x, 0), COALESCE(cl.y, 0), COALESCE(cl.collapsed, false),
w.budget_limit, COALESCE(w.monthly_spend, 0),
w.broadcast_enabled, w.talk_to_user_enabled,
COALESCE(w.compute, '{}'::jsonb)
w.broadcast_enabled, w.talk_to_user_enabled
FROM workspaces w
LEFT JOIN canvas_layouts cl ON cl.workspace_id = w.id
WHERE w.id = $1
@@ -33,7 +33,7 @@ var wsColumns = []string{
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled", "compute",
"broadcast_enabled", "talk_to_user_enabled",
}
// ==================== GET — financial fields stripped from open endpoint ====================
@@ -56,8 +56,7 @@ func TestWorkspaceBudget_Get_NilLimit(t *testing.T) {
nil, // budget_limit NULL
0, // monthly_spend 0
false, // broadcast_enabled
true, // talk_to_user_enabled
[]byte(`{}`)))
true)) // talk_to_user_enabled
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -101,8 +100,7 @@ func TestWorkspaceBudget_Get_WithLimit(t *testing.T) {
0.0, 0.0, false,
int64(500), // budget_limit = $5.00 in DB
int64(123), // monthly_spend = $1.23 in DB
false, true, // broadcast_enabled, talk_to_user_enabled
[]byte(`{}`)))
false, true)) // broadcast_enabled, talk_to_user_enabled
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -147,17 +145,20 @@ func TestWorkspaceBudget_Create_WithLimit(t *testing.T) {
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(
sqlmock.AnyArg(), // id
"Budgeted Agent", // name
nil, // role
3, // tier (default, workspace.go create-handler)
"langgraph", // runtime
(*string)(nil), // parent_id
nil, // workspace_dir
"none", // workspace_access
&budgetVal, // budget_limit ($10)
sqlmock.AnyArg(), // id
"Budgeted Agent", // name
nil, // role
3, // tier (default, workspace.go create-handler)
"langgraph", // runtime
sqlmock.AnyArg(), // awareness_namespace
(*string)(nil), // parent_id
nil, // workspace_dir
"none", // workspace_access
&budgetVal, // budget_limit ($10)
models.DefaultMaxConcurrentTasks, // max_concurrent_tasks default
"push", // delivery_mode default (#2339)
"push", // delivery_mode default (#2339)
(*string)(nil), // compute_instance_type default
(*int)(nil), // compute_volume_root_gb default
).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
@@ -169,7 +170,7 @@ func TestWorkspaceBudget_Create_WithLimit(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Budgeted Agent","model":"anthropic:claude-opus-4-7","budget_limit":1000}`
body := `{"name":"Budgeted Agent","budget_limit":1000}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
@@ -1,232 +0,0 @@
package handlers
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
"github.com/gin-gonic/gin"
)
const (
workspaceComputeDiskFloorGB = 30
workspaceComputeDiskCeilingGB = 500
workspaceDisplayMinWidth = 800
workspaceDisplayMaxWidth = 3840
workspaceDisplayMinHeight = 600
workspaceDisplayMaxHeight = 2160
)
type workspaceDisplayResponse struct {
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
Mode string `json:"mode,omitempty"`
Protocol string `json:"protocol,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Status string `json:"status,omitempty"`
}
var workspaceComputeInstanceAllowlist = map[string]struct{}{
"t3.medium": {},
"t3.large": {},
"t3.xlarge": {},
"t3.2xlarge": {},
"m6i.large": {},
"m6i.xlarge": {},
"c6i.xlarge": {},
}
func validateWorkspaceCompute(compute models.WorkspaceCompute) error {
if compute.InstanceType != "" {
if _, ok := workspaceComputeInstanceAllowlist[compute.InstanceType]; !ok {
return fmt.Errorf("unsupported compute.instance_type")
}
}
if compute.Volume.RootGB != 0 {
if compute.Volume.RootGB < workspaceComputeDiskFloorGB || compute.Volume.RootGB > workspaceComputeDiskCeilingGB {
return fmt.Errorf("compute.volume.root_gb must be between %d and %d", workspaceComputeDiskFloorGB, workspaceComputeDiskCeilingGB)
}
}
switch compute.Display.Mode {
case "", "none", "desktop-control", "gpu-desktop-control":
default:
return fmt.Errorf("unsupported compute.display.mode")
}
switch compute.Display.Protocol {
case "", "dcv", "novnc":
default:
return fmt.Errorf("unsupported compute.display.protocol")
}
if err := validateWorkspaceDisplayDimensions(compute.Display.Width, compute.Display.Height); err != nil {
return err
}
return nil
}
func validateWorkspaceDisplayConfig(display models.WorkspaceComputeDisplay) error {
switch display.Mode {
case "", "none", "desktop-control", "gpu-desktop-control":
default:
return fmt.Errorf("unsupported compute.display.mode")
}
switch display.Protocol {
case "", "dcv", "novnc":
default:
return fmt.Errorf("unsupported compute.display.protocol")
}
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
}
func workspaceComputeIsZero(compute models.WorkspaceCompute) bool {
return compute.InstanceType == "" &&
compute.Volume.RootGB == 0 &&
compute.Display.Mode == "" &&
compute.Display.Width == 0 &&
compute.Display.Height == 0 &&
compute.Display.Protocol == ""
}
func workspaceComputeJSON(compute models.WorkspaceCompute) (string, error) {
if workspaceComputeIsZero(compute) {
return "{}", nil
}
out := map[string]interface{}{}
if compute.InstanceType != "" {
out["instance_type"] = compute.InstanceType
}
if compute.Volume.RootGB != 0 {
out["volume"] = map[string]interface{}{"root_gb": compute.Volume.RootGB}
}
display := map[string]interface{}{}
if compute.Display.Mode != "" {
display["mode"] = compute.Display.Mode
}
if compute.Display.Width != 0 {
display["width"] = compute.Display.Width
}
if compute.Display.Height != 0 {
display["height"] = compute.Display.Height
}
if compute.Display.Protocol != "" {
display["protocol"] = compute.Display.Protocol
}
if len(display) > 0 {
out["display"] = display
}
b, err := json.Marshal(out)
if err != nil {
return "", err
}
return string(b), nil
}
func withStoredCompute(ctx context.Context, workspaceID string, payload models.CreateWorkspacePayload) models.CreateWorkspacePayload {
if !workspaceComputeIsZero(payload.Compute) || db.DB == nil {
return payload
}
var raw string
err := db.DB.QueryRowContext(ctx,
`SELECT COALESCE(compute, '{}'::jsonb) FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&raw)
if err != nil {
if err != sql.ErrNoRows {
log.Printf("withStoredCompute: load compute for %s failed: %v", workspaceID, err)
}
return payload
}
if raw == "" || raw == "{}" {
return payload
}
var compute models.WorkspaceCompute
if err := json.Unmarshal([]byte(raw), &compute); err != nil {
log.Printf("withStoredCompute: invalid compute JSON for %s: %v", workspaceID, err)
return payload
}
if err := validateWorkspaceCompute(compute); err != nil {
log.Printf("withStoredCompute: stored compute for %s failed validation: %v", workspaceID, err)
return payload
}
payload.Compute = compute
return payload
}
// Display handles GET /workspaces/:id/display.
func (h *WorkspaceHandler) Display(c *gin.Context) {
workspaceID := c.Param("id")
var raw, instanceID string
err := db.DB.QueryRowContext(c.Request.Context(),
`SELECT COALESCE(compute, '{}'::jsonb), COALESCE(instance_id, '') FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&raw, &instanceID)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(404, gin.H{"error": "workspace not found"})
return
}
log.Printf("Display: load compute for %s failed: %v", workspaceID, err)
c.JSON(500, gin.H{"error": "failed to load display config"})
return
}
var compute models.WorkspaceCompute
if raw != "" && raw != "{}" {
if err := json.Unmarshal([]byte(raw), &compute); err != nil {
log.Printf("Display: invalid compute JSON for %s: %v", workspaceID, err)
c.JSON(500, gin.H{"error": "invalid display config"})
return
}
if err := validateWorkspaceDisplayConfig(compute.Display); err != nil {
log.Printf("Display: invalid stored compute for %s: %v", workspaceID, err)
c.JSON(500, gin.H{"error": "invalid display config"})
return
}
}
if compute.Display.Mode == "" || compute.Display.Mode == "none" {
c.JSON(200, workspaceDisplayResponse{
Available: false,
Reason: "display_not_enabled",
})
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",
Mode: compute.Display.Mode,
Protocol: compute.Display.Protocol,
Width: compute.Display.Width,
Height: compute.Display.Height,
Status: "not_configured",
})
}
@@ -1,526 +0,0 @@
package handlers
import (
"bytes"
"context"
"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"
"github.com/gin-gonic/gin"
)
func TestValidateWorkspaceCompute_AcceptsPhase1SizingAndDisplayNone(t *testing.T) {
compute := models.WorkspaceCompute{
InstanceType: "m6i.xlarge",
Volume: models.WorkspaceComputeVolume{RootGB: 100},
Display: models.WorkspaceComputeDisplay{Mode: "none"},
}
if err := validateWorkspaceCompute(compute); err != nil {
t.Fatalf("validateWorkspaceCompute returned error for valid compute: %v", err)
}
}
func TestValidateWorkspaceCompute_RejectsUnknownInstanceType(t *testing.T) {
compute := models.WorkspaceCompute{InstanceType: "p4d.24xlarge"}
if err := validateWorkspaceCompute(compute); err == nil {
t.Fatal("validateWorkspaceCompute accepted unsupported instance type")
}
}
func TestValidateWorkspaceCompute_RejectsOutOfRangeRootVolume(t *testing.T) {
for _, rootGB := range []int{29, 501} {
compute := models.WorkspaceCompute{Volume: models.WorkspaceComputeVolume{RootGB: rootGB}}
if err := validateWorkspaceCompute(compute); err == nil {
t.Fatalf("validateWorkspaceCompute accepted root_gb=%d", rootGB)
}
}
}
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",
Volume: models.WorkspaceComputeVolume{RootGB: 100},
})
if err != nil {
t.Fatalf("workspaceComputeJSON returned error: %v", err)
}
if strings.Contains(got, `"display"`) {
t.Fatalf("workspaceComputeJSON included empty display section: %s", got)
}
if got != `{"instance_type":"m6i.xlarge","volume":{"root_gb":100}}` {
t.Fatalf("workspaceComputeJSON = %s", got)
}
}
func TestWorkspaceCreate_WithCompute_PersistsComputeJSON(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`UPDATE workspaces SET compute = \$2::jsonb`).
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectExec("INSERT INTO canvas_layouts").
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{
"name":"Sized Agent",
"external":true,
"runtime":"external",
"compute":{
"instance_type":"m6i.xlarge",
"volume":{"root_gb":100},
"display":{"mode":"none"}
}
}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceCreate_WithInvalidCompute_ReturnsBadRequest(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{
"name":"Oversized Agent",
"model":"gpt-4",
"compute":{"instance_type":"p4d.24xlarge"}
}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestBuildProvisionerConfig_CopiesComputeSizingFromPayload(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT COALESCE\(workspace_dir`).
WithArgs("ws-compute").
WillReturnRows(sqlmock.NewRows([]string{"workspace_dir", "workspace_access"}).AddRow("", "none"))
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
cfg := handler.buildProvisionerConfig(
context.Background(),
"ws-compute",
"",
nil,
models.CreateWorkspacePayload{
Tier: 4,
Runtime: "claude-code",
Compute: models.WorkspaceCompute{
InstanceType: "m6i.xlarge",
Volume: models.WorkspaceComputeVolume{RootGB: 100},
Display: models.WorkspaceComputeDisplay{Mode: "desktop-control", Protocol: "novnc", Width: 1920, Height: 1080},
},
},
nil,
t.TempDir(),
)
if cfg.InstanceType != "m6i.xlarge" {
t.Errorf("cfg.InstanceType = %q, want m6i.xlarge", cfg.InstanceType)
}
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) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-restart-compute").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"instance_type":"m6i.xlarge","volume":{"root_gb":100}}`))
payload := models.CreateWorkspacePayload{Name: "Restart Me", Tier: 4, Runtime: "claude-code"}
got := withStoredCompute(context.Background(), "ws-restart-compute", payload)
if got.Compute.InstanceType != "m6i.xlarge" {
t.Errorf("stored compute instance_type = %q, want m6i.xlarge", got.Compute.InstanceType)
}
if got.Compute.Volume.RootGB != 100 {
t.Errorf("stored compute root_gb = %d, want 100", got.Compute.Volume.RootGB)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplay_NonDisplayWorkspaceReturnsUnavailable(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-no-display").
WillReturnRows(sqlmock.NewRows([]string{"compute", "instance_id"}).AddRow(`{}`, ""))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-no-display"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-no-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"] != false {
t.Fatalf("available = %v, want false", resp["available"])
}
if resp["reason"] != "display_not_enabled" {
t.Fatalf("reason = %v, want display_not_enabled", resp["reason"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplay_DisplayConfiguredReturnsSessionUnavailableContract(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}}`, ""))
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"] != false {
t.Fatalf("available = %v, want false", resp["available"])
}
if resp["reason"] != "display_session_unavailable" {
t.Fatalf("reason = %v, want display_session_unavailable", resp["reason"])
}
if resp["status"] != "not_configured" {
t.Fatalf("status = %v, want not_configured", resp["status"])
}
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"])
}
if _, ok := resp["url"]; ok {
t.Fatalf("display response exposed url before session infra exists: %v", resp["url"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
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\), COALESCE\(instance_id, ''\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-display-sizing-drift").
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)
c.Params = gin.Params{{Key: "id", Value: "ws-display-sizing-drift"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-display-sizing-drift/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["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_InvalidStoredDisplayConfigReturnsServerError(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-invalid-display").
WillReturnRows(sqlmock.NewRows([]string{"compute", "instance_id"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"vnc"}}`, ""))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-invalid-display"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-invalid-display/display", nil)
handler.Display(c)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, 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["error"] != "invalid display config" {
t.Fatalf("error = %v, want invalid display config", resp["error"])
}
if err := mock.ExpectationsWereMet(); err != nil {
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)
}
}
@@ -103,13 +103,13 @@ func cleanupTestRows(t *testing.T, conn *sql.DB, namePrefix string) {
// TestIntegration_WorkspaceCreate_NameRetry_AutoSuffixesOnCollision
// exercises the helper end-to-end against a real Postgres:
//
// 1. INSERT a row with name "<prefix>-Repro" — succeeds.
// 2. Run insertWorkspaceWithNameRetry with the same name —
// partial-unique violation fires, helper retries with
// " (2)", that succeeds.
// 3. SELECT the row by id, confirm name = "<prefix>-Repro (2)".
// 4. Run helper AGAIN — second collision, helper retries with
// " (3)".
// 1. INSERT a row with name "<prefix>-Repro" — succeeds.
// 2. Run insertWorkspaceWithNameRetry with the same name —
// partial-unique violation fires, helper retries with
// " (2)", that succeeds.
// 3. SELECT the row by id, confirm name = "<prefix>-Repro (2)".
// 4. Run helper AGAIN — second collision, helper retries with
// " (3)".
//
// This is the live-test that proves the partial-index behaviour
// matches the migration's intent — sqlmock cannot reach this depth.
@@ -130,9 +130,9 @@ func TestIntegration_WorkspaceCreate_NameRetry_AutoSuffixesOnCollision(t *testin
// targets + the NOT NULL columns required by the schema).
firstID := uuid.New().String()
if _, err := conn.ExecContext(ctx, `
INSERT INTO workspaces (id, name, tier, runtime, status)
VALUES ($1, $2, 2, 'claude-code', 'provisioning')
`, firstID, baseName); err != nil {
INSERT INTO workspaces (id, name, tier, runtime, awareness_namespace, status)
VALUES ($1, $2, 2, 'claude-code', $3, 'provisioning')
`, firstID, baseName, "workspace:"+firstID); err != nil {
t.Fatalf("seed first row: %v", err)
}
@@ -145,10 +145,10 @@ func TestIntegration_WorkspaceCreate_NameRetry_AutoSuffixesOnCollision(t *testin
}
secondID := uuid.New().String()
query := `
INSERT INTO workspaces (id, name, tier, runtime, status)
VALUES ($1, $2, 2, 'claude-code', 'provisioning')
INSERT INTO workspaces (id, name, tier, runtime, awareness_namespace, status)
VALUES ($1, $2, 2, 'claude-code', $3, 'provisioning')
`
args := []any{secondID, baseName}
args := []any{secondID, baseName, "workspace:" + secondID}
persistedName, finalTx, err := insertWorkspaceWithNameRetry(
ctx, tx, beginTx, baseName, 1, query, args,
)
@@ -179,7 +179,7 @@ func TestIntegration_WorkspaceCreate_NameRetry_AutoSuffixesOnCollision(t *testin
t.Fatalf("begin tx3: %v", err)
}
thirdID := uuid.New().String()
args3 := []any{thirdID, baseName}
args3 := []any{thirdID, baseName, "workspace:" + thirdID}
persistedName3, finalTx3, err := insertWorkspaceWithNameRetry(
ctx, tx3, beginTx, baseName, 1, query, args3,
)
@@ -216,9 +216,9 @@ func TestIntegration_WorkspaceCreate_NameRetry_TombstonedRowDoesNotCollide(t *te
// Seed a row, then tombstone it.
firstID := uuid.New().String()
if _, err := conn.ExecContext(ctx, `
INSERT INTO workspaces (id, name, tier, runtime, status)
VALUES ($1, $2, 2, 'claude-code', 'removed')
`, firstID, baseName); err != nil {
INSERT INTO workspaces (id, name, tier, runtime, awareness_namespace, status)
VALUES ($1, $2, 2, 'claude-code', $3, 'removed')
`, firstID, baseName, "workspace:"+firstID); err != nil {
t.Fatalf("seed tombstoned row: %v", err)
}
@@ -231,10 +231,10 @@ func TestIntegration_WorkspaceCreate_NameRetry_TombstonedRowDoesNotCollide(t *te
}
secondID := uuid.New().String()
query := `
INSERT INTO workspaces (id, name, tier, runtime, status)
VALUES ($1, $2, 2, 'claude-code', 'provisioning')
INSERT INTO workspaces (id, name, tier, runtime, awareness_namespace, status)
VALUES ($1, $2, 2, 'claude-code', $3, 'provisioning')
`
args := []any{secondID, baseName}
args := []any{secondID, baseName, "workspace:" + secondID}
persistedName, finalTx, err := insertWorkspaceWithNameRetry(
ctx, tx, beginTx, baseName, 1, query, args,
)
@@ -435,16 +435,13 @@ func (h *WorkspaceHandler) CascadeDelete(ctx context.Context, id string) ([]stri
if err != nil {
return nil, nil, fmt.Errorf("descendant query: %w", err)
}
defer descRows.Close()
for descRows.Next() {
var descID string
if descRows.Scan(&descID) == nil {
descendantIDs = append(descendantIDs, descID)
}
}
if err := descRows.Err(); err != nil {
return nil, nil, fmt.Errorf("CascadeDelete: failed iterating descendants: %w", err)
}
descRows.Close()
allIDs := append([]string{id}, descendantIDs...)
@@ -503,32 +503,6 @@ func TestCascadeDelete_DescendantQueryError(t *testing.T) {
// sqlmock verifies all expected queries were executed
}
func TestCascadeDelete_DescendantRowsError(t *testing.T) {
mock, _ := setupWorkspaceCrudTest(t)
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
// RowError(0, ...) requires a real row at index 0 to be reachable —
// sqlmock only invokes nextErr[N] when r.pos-1 == N and the row exists.
// AddRow ensures Next() attempts the first row, triggers the error,
// and rows.Err() returns the injected error.
h := &WorkspaceHandler{}
rows := sqlmock.NewRows([]string{"id"}).AddRow("desc-1").RowError(0, sql.ErrConnDone)
mock.ExpectQuery(`WITH RECURSIVE descendants AS`).
WithArgs(wsID).
WillReturnRows(rows)
deleted, stopErrs, err := h.CascadeDelete(context.Background(), wsID)
if err == nil {
t.Fatal("CascadeDelete returned nil error; want descendant rows error")
}
if deleted != nil {
t.Errorf("deleted = %v; want nil", deleted)
}
if stopErrs != nil {
t.Errorf("stopErrs = %v; want nil", stopErrs)
}
}
// Note: Full CascadeDelete testing requires mocking StopWorkspace, RemoveVolume,
// and provisioner calls — covered in integration tests. Unit tests here focus on
// the validation and pre-condition paths.
@@ -1,419 +0,0 @@
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"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
"github.com/gin-gonic/gin"
)
const (
displayControlDefaultTTLSeconds = 300
displayControlMinTTLSeconds = 30
displayControlMaxTTLSeconds = 3600
)
type workspaceDisplayControlResponse struct {
Controller string `json:"controller"`
ControlledBy string `json:"controlled_by,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
SessionURL string `json:"session_url,omitempty"`
}
type workspaceDisplayControlNoneResponse struct {
Controller string `json:"controller"`
}
type acquireDisplayControlRequest struct {
Controller string `json:"controller"`
TTLSeconds int `json:"ttl_seconds"`
}
type releaseDisplayControlRequest struct {
Force bool `json:"force"`
}
// DisplayControl handles GET /workspaces/:id/display/control.
func (h *WorkspaceHandler) DisplayControl(c *gin.Context) {
lock, found, err := h.loadActiveDisplayControl(c, c.Param("id"))
if err != nil {
log.Printf("DisplayControl: load lock for %s failed: %v", c.Param("id"), err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load display control"})
return
}
if !found {
c.JSON(http.StatusOK, workspaceDisplayControlNoneResponse{Controller: "none"})
return
}
c.JSON(http.StatusOK, lock)
}
// AcquireDisplayControl handles POST /workspaces/:id/display/control/acquire.
func (h *WorkspaceHandler) AcquireDisplayControl(c *gin.Context) {
var req acquireDisplayControlRequest
if c.Request.Body != nil && c.Request.ContentLength != 0 {
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid display control request"})
return
}
}
if req.Controller == "" {
req.Controller = "user"
}
if req.Controller != "user" {
c.JSON(http.StatusBadRequest, gin.H{"error": "browser callers may only acquire user display control"})
return
}
if req.TTLSeconds == 0 {
req.TTLSeconds = displayControlDefaultTTLSeconds
}
if req.TTLSeconds < displayControlMinTTLSeconds || req.TTLSeconds > displayControlMaxTTLSeconds {
c.JSON(http.StatusBadRequest, gin.H{"error": "ttl_seconds must be between 30 and 3600"})
return
}
if ok := h.displayControlEnabled(c, c.Param("id")); !ok {
return
}
controlledBy, ok := displayControlActor(c)
if !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "display control requires admin-token or org-token auth"})
return
}
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{
"controller": req.Controller,
"controlled_by": controlledBy,
"ttl_seconds": req.TTLSeconds,
})
var lock workspaceDisplayControlResponse
err := db.DB.QueryRowContext(c.Request.Context(), `
INSERT INTO workspace_display_control_locks
(workspace_id, controller, controlled_by, expires_at)
VALUES
($1, $2, $3, now() + ($4 * interval '1 second'))
ON CONFLICT (workspace_id) DO UPDATE
SET controller = EXCLUDED.controller,
controlled_by = EXCLUDED.controlled_by,
expires_at = EXCLUDED.expires_at,
updated_at = now()
WHERE workspace_display_control_locks.expires_at <= now()
OR workspace_display_control_locks.controlled_by = EXCLUDED.controlled_by
RETURNING controller, controlled_by, expires_at`,
workspaceID, req.Controller, controlledBy, req.TTLSeconds,
).Scan(&lock.Controller, &lock.ControlledBy, &lock.ExpiresAt)
if err == nil {
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,
"ttl_seconds": req.TTLSeconds,
"duration_ms": time.Since(startedAt).Milliseconds(),
})
c.JSON(http.StatusOK, lock)
return
}
if err == sql.ErrNoRows {
current, found, loadErr := h.loadActiveDisplayControl(c, workspaceID)
if loadErr != nil {
log.Printf("AcquireDisplayControl: load active lock for %s failed: %v", workspaceID, loadErr)
emitDisplayControlEvent(c.Request.Context(), "display.control.acquire.failed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"error": loadErr.Error(),
})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load display control"})
return
}
emitDisplayControlEvent(c.Request.Context(), "display.control.acquire.failed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"error": "display control already held",
})
if !found {
c.JSON(http.StatusConflict, gin.H{
"error": "display control already held",
"current": workspaceDisplayControlNoneResponse{Controller: "none"},
})
return
}
c.JSON(http.StatusConflict, gin.H{
"error": "display control already held",
"current": current,
})
return
}
log.Printf("AcquireDisplayControl: acquire lock for %s failed: %v", workspaceID, err)
emitDisplayControlEvent(c.Request.Context(), "display.control.acquire.failed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"error": err.Error(),
})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to acquire display control"})
}
// ReleaseDisplayControl handles POST /workspaces/:id/display/control/release.
func (h *WorkspaceHandler) ReleaseDisplayControl(c *gin.Context) {
var req releaseDisplayControlRequest
if c.Request.Body != nil && c.Request.ContentLength != 0 {
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid display control release request"})
return
}
}
if req.Force {
if !displayControlIsAdminToken(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "force release requires admin-token auth"})
return
}
}
controlledBy, ok := displayControlActor(c)
if !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "display control requires admin-token or org-token auth"})
return
}
workspaceID := c.Param("id")
startedAt := time.Now()
emitDisplayControlEvent(c.Request.Context(), "display.control.release.started", workspaceID, map[string]any{
"controlled_by": controlledBy,
"force": req.Force,
})
query := `DELETE FROM workspace_display_control_locks WHERE workspace_id = $1 AND controlled_by = $2`
args := []interface{}{workspaceID, controlledBy}
if req.Force {
query = `DELETE FROM workspace_display_control_locks WHERE workspace_id = $1`
args = []interface{}{workspaceID}
}
result, err := db.DB.ExecContext(c.Request.Context(), query, args...)
if err != nil {
log.Printf("ReleaseDisplayControl: release lock for %s failed: %v", workspaceID, err)
emitDisplayControlEvent(c.Request.Context(), "display.control.release.failed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"error": err.Error(),
"force": req.Force,
})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to release display control"})
return
}
rowsAffected, err := result.RowsAffected()
if err != nil {
log.Printf("ReleaseDisplayControl: rows affected for %s failed: %v", workspaceID, err)
emitDisplayControlEvent(c.Request.Context(), "display.control.release.failed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"error": err.Error(),
"force": req.Force,
})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to release display control"})
return
}
if rowsAffected == 0 {
current, found, loadErr := h.loadActiveDisplayControl(c, workspaceID)
if loadErr != nil {
log.Printf("ReleaseDisplayControl: load active lock for %s failed: %v", workspaceID, loadErr)
emitDisplayControlEvent(c.Request.Context(), "display.control.release.failed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"error": loadErr.Error(),
"force": req.Force,
})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load display control"})
return
}
if !found {
emitDisplayControlEvent(c.Request.Context(), "display.control.release.completed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"force": req.Force,
"rows_affected": rowsAffected,
})
c.JSON(http.StatusOK, workspaceDisplayControlNoneResponse{Controller: "none"})
return
}
emitDisplayControlEvent(c.Request.Context(), "display.control.release.failed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"error": "display control held by another caller",
"force": req.Force,
})
c.JSON(http.StatusConflict, gin.H{
"error": "display control held by another caller",
"current": current,
})
return
}
emitDisplayControlEvent(c.Request.Context(), "display.control.release.completed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"force": req.Force,
"rows_affected": rowsAffected,
})
c.JSON(http.StatusOK, workspaceDisplayControlNoneResponse{Controller: "none"})
}
func (h *WorkspaceHandler) loadActiveDisplayControl(c *gin.Context, workspaceID string) (workspaceDisplayControlResponse, bool, error) {
var lock workspaceDisplayControlResponse
err := db.DB.QueryRowContext(c.Request.Context(),
`SELECT controller, controlled_by, expires_at FROM workspace_display_control_locks WHERE workspace_id = $1 AND expires_at > now()`,
workspaceID,
).Scan(&lock.Controller, &lock.ControlledBy, &lock.ExpiresAt)
if err == nil {
return lock, true, nil
}
if err == sql.ErrNoRows {
return workspaceDisplayControlResponse{}, false, nil
}
return workspaceDisplayControlResponse{}, false, err
}
func (h *WorkspaceHandler) displayControlEnabled(c *gin.Context, workspaceID string) bool {
var raw string
err := db.DB.QueryRowContext(c.Request.Context(),
`SELECT COALESCE(compute, '{}'::jsonb) FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&raw)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return false
}
log.Printf("displayControlEnabled: load compute for %s failed: %v", workspaceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load display config"})
return false
}
compute, err := parseWorkspaceDisplayCompute(workspaceID, raw)
if err != nil {
log.Printf("displayControlEnabled: invalid display config for %s: %v", workspaceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid display config"})
return false
}
if compute.Display.Mode == "" || compute.Display.Mode == "none" {
c.JSON(http.StatusBadRequest, gin.H{"error": "display not enabled"})
return false
}
return true
}
func parseWorkspaceDisplayCompute(workspaceID, raw string) (models.WorkspaceCompute, error) {
var compute models.WorkspaceCompute
if raw == "" || raw == "{}" {
return compute, nil
}
if err := json.Unmarshal([]byte(raw), &compute); err != nil {
return models.WorkspaceCompute{}, fmt.Errorf("invalid compute JSON for %s: %w", workspaceID, err)
}
if err := validateWorkspaceDisplayConfig(compute.Display); err != nil {
return models.WorkspaceCompute{}, err
}
return compute, nil
}
func displayControlActor(c *gin.Context) (string, bool) {
if v, ok := c.Get("org_token_prefix"); ok {
if s, ok := v.(string); ok && s != "" {
return actorOrgTokenPrefix + s, true
}
}
if displayControlIsAdminToken(c) {
return actorAdminToken, true
}
// Browser session auth is intentionally observe-only until AdminAuth
// exposes a stable per-user or per-session identity in gin.Context.
return "", false
}
func displayControlIsAdminToken(c *gin.Context) bool {
adminSecret := os.Getenv("ADMIN_TOKEN")
if adminSecret == "" {
return false
}
tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization"))
return subtle.ConstantTimeCompare([]byte(tok), []byte(adminSecret)) == 1
}
func emitDisplayControlEvent(ctx context.Context, eventType string, workspaceID string, payload map[string]any) {
if payload == nil {
payload = map[string]any{}
}
payloadJSON, err := json.Marshal(payload)
if err != nil {
log.Printf("emitDisplayControlEvent: marshal %s payload failed: %v", eventType, err)
return
}
if _, err := db.DB.ExecContext(ctx, `
INSERT INTO structure_events (event_type, workspace_id, payload, created_at)
VALUES ($1, $2, $3::jsonb, now())
`, eventType, workspaceID, string(payloadJSON)); err != nil {
log.Printf("emitDisplayControlEvent: insert %s failed: %v", eventType, err)
}
}
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")
}
@@ -1,379 +0,0 @@
package handlers
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
)
func attachDisplayControlAdminToken(t *testing.T, c *gin.Context) {
t.Helper()
t.Setenv("ADMIN_TOKEN", "test-admin-secret")
c.Request.Header.Set("Authorization", "Bearer test-admin-secret")
}
func TestWorkspaceDisplayControl_NoActiveLockReturnsNone(t *testing.T) {
mock := setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT controller, controlled_by, expires_at FROM workspace_display_control_locks WHERE workspace_id = \$1 AND expires_at > now\(\)`).
WithArgs("ws-display").
WillReturnError(sql.ErrNoRows)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-display/display/control", nil)
handler.DisplayControl(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["controller"] != "none" {
t.Fatalf("controller = %v, want none", resp["controller"])
}
if _, ok := resp["expires_at"]; ok {
t.Fatalf("none response included expires_at: %#v", resp)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplayControlAcquire_ClaimsUnlockedDisplay(t *testing.T) {
mock := setupTestDB(t)
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)
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-display").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
mock.ExpectQuery(`INSERT INTO workspace_display_control_locks`).
WithArgs("ws-display", "user", "admin-token", 300).
WillReturnRows(sqlmock.NewRows([]string{"controller", "controlled_by", "expires_at"}).
AddRow("user", "admin-token", expiresAt))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/acquire", bytes.NewBufferString(`{"controller":"user","ttl_seconds":300}`))
c.Request.Header.Set("Content-Type", "application/json")
attachDisplayControlAdminToken(t, c)
handler.AcquireDisplayControl(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["controller"] != "user" || resp["controlled_by"] != "admin-token" {
t.Fatalf("lock response = %#v, want user/admin-token", resp)
}
if resp["expires_at"] == "" {
t.Fatalf("expires_at missing in response: %#v", resp)
}
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)
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-display").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
mock.ExpectQuery(`INSERT INTO workspace_display_control_locks`).
WithArgs("ws-display", "user", "admin-token", 300).
WillReturnError(sql.ErrNoRows)
mock.ExpectQuery(`SELECT controller, controlled_by, expires_at FROM workspace_display_control_locks WHERE workspace_id = \$1 AND expires_at > now\(\)`).
WithArgs("ws-display").
WillReturnRows(sqlmock.NewRows([]string{"controller", "controlled_by", "expires_at"}).
AddRow("agent", "sidecar", expiresAt))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/acquire", bytes.NewBufferString(`{"controller":"user","ttl_seconds":300}`))
c.Request.Header.Set("Content-Type", "application/json")
attachDisplayControlAdminToken(t, c)
handler.AcquireDisplayControl(c)
if w.Code != http.StatusConflict {
t.Fatalf("expected status 409, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["error"] != "display control already held" {
t.Fatalf("error = %v, want display control already held", resp["error"])
}
current, ok := resp["current"].(map[string]interface{})
if !ok || current["controller"] != "agent" || current["controlled_by"] != "sidecar" {
t.Fatalf("current lock = %#v, want agent/sidecar", resp["current"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplayControlAcquire_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())
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-no-display").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{}`))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-no-display"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-no-display/display/control/acquire", bytes.NewBufferString(`{"controller":"user","ttl_seconds":300}`))
c.Request.Header.Set("Content-Type", "application/json")
attachDisplayControlAdminToken(t, c)
handler.AcquireDisplayControl(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["error"] != "display not enabled" {
t.Fatalf("error = %v, want display not enabled", resp["error"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplayControlAcquire_RejectsCoarseSessionActor(t *testing.T) {
mock := setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-display").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/acquire", bytes.NewBufferString(`{"controller":"user","ttl_seconds":300}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request.Header.Set("Cookie", "molecule_session=present")
handler.AcquireDisplayControl(c)
if w.Code != http.StatusForbidden {
t.Fatalf("expected status 403, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["error"] != "display control requires admin-token or org-token auth" {
t.Fatalf("error = %v, want display control requires admin-token or org-token auth", resp["error"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplayControlRelease_RemovesCallerLock(t *testing.T) {
mock := setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectExec(`DELETE FROM workspace_display_control_locks WHERE workspace_id = \$1 AND controlled_by = \$2`).
WithArgs("ws-display", "admin-token").
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/release", nil)
attachDisplayControlAdminToken(t, c)
handler.ReleaseDisplayControl(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["controller"] != "none" {
t.Fatalf("controller = %v, want none", resp["controller"])
}
if _, ok := resp["expires_at"]; ok {
t.Fatalf("none response included expires_at: %#v", resp)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplayControlRelease_ConflictWhenCallerDoesNotOwnLock(t *testing.T) {
mock := setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
expiresAt := time.Date(2026, 5, 23, 18, 30, 0, 0, time.UTC)
mock.ExpectExec(`DELETE FROM workspace_display_control_locks WHERE workspace_id = \$1 AND controlled_by = \$2`).
WithArgs("ws-display", "admin-token").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectQuery(`SELECT controller, controlled_by, expires_at FROM workspace_display_control_locks WHERE workspace_id = \$1 AND expires_at > now\(\)`).
WithArgs("ws-display").
WillReturnRows(sqlmock.NewRows([]string{"controller", "controlled_by", "expires_at"}).
AddRow("user", "org-token:abcd1234", expiresAt))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/release", nil)
attachDisplayControlAdminToken(t, c)
handler.ReleaseDisplayControl(c)
if w.Code != http.StatusConflict {
t.Fatalf("expected status 409, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["error"] != "display control held by another caller" {
t.Fatalf("error = %v, want display control held by another caller", resp["error"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplayControlRelease_RejectsOrgTokenForceRelease(t *testing.T) {
setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Set("org_token_prefix", "abcd1234")
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/release", bytes.NewBufferString(`{"force":true}`))
c.Request.Header.Set("Content-Type", "application/json")
handler.ReleaseDisplayControl(c)
if w.Code != http.StatusForbidden {
t.Fatalf("expected status 403, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["error"] != "force release requires admin-token auth" {
t.Fatalf("error = %v, want force release requires admin-token auth", resp["error"])
}
}
func TestWorkspaceDisplayControlAcquire_RejectsAgentImpersonation(t *testing.T) {
setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/acquire", bytes.NewBufferString(`{"controller":"agent","ttl_seconds":300}`))
c.Request.Header.Set("Content-Type", "application/json")
attachDisplayControlAdminToken(t, c)
handler.AcquireDisplayControl(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["error"] != "browser callers may only acquire user display control" {
t.Fatalf("error = %v, want browser callers may only acquire user display control", resp["error"])
}
}
@@ -1,168 +0,0 @@
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)})
})
}
@@ -128,7 +128,7 @@ func (h *WorkspaceHandler) provisionWorkspaceOpts(workspaceID, templatePath stri
workspaceID, filepath.Base(runtimeTemplate))
templatePath = runtimeTemplate
// Rebuild cfg with the recovered template path so Start() sees it.
cfg = h.buildProvisionerConfig(ctx, workspaceID, templatePath, configFiles, payload, prepared.EnvVars, prepared.PluginsPath)
cfg = h.buildProvisionerConfig(ctx, workspaceID, templatePath, configFiles, payload, prepared.EnvVars, prepared.PluginsPath, prepared.AwarenessNamespace)
cfg.ResetClaudeSession = resetClaudeSession
recovered = true
break
@@ -194,11 +194,10 @@ func (h *WorkspaceHandler) provisionWorkspaceOpts(workspaceID, templatePath stri
// a ~64k context window worth of text — but small enough to prevent abuse.
const maxMemoryContentLength = 100_000 // ~100 KiB of text
func seedInitialMemories(ctx context.Context, workspaceID string, memories []models.MemorySeed) {
func seedInitialMemories(ctx context.Context, workspaceID string, memories []models.MemorySeed, awarenessNamespace string) {
if len(memories) == 0 {
return
}
namespace := workspaceMemoryNamespace(workspaceID)
for _, mem := range memories {
scope := strings.ToUpper(mem.Scope)
if scope == "" {
@@ -224,27 +223,33 @@ func seedInitialMemories(ctx context.Context, workspaceID string, memories []mod
if _, err := db.DB.ExecContext(ctx, `
INSERT INTO agent_memories (workspace_id, content, scope, namespace)
VALUES ($1, $2, $3, $4)
`, workspaceID, redactedContent, scope, namespace); err != nil {
`, workspaceID, redactedContent, scope, awarenessNamespace); err != nil {
log.Printf("seedInitialMemories: failed to insert memory for %s (scope=%s): %v", workspaceID, scope, err)
}
}
log.Printf("seedInitialMemories: seeded %d memories for workspace %s", len(memories), workspaceID)
}
// workspaceMemoryNamespace returns the canonical v2 memory namespace
// string for a workspace. Matches the form produced by
// internal/memory/namespace/resolver.go for self-reads (issue #1735).
func workspaceMemoryNamespace(workspaceID string) string {
func workspaceAwarenessNamespace(workspaceID string) string {
return fmt.Sprintf("workspace:%s", workspaceID)
}
func (h *WorkspaceHandler) loadAwarenessNamespace(ctx context.Context, workspaceID string) string {
var awarenessNamespace string
err := db.DB.QueryRowContext(ctx, `SELECT COALESCE(awareness_namespace, '') FROM workspaces WHERE id = $1`, workspaceID).Scan(&awarenessNamespace)
if err != nil || awarenessNamespace == "" {
return workspaceAwarenessNamespace(workspaceID)
}
return awarenessNamespace
}
func (h *WorkspaceHandler) buildProvisionerConfig(
ctx context.Context,
workspaceID, templatePath string,
configFiles map[string][]byte,
payload models.CreateWorkspacePayload,
envVars map[string]string,
pluginsPath string,
pluginsPath, awarenessNamespace string,
) provisioner.WorkspaceConfig {
// Per-workspace workspace_dir takes priority over global WORKSPACE_DIR env var.
// If neither is set, the provisioner creates an isolated Docker volume.
@@ -283,24 +288,18 @@ func (h *WorkspaceHandler) buildProvisionerConfig(
}
return provisioner.WorkspaceConfig{
WorkspaceID: workspaceID,
TemplatePath: templatePath,
ConfigFiles: configFiles,
PluginsPath: pluginsPath,
WorkspacePath: workspacePath,
WorkspaceAccess: workspaceAccess,
Tier: payload.Tier,
Runtime: payload.Runtime,
InstanceType: payload.Compute.InstanceType,
DiskGB: int32(payload.Compute.Volume.RootGB),
Display: provisioner.WorkspaceDisplayConfig{
Mode: payload.Compute.Display.Mode,
Width: payload.Compute.Display.Width,
Height: payload.Compute.Display.Height,
Protocol: payload.Compute.Display.Protocol,
},
EnvVars: envVars,
PlatformURL: h.platformURL,
WorkspaceID: workspaceID,
TemplatePath: templatePath,
ConfigFiles: configFiles,
PluginsPath: pluginsPath,
WorkspacePath: workspacePath,
WorkspaceAccess: workspaceAccess,
Tier: payload.Tier,
Runtime: payload.Runtime,
EnvVars: envVars,
PlatformURL: h.platformURL,
AwarenessURL: os.Getenv("AWARENESS_URL"),
AwarenessNamespace: awarenessNamespace,
// Image left empty — molecule-core's runtime_image_pins table (mig
// 047, dead reader removed by RFC internal#617 / task #335) was an
// aspirational SSOT that never received a writer. CP's
@@ -310,9 +309,31 @@ func (h *WorkspaceHandler) buildProvisionerConfig(
// RuntimeImages[Runtime] :latest lookup, which is what the dead
// reader's sql.ErrNoRows path was producing already.
Image: "",
// Compute overrides (nullable — omitted = platform-managed default).
// Issue #1686 Phase 1.
InstanceType: extractComputeInstanceType(payload.Compute),
VolumeRootGB: extractComputeVolumeRootGB(payload.Compute),
}
}
// extractComputeInstanceType returns the instance type from a ComputeConfig,
// or nil when cfg is nil or the field is empty.
func extractComputeInstanceType(cfg *models.ComputeConfig) *string {
if cfg != nil && cfg.InstanceType != "" {
return &cfg.InstanceType
}
return nil
}
// extractComputeVolumeRootGB returns the root volume size from a ComputeConfig,
// or nil when cfg is nil or the field is zero.
func extractComputeVolumeRootGB(cfg *models.ComputeConfig) *int {
if cfg != nil && cfg.Volume.RootGB != 0 {
return &cfg.Volume.RootGB
}
return nil
}
// issueAndInjectToken rotates the workspace auth token and injects the
// plaintext into cfg.ConfigFiles[".auth_token"] so it is written into the
// /configs volume by WriteFilesToContainer immediately after the container
@@ -549,22 +570,13 @@ func (h *WorkspaceHandler) ensureDefaultConfig(workspaceID string, payload model
// via a crafted runtime string (#241).
runtime := sanitizeRuntime(payload.Runtime)
// Generate a minimal config.yaml.
//
// SSOT (CTO 2026-05-22): model is REQUIRED user input. The platform
// must not provide a default; the runtime must not fall back. The
// Create handler is responsible for rejecting empty model BEFORE
// reaching provisionWorkspace; this is a defence-in-depth assertion.
// If we hit here with an empty model the YAML below would still
// render a `model: ""` line — which renders all downstream provider
// derivation undefined. Log loudly and let the workspace boot into
// not_configured rather than masking the contract violation with a
// silently-broken default (the prior `anthropic:claude-opus-4-7`
// fallback was the canonical example — every codex workspace
// created without an explicit model wedged).
// Generate a minimal config.yaml
model := payload.Model
if model == "" {
log.Printf("ensureDefaultConfig: workspace %s reached provisioning with empty model — Create handler should have rejected this; rendering empty model: \"\" in config.yaml (workspace will boot not_configured)", workspaceID)
// SSOT: per-runtime defaults live in models/runtime_defaults.go
// (see RFC #2873). Was previously duplicated here AND in
// org_import.go; consolidating prevents silent drift.
model = models.DefaultModel(runtime)
}
if runtime == "claude-code" {
model = normalizeClaudeCodeModel(model)
@@ -1019,3 +1031,4 @@ func (h *WorkspaceHandler) provisionWorkspaceCP(workspaceID, templatePath string
log.Printf("CPProvisioner: workspace %s started as machine %s via control plane", workspaceID, machineID)
}
@@ -85,9 +85,10 @@ func readOrLazyHealInboundSecret(ctx context.Context, workspaceID, opLabel strin
// prepareProvisionContext when the caller proceeds; nil + non-empty
// abort message when the caller must mark the workspace failed.
type preparedProvisionContext struct {
EnvVars map[string]string
PluginsPath string
Config provisioner.WorkspaceConfig
EnvVars map[string]string
PluginsPath string
AwarenessNamespace string
Config provisioner.WorkspaceConfig
}
// provisionAbort describes why prepareProvisionContext refused to
@@ -169,6 +170,7 @@ func (h *WorkspaceHandler) prepareProvisionContext(
}
pluginsPath, _ := filepath.Abs(filepath.Join(h.configsDir, "..", "plugins"))
awarenessNamespace := h.loadAwarenessNamespace(ctx, workspaceID)
// Per-agent git identity (#1957) — must run after secret loads so
// a workspace_secret named GIT_AUTHOR_NAME can override.
@@ -229,13 +231,14 @@ func (h *WorkspaceHandler) prepareProvisionContext(
}
}
cfg := h.buildProvisionerConfig(ctx, workspaceID, templatePath, configFiles, payload, envVars, pluginsPath)
cfg := h.buildProvisionerConfig(ctx, workspaceID, templatePath, configFiles, payload, envVars, pluginsPath, awarenessNamespace)
cfg.ResetClaudeSession = resetClaudeSession
return &preparedProvisionContext{
EnvVars: envVars,
PluginsPath: pluginsPath,
Config: cfg,
EnvVars: envVars,
PluginsPath: pluginsPath,
AwarenessNamespace: awarenessNamespace,
Config: cfg,
}, nil
}
@@ -756,55 +756,47 @@ func TestWorkspaceCreate_FirstDeploy_PersistsModelAndProvider(t *testing.T) {
}
}
// TestWorkspaceCreate_FirstDeploy_NoModel_Returns422 inverts the prior
// premise (CTO 2026-05-22 SSOT directive — see
// feedback_workspace_model_required_no_platform_default_dynamic_credential_intake
// and TestCreate_ModelRequired_Returns422 in handlers_extended_test.go).
//
// Pre-2026-05-22 the canvas was allowed to omit `model` and the workspace
// would 201 with no workspace_secrets rows for MODEL/LLM_PROVIDER (the
// thinking being that templates inherit the runtime default later). That
// "soft fallback" was the load-bearing bug magnet — `DefaultModel(runtime)`
// would later return `anthropic:claude-opus-4-7`, and codex workspaces
// wedged forever at adapter init.
//
// New contract: empty model is a 422 MODEL_REQUIRED, with NO DB writes
// at all. The gate fires at the Create boundary before INSERT INTO
// workspaces. The follow-on workspace_secrets gate (which the original
// test pinned) is therefore unreachable on the empty-model path — there
// is no row to mint secrets for.
func TestWorkspaceCreate_FirstDeploy_NoModel_Returns422(t *testing.T) {
// TestWorkspaceCreate_FirstDeploy_NoModel_NoSecretWritten asserts that
// when payload.Model is empty, NEITHER MODEL nor LLM_PROVIDER is
// written. Important: the canvas can omit `model` (template inherits
// the runtime default later); we must not poison workspace_secrets with
// empty rows in that case.
func TestWorkspaceCreate_FirstDeploy_NoModel_NoSecretWritten(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
// NO mock.ExpectBegin / INSERT INTO workspaces — the Create gate
// MUST fire before any DB write. If the gate fires late, sqlmock
// will surface "call to ExecQuery 'INSERT INTO workspaces' was not
// expected" — which is exactly the failure mode we want to flag.
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
// NO INSERT INTO workspace_secrets here — the gate is payload.Model != "".
mock.ExpectExec("INSERT INTO canvas_layouts").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO structure_events").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`UPDATE workspaces SET status =`).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO structure_events").
WillReturnResult(sqlmock.NewResult(0, 1))
// Body: hermes runtime WITHOUT external:true (the external-runtime
// exemption — see TestCreate_ExternalRuntime_NoModel_OK — does NOT
// apply here; hermes spawns a real adapter and model selection
// matters at adapter init). This is exactly the shape the old
// "no-model-no-secret-write" test pinned, minus the external flag.
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"No Model Agent","runtime":"hermes"}`
body := `{"name":"No Model Agent","runtime":"hermes","external":true}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected 422 MODEL_REQUIRED for empty model, got %d: %s", w.Code, w.Body.String())
}
if !bytes.Contains(w.Body.Bytes(), []byte(`"code":"MODEL_REQUIRED"`)) {
t.Errorf("expected code=MODEL_REQUIRED in body, got %s", w.Body.String())
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock saw an unexpected DB write — the MODEL_REQUIRED gate fired too late: %v", err)
t.Errorf("sqlmock expectations not met — empty payload.Model should NOT trigger workspace_secrets writes: %v", err)
}
}
@@ -17,9 +17,9 @@ import (
"gopkg.in/yaml.v3"
)
// ==================== workspaceMemoryNamespace ====================
// ==================== workspaceAwarenessNamespace ====================
func TestWorkspaceMemoryNamespace(t *testing.T) {
func TestWorkspaceAwarenessNamespace(t *testing.T) {
tests := []struct {
workspaceID string
expected string
@@ -31,9 +31,9 @@ func TestWorkspaceMemoryNamespace(t *testing.T) {
for _, tt := range tests {
t.Run(tt.workspaceID, func(t *testing.T) {
result := workspaceMemoryNamespace(tt.workspaceID)
result := workspaceAwarenessNamespace(tt.workspaceID)
if result != tt.expected {
t.Errorf("workspaceMemoryNamespace(%q) = %q, want %q", tt.workspaceID, result, tt.expected)
t.Errorf("workspaceAwarenessNamespace(%q) = %q, want %q", tt.workspaceID, result, tt.expected)
}
})
}
@@ -193,17 +193,10 @@ func TestEnsureDefaultConfig_Hermes(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
// Post-CTO-SSOT-directive (2026-05-22): model is required user input;
// ensureDefaultConfig no longer fills in a runtime default. The Create
// handler gates on empty model and 422s before reaching here, so this
// test now passes the model explicitly to exercise the YAML rendering
// path — same model value the prior implicit DefaultModel("hermes")
// returned.
payload := models.CreateWorkspacePayload{
Name: "Test Agent",
Tier: 1,
Runtime: "hermes",
Model: "anthropic:claude-opus-4-7",
}
files := handler.ensureDefaultConfig("ws-test-123", payload)
@@ -226,7 +219,7 @@ func TestEnsureDefaultConfig_Hermes(t *testing.T) {
t.Errorf("config.yaml missing tier, got:\n%s", content)
}
if !contains(content, `model: "anthropic:claude-opus-4-7"`) {
t.Errorf("config.yaml should render the supplied model, got:\n%s", content)
t.Errorf("config.yaml should use default non-claude model, got:\n%s", content)
}
}
@@ -234,14 +227,10 @@ func TestEnsureDefaultConfig_ClaudeCode(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
// Post-CTO-SSOT-directive (2026-05-22): model is supplied explicitly
// instead of relying on the deleted DefaultModel("claude-code") =
// "sonnet" fallback. The Create handler 422s on empty model upstream.
payload := models.CreateWorkspacePayload{
Name: "Code Agent",
Tier: 2,
Runtime: "claude-code",
Model: "sonnet",
}
files := handler.ensureDefaultConfig("ws-code-123", payload)
@@ -418,16 +407,9 @@ func TestEnsureDefaultConfig_EmptyRuntimeDefaultsToClaudeCode(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
// Post-CTO-SSOT-directive (2026-05-22): ensureDefaultConfig is no
// longer the source of the model default — it just renders whatever
// the Create handler decided. The "empty runtime → claude-code"
// fallback inside sanitizeRuntime() is still in effect; this test
// continues to pin that behaviour by supplying the explicit
// claude-code model that the Create handler would have required.
payload := models.CreateWorkspacePayload{
Name: "Default Agent",
Tier: 1,
Model: "sonnet",
Name: "Default Agent",
Tier: 1,
}
files := handler.ensureDefaultConfig("ws-empty-rt", payload)
@@ -436,7 +418,7 @@ func TestEnsureDefaultConfig_EmptyRuntimeDefaultsToClaudeCode(t *testing.T) {
t.Errorf("empty runtime should default to claude-code, got:\n%s", configYAML)
}
if !contains(configYAML, `model: "sonnet"`) {
t.Errorf("claude-code workspace should render the supplied model (quoted), got:\n%s", configYAML)
t.Errorf("claude-code default model should be sonnet (quoted), got:\n%s", configYAML)
}
}
@@ -645,7 +627,7 @@ func TestSeedInitialMemories_TruncatesOversizedContent(t *testing.T) {
WillReturnResult(sqlmock.NewResult(1, 1))
}
seedInitialMemories(context.Background(), workspaceID, memories)
seedInitialMemories(context.Background(), workspaceID, memories, "test-ns")
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet DB expectations: %v", err)
@@ -674,7 +656,7 @@ func TestSeedInitialMemories_RedactsSecrets(t *testing.T) {
WithArgs(workspaceID, wantRedacted, "LOCAL", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
seedInitialMemories(context.Background(), workspaceID, memories)
seedInitialMemories(context.Background(), workspaceID, memories, "test-ns")
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet DB expectations: %v", err)
@@ -691,7 +673,7 @@ func TestSeedInitialMemories_InvalidScopeSkipped(t *testing.T) {
{Content: "this should be skipped", Scope: "NOT_A_REAL_SCOPE"},
}
seedInitialMemories(context.Background(), "ws-bad-scope", memories)
seedInitialMemories(context.Background(), "ws-bad-scope", memories, "test-ns")
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unexpected DB calls for invalid scope: %v", err)
@@ -704,7 +686,7 @@ func TestSeedInitialMemories_EmptyMemoriesNil(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectationsWereMet()
seedInitialMemories(context.Background(), "ws-nil", nil)
seedInitialMemories(context.Background(), "ws-nil", nil, "test-ns")
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unexpected DB calls for nil slice: %v", err)
@@ -733,6 +715,7 @@ func TestBuildProvisionerConfig_BasicFields(t *testing.T) {
models.CreateWorkspacePayload{Tier: 1, Runtime: "langgraph"},
map[string]string{"API_KEY": "secret"},
pluginsPath,
"workspace:ws-basic",
)
if cfg.WorkspaceID != "ws-basic" {
@@ -747,6 +730,9 @@ func TestBuildProvisionerConfig_BasicFields(t *testing.T) {
if cfg.PlatformURL != "http://localhost:8080" {
t.Errorf("expected PlatformURL 'http://localhost:8080', got %q", cfg.PlatformURL)
}
if cfg.AwarenessNamespace != "workspace:ws-basic" {
t.Errorf("expected AwarenessNamespace 'workspace:ws-basic', got %q", cfg.AwarenessNamespace)
}
if cfg.PluginsPath != pluginsPath {
t.Errorf("expected PluginsPath %q, got %q", pluginsPath, cfg.PluginsPath)
}
@@ -771,6 +757,7 @@ func TestBuildProvisionerConfig_WorkspacePathFromEnv(t *testing.T) {
workspaceDir := t.TempDir()
t.Setenv("WORKSPACE_DIR", workspaceDir)
t.Setenv("AWARENESS_URL", "http://awareness:37800")
pluginsPath := t.TempDir()
cfg := handler.buildProvisionerConfig(
@@ -781,11 +768,84 @@ func TestBuildProvisionerConfig_WorkspacePathFromEnv(t *testing.T) {
models.CreateWorkspacePayload{Tier: 2, Runtime: "claude-code"},
nil,
pluginsPath,
"workspace:ws-env",
)
if cfg.WorkspacePath != workspaceDir {
t.Errorf("expected WorkspacePath from env, got %q", cfg.WorkspacePath)
}
if cfg.AwarenessURL != "http://awareness:37800" {
t.Errorf("expected AwarenessURL from env, got %q", cfg.AwarenessURL)
}
}
// TestBuildProvisionerConfig_ComputeOverrides verifies that #1686 Phase 1
// compute fields (instance_type + volume.root_gb) are threaded from the
// create payload into the provisioner config.
func TestBuildProvisionerConfig_ComputeOverrides(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT COALESCE\(workspace_dir`).
WithArgs("ws-compute").
WillReturnRows(sqlmock.NewRows([]string{"workspace_dir", "workspace_access"}).AddRow("", "none"))
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
cfg := handler.buildProvisionerConfig(
context.Background(),
"ws-compute",
"",
nil,
models.CreateWorkspacePayload{
Tier: 2,
Runtime: "python",
Compute: &models.ComputeConfig{
InstanceType: "g4dn.xlarge",
Volume: models.ComputeVolume{RootGB: 256},
},
},
nil,
"",
"workspace:ws-compute",
)
if cfg.InstanceType == nil || *cfg.InstanceType != "g4dn.xlarge" {
t.Errorf("InstanceType = %v, want g4dn.xlarge", cfg.InstanceType)
}
if cfg.VolumeRootGB == nil || *cfg.VolumeRootGB != 256 {
t.Errorf("VolumeRootGB = %v, want 256", cfg.VolumeRootGB)
}
}
// TestBuildProvisionerConfig_ComputeNil verifies backward compat: when the
// payload omits compute, the provisioner config fields are nil so the CP
// applies its own defaults.
func TestBuildProvisionerConfig_ComputeNil(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT COALESCE\(workspace_dir`).
WithArgs("ws-no-compute").
WillReturnRows(sqlmock.NewRows([]string{"workspace_dir", "workspace_access"}).AddRow("", "none"))
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
cfg := handler.buildProvisionerConfig(
context.Background(),
"ws-no-compute",
"",
nil,
models.CreateWorkspacePayload{Tier: 1, Runtime: "python"},
nil,
"",
"workspace:ws-no-compute",
)
if cfg.InstanceType != nil {
t.Errorf("InstanceType = %v, want nil", cfg.InstanceType)
}
if cfg.VolumeRootGB != nil {
t.Errorf("VolumeRootGB = %v, want nil", cfg.VolumeRootGB)
}
}
// ==================== issueAndInjectToken (issue #418) ====================
@@ -797,8 +857,6 @@ func TestIssueAndInjectToken_HappyPath(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
// RevokeAllForWorkspace UPDATE (0 rows — no prior tokens, still succeeds)
mock.ExpectExec(`UPDATE workspace_auth_tokens SET revoked_at`).
@@ -836,8 +894,6 @@ func TestIssueAndInjectToken_RotatesExistingToken(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
// RevokeAllForWorkspace: 1 existing token revoked
mock.ExpectExec(`UPDATE workspace_auth_tokens SET revoked_at`).
@@ -904,8 +960,6 @@ func TestIssueAndInjectToken_IssueFailSkipsInjection(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
mock.ExpectExec(`UPDATE workspace_auth_tokens SET revoked_at`).
WithArgs("ws-418-issue-fail").
@@ -932,8 +986,6 @@ func TestIssueAndInjectToken_NilConfigFilesAllocated(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
mock.ExpectExec(`UPDATE workspace_auth_tokens SET revoked_at`).
WithArgs("ws-418-nil-cfg").
@@ -998,7 +1050,7 @@ func TestSeedInitialMemories_Truncation(t *testing.T) {
WithArgs(sqlmock.AnyArg(), expectTruncated, "LOCAL", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
seedInitialMemories(context.Background(), "ws-1066-test", memories)
seedInitialMemories(context.Background(), "ws-1066-test", memories, "test-ns")
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("DB expectations not met: %v\n"+
@@ -1018,7 +1070,7 @@ func TestSeedInitialMemories_ContentUnderLimit(t *testing.T) {
WithArgs(sqlmock.AnyArg(), "short content", "TEAM", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
seedInitialMemories(context.Background(), "ws-1066-under", memories)
seedInitialMemories(context.Background(), "ws-1066-under", memories, "test-ns")
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("DB expectations not met: %v", err)
@@ -1043,7 +1095,7 @@ func TestSeedInitialMemories_ExactlyAtLimit(t *testing.T) {
WithArgs(sqlmock.AnyArg(), atLimitContent, "LOCAL", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
seedInitialMemories(context.Background(), "ws-boundary", memories)
seedInitialMemories(context.Background(), "ws-boundary", memories, "test-ns")
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("DB expectations not met: %v", err)
@@ -1059,7 +1111,7 @@ func TestSeedInitialMemories_EmptyContent(t *testing.T) {
}
// seedInitialMemories skips empty content at line 234 — no DB call expected.
seedInitialMemories(context.Background(), "ws-empty", memories)
seedInitialMemories(context.Background(), "ws-empty", memories, "test-ns")
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("DB expectations not met: %v", err)
@@ -1083,7 +1135,7 @@ func TestSeedInitialMemories_OversizedWithSecrets(t *testing.T) {
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), "GLOBAL", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
seedInitialMemories(context.Background(), "ws-secrets", memories)
seedInitialMemories(context.Background(), "ws-secrets", memories, "test-ns")
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("DB expectations not met: %v", err)

Some files were not shown because too many files have changed in this diff Show More