Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f949ef4ad | |||
| 43a77ccfbc | |||
| c7eeec1607 | |||
| 2e8603f940 | |||
| 2045388293 | |||
| a118c63cd9 | |||
| 02a37a360c | |||
| 5a05302cd6 | |||
| 59b4f44224 | |||
| ee55473812 | |||
| 29b4bffb13 |
@@ -284,7 +284,8 @@ def list_queued_issues() -> list[dict]:
|
||||
query={
|
||||
"state": "open",
|
||||
"type": "pulls",
|
||||
"labels": QUEUE_LABEL,
|
||||
# NOTE: Gitea 1.22.6 uses `label` (singular), not `labels` (plural).
|
||||
"label": QUEUE_LABEL,
|
||||
"limit": "50",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
name: E2E Peer Visibility (literal MCP list_peers)
|
||||
|
||||
# WHY A DEDICATED WORKFLOW (not folded into e2e-staging-saas.yml)
|
||||
# --------------------------------------------------------------
|
||||
# This is the systemic fix for a real trust failure. Hermes and OpenClaw
|
||||
# were reported "fleet-verified / cascade-complete" because the *proxy*
|
||||
# signals were green (registry registration + heartbeat for Hermes; model
|
||||
# round-trip 200 for OpenClaw). A freshly-provisioned workspace asked on
|
||||
# canvas "can you see your peers" actually FAILS:
|
||||
# - Hermes: 401 on the molecule MCP `list_peers` call
|
||||
# - OpenClaw: native `sessions_list` fallback, sees no platform peers
|
||||
# Tasks #142/#159 were even marked "completed" under this proxy flaw.
|
||||
#
|
||||
# A dedicated workflow (vs extending e2e-staging-saas.yml) because:
|
||||
# - It must provision MULTIPLE distinct runtimes (hermes, openclaw,
|
||||
# claude-code) in ONE org and assert each sees the others. The
|
||||
# full-saas script is single-runtime-per-run (E2E_RUNTIME) and folding
|
||||
# a multi-runtime matrix into it would conflate concerns and bloat its
|
||||
# already-45-min run.
|
||||
# - It needs its own concurrency group so it doesn't fight full-saas /
|
||||
# canvas for the staging org-creation quota.
|
||||
# - It needs an independent, non-required status-context name so it can
|
||||
# be RED today (the in-flight Hermes-401 / OpenClaw-MCP-wiring fixes
|
||||
# have not landed) WITHOUT wedging unrelated merges — and flipped to
|
||||
# REQUIRED in one branch-protection edit once it goes green
|
||||
# (flip-to-required checklist: molecule-core#1296).
|
||||
#
|
||||
# THE ASSERTION IS NOT A PROXY. The driving script
|
||||
# tests/e2e/test_peer_visibility_mcp_staging.sh issues the byte-for-byte
|
||||
# JSON-RPC `tools/call name=list_peers` envelope to `POST
|
||||
# /workspaces/:id/mcp` using each workspace's OWN bearer token, through
|
||||
# the real WorkspaceAuth + MCPRateLimiter middleware chain — the exact
|
||||
# call mcp_molecule_list_peers makes from a canvas agent. It does NOT
|
||||
# read a registry row, /health, the heartbeat table, or
|
||||
# GET /registry/:id/peers.
|
||||
#
|
||||
# HONEST GATE — NO continue-on-error. Per feedback_fix_root_not_symptom a
|
||||
# fake-green mask would defeat the entire purpose. This workflow goes red
|
||||
# on today's broken behavior and green only when the root-cause fixes
|
||||
# actually land. It is intentionally NOT in branch_protections — see PR
|
||||
# body for the required-vs-not decision + flip tracking issue.
|
||||
#
|
||||
# Gitea 1.22.6 / act_runner notes honored:
|
||||
# - No cross-repo `uses:` (feedback_gitea_cross_repo_uses_blocked). The
|
||||
# actions/checkout SHA is the one e2e-staging-canvas.yml already uses
|
||||
# successfully (a mirrored SHA — see #1277/PR#1292 root-cause).
|
||||
# - Per-SHA concurrency, not global (feedback_concurrency_group_per_sha).
|
||||
# - Workflow-level GITHUB_SERVER_URL pinned
|
||||
# (feedback_act_runner_github_server_url).
|
||||
# - pr-validate posts a status under the same check name so a
|
||||
# workflow-only PR is not silently statusless and the context is
|
||||
# flip-to-required-ready (mirrors e2e-staging-saas.yml's proven shape;
|
||||
# real EC2-provisioning E2E is push/dispatch/cron only — it is 30+ min
|
||||
# and cannot run per-PR-update).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'workspace-server/internal/handlers/mcp.go'
|
||||
- 'workspace-server/internal/handlers/mcp_tools.go'
|
||||
- 'workspace-server/internal/middleware/**'
|
||||
- 'workspace-server/internal/handlers/registry.go'
|
||||
- 'workspace-server/internal/handlers/workspace.go'
|
||||
- 'workspace/a2a_mcp_server.py'
|
||||
- 'workspace/platform_tools/registry.py'
|
||||
- 'tests/e2e/test_peer_visibility_mcp_staging.sh'
|
||||
- '.gitea/workflows/e2e-peer-visibility.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'workspace-server/internal/handlers/mcp.go'
|
||||
- 'workspace-server/internal/handlers/mcp_tools.go'
|
||||
- 'workspace-server/internal/middleware/**'
|
||||
- 'workspace-server/internal/handlers/registry.go'
|
||||
- 'workspace-server/internal/handlers/workspace.go'
|
||||
- 'workspace/a2a_mcp_server.py'
|
||||
- 'workspace/platform_tools/registry.py'
|
||||
- 'tests/e2e/test_peer_visibility_mcp_staging.sh'
|
||||
- '.gitea/workflows/e2e-peer-visibility.yml'
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# 07:30 UTC daily — catches AMI / template-hermes / template-openclaw
|
||||
# drift even on quiet days. Offset 30m from e2e-staging-saas (07:00)
|
||||
# so the two don't collide on the staging org-creation quota.
|
||||
- cron: '30 7 * * *'
|
||||
|
||||
concurrency:
|
||||
# Per-SHA (feedback_concurrency_group_per_sha). A single global group
|
||||
# would let a queued staging/main push behind a PR run get cancelled,
|
||||
# leaving any gate that reads "completed run at SHA" stuck.
|
||||
group: e2e-peer-visibility-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
# PR path: post a real status under the required-ready check name so a
|
||||
# workflow-only PR is never silently statusless. The actual EC2 E2E is
|
||||
# push/dispatch/cron only (30+ min). This is NOT a fake-green mask of
|
||||
# the real assertion — it validates the driving script's bash syntax
|
||||
# and inline-python so a broken test script fails at PR time.
|
||||
pr-validate:
|
||||
name: E2E Peer Visibility
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Validate driving script
|
||||
run: |
|
||||
bash -n tests/e2e/test_peer_visibility_mcp_staging.sh
|
||||
echo "test_peer_visibility_mcp_staging.sh — bash syntax OK"
|
||||
echo "Real fresh-provision MCP list_peers E2E runs on push to"
|
||||
echo "main / workflow_dispatch / daily cron (30+ min EC2 boot)."
|
||||
|
||||
# Real gate: provisions a throwaway org + sibling-per-runtime, drives
|
||||
# the LITERAL list_peers MCP call per runtime, asserts 200 + expected
|
||||
# peer set, then scoped teardown. push(main)/dispatch/cron only.
|
||||
peer-visibility:
|
||||
name: E2E Peer Visibility
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request'
|
||||
timeout-minutes: 60
|
||||
|
||||
env:
|
||||
MOLECULE_CP_URL: https://staging-api.moleculesai.app
|
||||
MOLECULE_ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
|
||||
# LLM provider key so each runtime can authenticate at boot.
|
||||
# Priority MiniMax → direct-Anthropic → OpenAI matches
|
||||
# test_staging_full_saas.sh's secrets-injection chain.
|
||||
E2E_MINIMAX_API_KEY: ${{ secrets.MOLECULE_STAGING_MINIMAX_API_KEY }}
|
||||
E2E_ANTHROPIC_API_KEY: ${{ secrets.MOLECULE_STAGING_ANTHROPIC_API_KEY }}
|
||||
E2E_OPENAI_API_KEY: ${{ secrets.MOLECULE_STAGING_OPENAI_API_KEY }}
|
||||
E2E_RUN_ID: "${{ github.run_id }}-${{ github.run_attempt }}"
|
||||
PV_RUNTIMES: "hermes openclaw claude-code"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Verify admin token present
|
||||
run: |
|
||||
if [ -z "$MOLECULE_ADMIN_TOKEN" ]; then
|
||||
echo "::error::CP_STAGING_ADMIN_API_TOKEN secret not set (Railway staging CP_ADMIN_API_TOKEN)"
|
||||
exit 2
|
||||
fi
|
||||
echo "Admin token present"
|
||||
|
||||
- name: Verify an LLM key present
|
||||
run: |
|
||||
if [ -z "${E2E_MINIMAX_API_KEY:-}" ] && [ -z "${E2E_ANTHROPIC_API_KEY:-}" ] && [ -z "${E2E_OPENAI_API_KEY:-}" ]; then
|
||||
echo "::error::No LLM provider key set — workspaces fail at boot with 'No provider API key found'. Set MOLECULE_STAGING_MINIMAX_API_KEY (or ANTHROPIC / OPENAI)."
|
||||
exit 2
|
||||
fi
|
||||
echo "LLM key present"
|
||||
|
||||
- name: CP staging health preflight
|
||||
run: |
|
||||
code=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 10 "$MOLECULE_CP_URL/health")
|
||||
if [ "$code" != "200" ]; then
|
||||
echo "::error::Staging CP unhealthy (HTTP $code) — infra, not a workspace bug. Failing loud per feedback_fix_root_not_symptom."
|
||||
exit 1
|
||||
fi
|
||||
echo "Staging CP healthy"
|
||||
|
||||
- name: Run fresh-provision peer-visibility E2E (literal MCP list_peers)
|
||||
run: bash tests/e2e/test_peer_visibility_mcp_staging.sh
|
||||
|
||||
# Belt-and-braces scoped teardown: the script installs an EXIT/INT/
|
||||
# TERM trap, but if the runner itself is cancelled the trap may not
|
||||
# fire. This always() step deletes ONLY the e2e-pv-<run_id> org this
|
||||
# run created — never a cluster-wide sweep
|
||||
# (feedback_never_run_cluster_cleanup_tests_on_live_platform). The
|
||||
# admin DELETE is idempotent so double-invoking is safe;
|
||||
# sweep-stale-e2e-orgs is the final net (slug starts with 'e2e-').
|
||||
- name: Teardown safety net (runs on cancel/failure)
|
||||
if: always()
|
||||
env:
|
||||
ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
|
||||
run: |
|
||||
set +e
|
||||
orgs=$(curl -sS "$MOLECULE_CP_URL/cp/admin/orgs?limit=500" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" 2>/dev/null \
|
||||
| python3 -c "
|
||||
import json, sys, os, datetime
|
||||
run_id = os.environ.get('GITHUB_RUN_ID', '')
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
print(''); sys.exit(0)
|
||||
# ONLY sweep slugs from THIS run. e2e-pv-<YYYYMMDD>-<run_id>-...
|
||||
# Sweep today AND yesterday's UTC date so a midnight-crossing run
|
||||
# still matches its own slug (same bug class as the saas/canvas
|
||||
# safety nets).
|
||||
today = datetime.date.today()
|
||||
yest = today - datetime.timedelta(days=1)
|
||||
dates = (today.strftime('%Y%m%d'), yest.strftime('%Y%m%d'))
|
||||
if run_id:
|
||||
prefixes = tuple(f'e2e-pv-{dt}-{run_id}-' for dt in dates)
|
||||
else:
|
||||
prefixes = tuple(f'e2e-pv-{dt}-' for dt in dates)
|
||||
orgs = d if isinstance(d, list) else d.get('orgs', [])
|
||||
cands = [o['slug'] for o in orgs
|
||||
if any(o.get('slug','').startswith(p) for p in prefixes)
|
||||
and o.get('instance_status') not in ('purged',)]
|
||||
print('\n'.join(cands))
|
||||
" 2>/dev/null)
|
||||
for slug in $orgs; do
|
||||
echo "Safety-net teardown: $slug"
|
||||
set +e
|
||||
curl -sS -o /tmp/pv-cleanup.out -w "%{http_code}" \
|
||||
-X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"confirm\":\"$slug\"}" >/tmp/pv-cleanup.code
|
||||
set -e
|
||||
code=$(cat /tmp/pv-cleanup.code 2>/dev/null || echo "000")
|
||||
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
|
||||
echo "[teardown] deleted $slug (HTTP $code)"
|
||||
else
|
||||
echo "::warning::pv teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within MAX_AGE_MINUTES. Body: $(head -c 300 /tmp/pv-cleanup.out 2>/dev/null)"
|
||||
fi
|
||||
done
|
||||
exit 0
|
||||
@@ -962,6 +962,32 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* talk_to_user disabled banner — shown when the workspace has
|
||||
talk_to_user_enabled=false. The agent cannot send canvas messages;
|
||||
the user can re-enable the ability from here without opening settings. */}
|
||||
{data.talkToUserEnabled === false && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-surface-sunken border-b border-line/40 shrink-0">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true" className="shrink-0 text-ink-mid">
|
||||
<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1Zm0 10.5a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM8 4a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4A.75.75 0 0 1 8 4Z" fill="currentColor"/>
|
||||
</svg>
|
||||
<span className="text-[10px] text-ink-mid flex-1">
|
||||
Agent is not enabled to chat with you.
|
||||
</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await api.patch(`/workspaces/${workspaceId}/abilities`, { talk_to_user_enabled: true });
|
||||
useCanvasStore.getState().updateNodeData(workspaceId, { talkToUserEnabled: true });
|
||||
} catch {
|
||||
// ignore — user will see no change and can retry
|
||||
}
|
||||
}}
|
||||
className="px-2 py-0.5 text-[10px] font-medium bg-accent/10 hover:bg-accent/20 text-accent rounded border border-accent/30 transition-colors shrink-0"
|
||||
>
|
||||
Enable
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Messages */}
|
||||
<div ref={containerRef} className="flex-1 overflow-y-auto p-3 space-y-3">
|
||||
{loading && (
|
||||
|
||||
@@ -176,7 +176,7 @@ export function deriveProvidersFromModels(models: ModelSpec[]): string[] {
|
||||
// exactly the point of the platform adaptor. The deep `~/.hermes/
|
||||
// config.yaml` on the container is a separate runtime-internal file,
|
||||
// not this one.
|
||||
const RUNTIMES_WITH_OWN_CONFIG = new Set<string>(["external", "kimi", "kimi-cli"]);
|
||||
const RUNTIMES_WITH_OWN_CONFIG = new Set<string>(["external", "kimi", "kimi-cli", "openclaw"]);
|
||||
|
||||
const FALLBACK_RUNTIME_OPTIONS: RuntimeOption[] = [
|
||||
{ value: "", label: "LangGraph (default)", models: [], providers: [] },
|
||||
|
||||
+12
-8
@@ -8,14 +8,18 @@ import { getTenantSlug } from "./tenant";
|
||||
export const PLATFORM_URL =
|
||||
process.env.NEXT_PUBLIC_PLATFORM_URL ?? "http://localhost:8080";
|
||||
|
||||
// 15s is long enough for slow CP queries but short enough that a
|
||||
// hung backend doesn't leave the UI spinning forever. The abort
|
||||
// propagates through AbortController so React components can observe
|
||||
// the error and render a retry affordance. Callers that know the
|
||||
// endpoint is intentionally slow (org import walks a tree of
|
||||
// workspaces with server-side pacing) can pass `timeoutMs` to
|
||||
// override.
|
||||
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
// 35s is long enough for the slowest server-side path (EIC SSH
|
||||
// tunnel for tenant EC2 file operations, bounded server-side by
|
||||
// `eicFileOpTimeout = 30 * time.Second` in
|
||||
// workspace-server/internal/handlers/template_files_eic.go) so the
|
||||
// canvas surfaces the server's real error instead of aborting first
|
||||
// with a generic timeout. Shorter values caused "Save & Restart" to
|
||||
// time out at the client before the backend returned its 5xx. The
|
||||
// abort still propagates through AbortController so React components
|
||||
// can render a retry affordance. Callers that know an endpoint is
|
||||
// intentionally slow (org import walks a tree of workspaces with
|
||||
// server-side pacing) can pass `timeoutMs` to override.
|
||||
const DEFAULT_TIMEOUT_MS = 35_000;
|
||||
|
||||
export interface RequestOptions {
|
||||
timeoutMs?: number;
|
||||
|
||||
@@ -519,6 +519,10 @@ export function buildNodesAndEdges(
|
||||
// #2054 — server-declared per-workspace provisioning timeout.
|
||||
// Falls through to the runtime profile when null/absent.
|
||||
provisionTimeoutMs: ws.provision_timeout_ms ?? null,
|
||||
// Workspace abilities — defaults preserved for old platform versions
|
||||
// that don't yet include these columns in the GET response.
|
||||
broadcastEnabled: ws.broadcast_enabled ?? false,
|
||||
talkToUserEnabled: ws.talk_to_user_enabled ?? true,
|
||||
},
|
||||
};
|
||||
if (hasParent) {
|
||||
|
||||
@@ -99,6 +99,13 @@ export interface WorkspaceNodeData extends Record<string, unknown> {
|
||||
* @/lib/runtimeProfiles. Lets a slow runtime declare its cold-boot
|
||||
* expectation without a canvas release. */
|
||||
provisionTimeoutMs?: number | null;
|
||||
/** When true the workspace may POST /broadcast to send org-wide messages.
|
||||
* Default false. Toggled by user/admin via PATCH /workspaces/:id/abilities. */
|
||||
broadcastEnabled?: boolean;
|
||||
/** When false the workspace cannot deliver canvas chat messages.
|
||||
* send_message_to_user / POST /notify return 403 and the canvas
|
||||
* shows a "not enabled" state with a button to re-enable. Default true. */
|
||||
talkToUserEnabled?: boolean;
|
||||
}
|
||||
|
||||
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit";
|
||||
|
||||
@@ -299,6 +299,9 @@ export interface WorkspaceData {
|
||||
* `@/lib/runtimeProfiles` when absent (the default behavior for any
|
||||
* template that hasn't yet declared the field). */
|
||||
provision_timeout_ms?: number | null;
|
||||
/** Workspace ability flags (migration 20260514). */
|
||||
broadcast_enabled?: boolean;
|
||||
talk_to_user_enabled?: boolean;
|
||||
}
|
||||
|
||||
let socket: ReconnectingSocket | null = null;
|
||||
|
||||
+376
@@ -0,0 +1,376 @@
|
||||
#!/usr/bin/env bash
|
||||
# Staging E2E — fresh-provision peer-visibility gate via the LITERAL MCP path.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# ---------------
|
||||
# Hermes and OpenClaw were repeatedly reported "fleet-verified / cascade-
|
||||
# complete" because the *proxy* signals were green:
|
||||
# - registry-registration + heartbeat (Hermes), and
|
||||
# - model round-trip 200 (OpenClaw).
|
||||
# But a freshly-provisioned workspace, asked on canvas "can you see your
|
||||
# peers", actually FAILS:
|
||||
# - Hermes: 401 on the molecule MCP `list_peers` call,
|
||||
# - OpenClaw: falls back to native `sessions_list`, sees no platform peers.
|
||||
# Tasks #142/#159 were even marked "completed" under this same proxy flaw.
|
||||
#
|
||||
# This script codifies the LITERAL user-facing path so it can never silently
|
||||
# regress: it provisions a brand-new throwaway org + sibling workspaces via
|
||||
# the real control-plane provisioning path, then for each runtime that should
|
||||
# have platform peer-visibility it drives the EXACT MCP call the canvas agent
|
||||
# makes — `POST /workspaces/:id/mcp` JSON-RPC tools/call name=list_peers,
|
||||
# authenticated by that workspace's own bearer token through the real
|
||||
# WorkspaceAuth + MCPRateLimiter middleware chain. It then asserts:
|
||||
# (1) HTTP 200,
|
||||
# (2) JSON-RPC `result` present (NOT an `error` object — a -32000
|
||||
# "tool call failed" or a 401 from WorkspaceAuth fails here),
|
||||
# (3) the returned peer set CONTAINS the other provisioned sibling
|
||||
# workspace IDs — not an empty list, not a native-sessions fallback.
|
||||
#
|
||||
# This is NOT a proxy. It does not look at a registry row, /health, the
|
||||
# heartbeat table, or `GET /registry/:id/peers`. It drives the byte-for-byte
|
||||
# JSON-RPC envelope that mcp_molecule_list_peers issues from a real agent.
|
||||
#
|
||||
# It is written to FAIL on today's broken Hermes/OpenClaw behavior and go
|
||||
# green only when the in-flight root-cause fixes (Hermes-401, OpenClaw MCP
|
||||
# wiring) actually land. That is the point: it is the objective proof gate.
|
||||
#
|
||||
# AUTH MODEL (mirrors tests/e2e/test_staging_full_saas.sh)
|
||||
# --------------------------------------------------------
|
||||
# Single MOLECULE_ADMIN_TOKEN (= CP_ADMIN_API_TOKEN on Railway staging)
|
||||
# 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 (returned by
|
||||
# POST /workspaces) drives its MCP call.
|
||||
#
|
||||
# Required env:
|
||||
# MOLECULE_ADMIN_TOKEN CP admin bearer — Railway staging CP_ADMIN_API_TOKEN
|
||||
# Optional env:
|
||||
# MOLECULE_CP_URL default https://staging-api.moleculesai.app
|
||||
# E2E_RUN_ID slug suffix; CI passes ${GITHUB_RUN_ID}
|
||||
# PV_RUNTIMES space list; default "hermes openclaw claude-code"
|
||||
# E2E_PROVISION_TIMEOUT_SECS default 1800 (hermes/openclaw cold EC2 budget)
|
||||
# E2E_MINIMAX_API_KEY / E2E_ANTHROPIC_API_KEY / E2E_OPENAI_API_KEY
|
||||
# LLM provider key injected so the runtime can boot
|
||||
# E2E_KEEP_ORG 1 → skip teardown (local debugging only)
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 every runtime saw its peers via the literal MCP call
|
||||
# 1 generic failure
|
||||
# 2 missing required env
|
||||
# 3 provisioning timed out
|
||||
# 4 teardown left orphan resources
|
||||
# 10 peer-visibility regression reproduced (the gate firing as designed)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
CP_URL="${MOLECULE_CP_URL:-https://staging-api.moleculesai.app}"
|
||||
ADMIN_TOKEN="${MOLECULE_ADMIN_TOKEN:?MOLECULE_ADMIN_TOKEN required — Railway staging CP_ADMIN_API_TOKEN}"
|
||||
RUN_ID_SUFFIX="${E2E_RUN_ID:-$(date +%H%M%S)-$$}"
|
||||
PV_RUNTIMES="${PV_RUNTIMES:-hermes openclaw claude-code}"
|
||||
PROVISION_TIMEOUT_SECS="${E2E_PROVISION_TIMEOUT_SECS:-1800}"
|
||||
|
||||
# Slug MUST start with 'e2e-' so the sweep-stale-e2e-orgs safety net
|
||||
# (EPHEMERAL_PREFIXES) catches any leak this run fails to tear down.
|
||||
SLUG="e2e-pv-$(date +%Y%m%d)-${RUN_ID_SUFFIX}"
|
||||
SLUG=$(echo "$SLUG" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9-' | head -c 32)
|
||||
|
||||
ORG_ID=""
|
||||
TENANT_URL=""
|
||||
TENANT_TOKEN=""
|
||||
|
||||
log() { echo "[$(date +%H:%M:%S)] $*"; }
|
||||
fail() { echo "[$(date +%H:%M:%S)] ❌ $*" >&2; exit 1; }
|
||||
ok() { echo "[$(date +%H:%M:%S)] ✅ $*"; }
|
||||
|
||||
admin_call() {
|
||||
local method="$1" path="$2"; shift 2
|
||||
curl -sS -X "$method" "$CP_URL$path" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" "$@"
|
||||
}
|
||||
tenant_call() {
|
||||
local method="$1" path="$2"; shift 2
|
||||
curl -sS -X "$method" "$TENANT_URL$path" \
|
||||
-H "Authorization: Bearer $TENANT_TOKEN" \
|
||||
-H "X-Molecule-Org-Id: $ORG_ID" \
|
||||
-H "Content-Type: application/json" "$@"
|
||||
}
|
||||
|
||||
# ─── Scoped teardown ───────────────────────────────────────────────────
|
||||
# Deletes ONLY the org this run created (DELETE /cp/admin/tenants/$SLUG
|
||||
# with the {"confirm":$SLUG} fat-finger guard). Never a cluster-wide
|
||||
# sweep — honors feedback_cleanup_after_each_test and
|
||||
# feedback_never_run_cluster_cleanup_tests_on_live_platform. The
|
||||
# workflow's always() step + sweep-stale-e2e-orgs are the outer nets.
|
||||
teardown() {
|
||||
local rc=$?
|
||||
set +e
|
||||
if [ "${E2E_KEEP_ORG:-0}" = "1" ]; then
|
||||
echo ""
|
||||
log "[teardown] E2E_KEEP_ORG=1 — leaving $SLUG for debugging (REMEMBER TO DELETE)"
|
||||
exit $rc
|
||||
fi
|
||||
echo ""
|
||||
log "[teardown] DELETE /cp/admin/tenants/$SLUG (scoped to this run only)"
|
||||
admin_call DELETE "/cp/admin/tenants/$SLUG" --max-time 120 \
|
||||
-d "{\"confirm\":\"$SLUG\"}" >/dev/null 2>&1
|
||||
for j in $(seq 1 24); do
|
||||
LIST=$(admin_call GET "/cp/admin/orgs?limit=500" 2>/dev/null)
|
||||
LEAK=$(echo "$LIST" | python3 -c "
|
||||
import sys, json
|
||||
try: d = json.load(sys.stdin)
|
||||
except Exception: print(1); sys.exit(0)
|
||||
orgs = d if isinstance(d, list) else d.get('orgs', [])
|
||||
print(sum(1 for o in orgs if o.get('slug') == '$SLUG' and o.get('instance_status') not in ('purged',) and o.get('status') != 'purged'))
|
||||
" 2>/dev/null || echo 1)
|
||||
if [ "$LEAK" = "0" ]; then
|
||||
log "[teardown] ✓ $SLUG purged (after ${j}x5s)"
|
||||
exit $rc
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
echo "::warning::[teardown] $SLUG still present after 120s — sweep-stale-e2e-orgs will catch it within MAX_AGE_MINUTES" >&2
|
||||
[ $rc -eq 0 ] && rc=4
|
||||
exit $rc
|
||||
}
|
||||
trap teardown EXIT INT TERM
|
||||
|
||||
# ─── 1. Provision the throwaway org ────────────────────────────────────
|
||||
log "1/6 POST /cp/admin/orgs — slug=$SLUG"
|
||||
CREATE=$(admin_call POST /cp/admin/orgs \
|
||||
-d "{\"slug\":\"$SLUG\",\"name\":\"E2E peer-visibility $SLUG\",\"owner_user_id\":\"e2e-runner:$SLUG\"}")
|
||||
ORG_ID=$(echo "$CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
|
||||
[ -n "$ORG_ID" ] || fail "org creation failed: $(echo "$CREATE" | head -c 300)"
|
||||
log " ORG_ID=$ORG_ID"
|
||||
|
||||
# ─── 2. Wait for tenant EC2 + DNS ──────────────────────────────────────
|
||||
log "2/6 waiting for tenant instance_status=running (cold EC2 + cloudflared)..."
|
||||
DEADLINE=$(( $(date +%s) + PROVISION_TIMEOUT_SECS ))
|
||||
while true; do
|
||||
[ "$(date +%s)" -gt "$DEADLINE" ] && fail "tenant never came up within ${PROVISION_TIMEOUT_SECS}s"
|
||||
STATUS=$(admin_call GET "/cp/admin/orgs?limit=500" 2>/dev/null | python3 -c "
|
||||
import sys, json
|
||||
try: d = json.load(sys.stdin)
|
||||
except Exception: sys.exit(0)
|
||||
orgs = d if isinstance(d, list) else d.get('orgs', [])
|
||||
for o in orgs:
|
||||
if o.get('slug') == '$SLUG':
|
||||
print(o.get('instance_status') or o.get('status') or 'unknown'); break
|
||||
" 2>/dev/null)
|
||||
case "$STATUS" in running|online|ready) break ;; esac
|
||||
sleep 10
|
||||
done
|
||||
log " tenant status=$STATUS"
|
||||
|
||||
# ─── 3. Per-tenant admin token + tenant URL ────────────────────────────
|
||||
log "3/6 fetching per-tenant admin token..."
|
||||
TT_RESP=$(admin_call GET "/cp/admin/orgs/$SLUG/admin-token")
|
||||
TENANT_TOKEN=$(echo "$TT_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('admin_token',''))" 2>/dev/null)
|
||||
[ -n "$TENANT_TOKEN" ] || fail "tenant token fetch failed: $(echo "$TT_RESP" | head -c 200)"
|
||||
|
||||
CP_HOST=$(echo "$CP_URL" | sed -E 's#^https?://##; s#/.*$##')
|
||||
case "$CP_HOST" in
|
||||
api.*) DERIVED_DOMAIN="${CP_HOST#api.}" ;;
|
||||
staging-api.*) DERIVED_DOMAIN="staging.${CP_HOST#staging-api.}" ;;
|
||||
*) DERIVED_DOMAIN="$CP_HOST" ;;
|
||||
esac
|
||||
TENANT_URL="https://${SLUG}.${DERIVED_DOMAIN}"
|
||||
log " tenant url: $TENANT_URL"
|
||||
|
||||
log "3b. waiting for tenant /health (TLS/DNS, up to 10min)..."
|
||||
for i in $(seq 1 120); do
|
||||
curl -fsS "$TENANT_URL/health" -m 5 -k >/dev/null 2>&1 && { log " /health ok (attempt $i)"; break; }
|
||||
sleep 5
|
||||
done
|
||||
|
||||
# ─── 4. Provision the parent + one sibling per runtime under test ──────
|
||||
# Inject the LLM provider key so each runtime can authenticate at boot.
|
||||
# Priority: MiniMax → direct-Anthropic → OpenAI (mirrors
|
||||
# test_staging_full_saas.sh's secrets-injection chain).
|
||||
SECRETS_JSON='{}'
|
||||
if [ -n "${E2E_MINIMAX_API_KEY:-}" ]; then
|
||||
SECRETS_JSON=$(python3 -c "import json,os;k=os.environ['E2E_MINIMAX_API_KEY'];print(json.dumps({'ANTHROPIC_BASE_URL':'https://api.minimax.io/anthropic','ANTHROPIC_AUTH_TOKEN':k,'MINIMAX_API_KEY':k}))")
|
||||
elif [ -n "${E2E_ANTHROPIC_API_KEY:-}" ]; then
|
||||
SECRETS_JSON=$(python3 -c "import json,os;k=os.environ['E2E_ANTHROPIC_API_KEY'];print(json.dumps({'ANTHROPIC_API_KEY':k}))")
|
||||
elif [ -n "${E2E_OPENAI_API_KEY:-}" ]; then
|
||||
SECRETS_JSON=$(python3 -c "import json,os;k=os.environ['E2E_OPENAI_API_KEY'];print(json.dumps({'OPENAI_API_KEY':k,'OPENAI_BASE_URL':'https://api.openai.com/v1','MODEL_PROVIDER':'openai:gpt-4o','HERMES_INFERENCE_PROVIDER':'custom','HERMES_CUSTOM_BASE_URL':'https://api.openai.com/v1','HERMES_CUSTOM_API_KEY':k,'HERMES_CUSTOM_API_MODE':'chat_completions'}))")
|
||||
fi
|
||||
|
||||
log "4/6 provisioning parent (claude-code) + one sibling per runtime under test..."
|
||||
P_RESP=$(tenant_call POST /workspaces \
|
||||
-d "{\"name\":\"pv-parent\",\"runtime\":\"claude-code\",\"tier\":3,\"secrets\":$SECRETS_JSON}")
|
||||
PARENT_ID=$(echo "$P_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
|
||||
[ -n "$PARENT_ID" ] || fail "parent create failed: $(echo "$P_RESP" | head -c 300)"
|
||||
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"
|
||||
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)
|
||||
# auth_token is top-level for container runtimes; external-like nest it
|
||||
# under connection.auth_token (verified vs staging response shape).
|
||||
WTOK=$(echo "$R" | 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)
|
||||
[ -n "$WID" ] || fail "$rt workspace create failed: $(echo "$R" | head -c 300)"
|
||||
[ -n "$WTOK" ] || fail "$rt workspace did not return an auth_token — cannot drive its MCP call (resp: $(echo "$R" | head -c 300))"
|
||||
WS_IDS[$rt]="$WID"
|
||||
WS_TOKENS[$rt]="$WTOK"
|
||||
ALL_WS_IDS="$ALL_WS_IDS $WID"
|
||||
log " $rt → $WID"
|
||||
done
|
||||
|
||||
# ─── 5. Wait for every sibling online ──────────────────────────────────
|
||||
log "5/6 waiting for all workspaces status=online (up to ${PROVISION_TIMEOUT_SECS}s — cold boot)..."
|
||||
WS_DEADLINE=$(( $(date +%s) + PROVISION_TIMEOUT_SECS ))
|
||||
for rt in $PV_RUNTIMES; do
|
||||
wid="${WS_IDS[$rt]}"
|
||||
LAST=""
|
||||
while true; do
|
||||
[ "$(date +%s)" -gt "$WS_DEADLINE" ] && fail "$rt ($wid) never reached online (last=$LAST)"
|
||||
S=$(tenant_call GET "/workspaces/$wid" 2>/dev/null | python3 -c "
|
||||
import sys, json
|
||||
try: d = json.load(sys.stdin)
|
||||
except Exception: sys.exit(0)
|
||||
w = d.get('workspace') if isinstance(d.get('workspace'), dict) else d
|
||||
print(w.get('status') or '')
|
||||
" 2>/dev/null)
|
||||
[ "$S" != "$LAST" ] && { log " $rt → $S"; LAST="$S"; }
|
||||
case "$S" in
|
||||
online) break ;;
|
||||
failed) sleep 10 ;; # transient: bootstrap-watcher 5-min deadline, heartbeat recovers
|
||||
*) sleep 10 ;;
|
||||
esac
|
||||
done
|
||||
ok " $rt online"
|
||||
done
|
||||
|
||||
# ─── 6. THE GATE — literal mcp_molecule_list_peers via POST /:id/mcp ────
|
||||
# This is the byte-for-byte user-facing call. NOT GET /registry/:id/peers,
|
||||
# NOT /health, NOT the heartbeat table. JSON-RPC 2.0 tools/call,
|
||||
# name=list_peers, authenticated by the workspace's OWN bearer token
|
||||
# through WorkspaceAuth + MCPRateLimiter.
|
||||
log "6/6 driving the LITERAL list_peers MCP call per runtime..."
|
||||
echo ""
|
||||
RPC_BODY='{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_peers","arguments":{}}}'
|
||||
REGRESSED=0
|
||||
declare -A VERDICT
|
||||
|
||||
for rt in $PV_RUNTIMES; do
|
||||
wid="${WS_IDS[$rt]}"
|
||||
wtok="${WS_TOKENS[$rt]}"
|
||||
# The expected peer set = every OTHER provisioned workspace (parent +
|
||||
# the sibling runtimes), excluding the caller itself.
|
||||
EXPECT_IDS=$(echo "$ALL_WS_IDS" | tr ' ' '\n' | grep -v "^${wid}$" | grep -v '^$')
|
||||
|
||||
set +e
|
||||
RESP=$(curl -sS -X POST "$TENANT_URL/workspaces/$wid/mcp" \
|
||||
-H "Authorization: Bearer $wtok" \
|
||||
-H "X-Molecule-Org-Id: $ORG_ID" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$RPC_BODY" \
|
||||
-o /tmp/pv_mcp_body.json -w "%{http_code}" 2>/dev/null)
|
||||
set -e
|
||||
HTTP_CODE="$RESP"
|
||||
BODY=$(cat /tmp/pv_mcp_body.json 2>/dev/null || echo '')
|
||||
|
||||
echo "--- $rt (ws=$wid) ---"
|
||||
echo " HTTP $HTTP_CODE"
|
||||
echo " body: $(echo "$BODY" | head -c 600)"
|
||||
|
||||
# (1) HTTP 200 — a 401 (WorkspaceAuth reject, the Hermes symptom) fails here.
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
echo " ✗ $rt: list_peers MCP call returned HTTP $HTTP_CODE (expected 200)"
|
||||
VERDICT[$rt]="FAIL(http=$HTTP_CODE)"
|
||||
REGRESSED=1
|
||||
continue
|
||||
fi
|
||||
|
||||
# (2) JSON-RPC result present, not an error object.
|
||||
PARSE=$(echo "$BODY" | python3 -c "
|
||||
import sys, json
|
||||
expect = set(filter(None, '''$EXPECT_IDS'''.split()))
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception as e:
|
||||
print('PARSE_ERROR:' + str(e)); sys.exit(0)
|
||||
if isinstance(d, dict) and d.get('error') is not None:
|
||||
print('RPC_ERROR:' + json.dumps(d['error'])[:200]); sys.exit(0)
|
||||
res = d.get('result') if isinstance(d, dict) else None
|
||||
if res is None:
|
||||
print('NO_RESULT'); sys.exit(0)
|
||||
# MCP tools/call result shape: {content:[{type:text,text:'<json or prose>'}]}
|
||||
text = ''
|
||||
if isinstance(res, dict):
|
||||
for c in res.get('content', []):
|
||||
if c.get('type') == 'text':
|
||||
text += c.get('text', '')
|
||||
text_l = text.lower()
|
||||
# Native-sessions fallback signature (the OpenClaw symptom): the agent
|
||||
# answered from its own runtime session list, not the platform peer set.
|
||||
if 'sessions_list' in text_l or 'no platform peers' in text_l or 'native session' in text_l:
|
||||
print('NATIVE_FALLBACK:' + text[:200]); sys.exit(0)
|
||||
# The expected sibling IDs must literally appear in the returned peer text.
|
||||
found = sorted(i for i in expect if i in text)
|
||||
missing = sorted(expect - set(found))
|
||||
if not expect:
|
||||
print('NO_EXPECTED_PEERS_CONFIGURED'); sys.exit(0)
|
||||
if missing:
|
||||
print('MISSING_PEERS:found=%d/%d missing=%s' % (len(found), len(expect), ','.join(m[:8] for m in missing)))
|
||||
sys.exit(0)
|
||||
print('OK:found=%d/%d' % (len(found), len(expect)))
|
||||
" 2>/dev/null)
|
||||
|
||||
case "$PARSE" in
|
||||
OK:*)
|
||||
echo " ✓ $rt: list_peers returned 200 and contains all expected peers ($PARSE)"
|
||||
VERDICT[$rt]="OK"
|
||||
;;
|
||||
NATIVE_FALLBACK:*)
|
||||
echo " ✗ $rt: list_peers fell back to NATIVE sessions — sees no platform peers ($PARSE)"
|
||||
VERDICT[$rt]="FAIL(native-fallback)"
|
||||
REGRESSED=1
|
||||
;;
|
||||
RPC_ERROR:*|NO_RESULT|PARSE_ERROR:*)
|
||||
echo " ✗ $rt: list_peers MCP call did not return a usable result ($PARSE)"
|
||||
VERDICT[$rt]="FAIL(rpc=$PARSE)"
|
||||
REGRESSED=1
|
||||
;;
|
||||
MISSING_PEERS:*)
|
||||
echo " ✗ $rt: list_peers returned 200 but peer set is wrong/empty ($PARSE)"
|
||||
VERDICT[$rt]="FAIL(peers=$PARSE)"
|
||||
REGRESSED=1
|
||||
;;
|
||||
*)
|
||||
echo " ✗ $rt: unexpected verdict '$PARSE'"
|
||||
VERDICT[$rt]="FAIL(unknown)"
|
||||
REGRESSED=1
|
||||
;;
|
||||
esac
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo "=== SUMMARY — fresh-provision peer-visibility (literal MCP list_peers) ==="
|
||||
for rt in $PV_RUNTIMES; do
|
||||
printf ' %-14s %s\n' "$rt" "${VERDICT[$rt]:-NO_RUN}"
|
||||
done
|
||||
echo ""
|
||||
|
||||
if [ "$REGRESSED" -ne 0 ]; then
|
||||
echo "✗ GATE FAILED — at least one runtime cannot see its peers via the"
|
||||
echo " literal mcp_molecule_list_peers call. This is the real user-facing"
|
||||
echo " failure the proxy signals (registry row / heartbeat / model 200)"
|
||||
echo " were hiding. Expected RED until the Hermes-401 + OpenClaw-MCP-wiring"
|
||||
echo " root-cause fixes land; goes green only when they actually do."
|
||||
exit 10
|
||||
fi
|
||||
|
||||
ok "GATE PASSED — every runtime under test sees its platform peers via the literal MCP call."
|
||||
exit 0
|
||||
Executable
+296
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env bash
|
||||
# E2E test: workspace broadcast and talk-to-user platform abilities.
|
||||
#
|
||||
# What this proves:
|
||||
# 1. talk_to_user_enabled (default true) — POST /notify works out-of-the-box.
|
||||
# 2. PATCH /workspaces/:id/abilities { talk_to_user_enabled: false } disables
|
||||
# delivery: /notify → 403 with error="talk_to_user_disabled" + delegate hint.
|
||||
# 3. Re-enabling talk_to_user_enabled restores delivery.
|
||||
# 4. broadcast_enabled (default false) — POST /broadcast → 403 when disabled.
|
||||
# 5. PATCH { broadcast_enabled: true } enables fan-out.
|
||||
# 6. POST /broadcast delivers to all non-sender, non-removed workspaces:
|
||||
# - Returns {"status":"sent","delivered":N}
|
||||
# - Receiver's activity log has a broadcast_receive entry with the message.
|
||||
# - Sender's activity log has a broadcast_sent entry.
|
||||
# 7. The sender itself does NOT receive a broadcast_receive entry.
|
||||
#
|
||||
# Usage: tests/e2e/test_workspace_abilities_e2e.sh
|
||||
# Prereqs: workspace-server on http://localhost:8080, MOLECULE_ENV != production
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(dirname "$0")/_lib.sh"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
SENDER_ID=""
|
||||
RECEIVER_ID=""
|
||||
|
||||
cleanup() {
|
||||
for wid in "$SENDER_ID" "$RECEIVER_ID"; do
|
||||
if [ -n "$wid" ]; then
|
||||
curl -s -X DELETE "$BASE/workspaces/$wid?confirm=true" > /dev/null || true
|
||||
fi
|
||||
done
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
assert() {
|
||||
local label="$1" actual="$2" expected="$3"
|
||||
if [ "$actual" = "$expected" ]; then
|
||||
echo " PASS — $label"
|
||||
PASS=$((PASS+1))
|
||||
else
|
||||
echo " FAIL — $label"
|
||||
echo " expected: $expected"
|
||||
echo " actual: $actual"
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
local label="$1" haystack="$2" needle="$3"
|
||||
if echo "$haystack" | grep -qF "$needle"; then
|
||||
echo " PASS — $label"
|
||||
PASS=$((PASS+1))
|
||||
else
|
||||
echo " FAIL — $label"
|
||||
echo " needle: $needle"
|
||||
echo " haystack: $haystack"
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_not_contains() {
|
||||
local label="$1" haystack="$2" needle="$3"
|
||||
if ! echo "$haystack" | grep -qF "$needle"; then
|
||||
echo " PASS — $label"
|
||||
PASS=$((PASS+1))
|
||||
else
|
||||
echo " FAIL — $label (unexpected match)"
|
||||
echo " needle: $needle"
|
||||
echo " haystack: $haystack"
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Pre-sweep: remove any stale leftover workspaces from a prior aborted run ──
|
||||
echo "=== Setup ==="
|
||||
for NAME in "Abilities Sender" "Abilities Receiver"; do
|
||||
PRIOR=$(curl -s "$BASE/workspaces" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
print(' '.join(w['id'] for w in json.load(sys.stdin) if w.get('name') == '$NAME'))
|
||||
except Exception:
|
||||
pass
|
||||
")
|
||||
for _wid in $PRIOR; do
|
||||
echo "Sweeping leftover '$NAME' workspace: $_wid"
|
||||
curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" > /dev/null || true
|
||||
done
|
||||
done
|
||||
|
||||
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
||||
-d '{"name":"Abilities Sender","tier":1}')
|
||||
SENDER_ID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
|
||||
[ -n "$SENDER_ID" ] || { echo "Failed to create sender workspace: $R"; exit 1; }
|
||||
echo "Created sender workspace: $SENDER_ID"
|
||||
|
||||
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
||||
-d '{"name":"Abilities Receiver","tier":1}')
|
||||
RECEIVER_ID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
|
||||
[ -n "$RECEIVER_ID" ] || { echo "Failed to create receiver workspace: $R"; exit 1; }
|
||||
echo "Created receiver workspace: $RECEIVER_ID"
|
||||
|
||||
# Mint workspace-scoped bearer tokens (test-only endpoint, disabled in prod).
|
||||
SENDER_TOKEN=$(e2e_mint_test_token "$SENDER_ID")
|
||||
[ -n "$SENDER_TOKEN" ] || { echo "Failed to mint sender token"; exit 1; }
|
||||
SENDER_AUTH="Authorization: Bearer $SENDER_TOKEN"
|
||||
|
||||
# Admin token — any live workspace bearer satisfies AdminAuth in local dev.
|
||||
# In production-like envs, set MOLECULE_ADMIN_TOKEN.
|
||||
ADMIN_TOKEN="${MOLECULE_ADMIN_TOKEN:-$SENDER_TOKEN}"
|
||||
ADMIN_AUTH="Authorization: Bearer $ADMIN_TOKEN"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "=== Part 1: talk_to_user ability ==="
|
||||
|
||||
echo ""
|
||||
echo "--- 1a: /notify works with default talk_to_user_enabled=true ---"
|
||||
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$SENDER_ID/notify" \
|
||||
-H "Content-Type: application/json" -H "$SENDER_AUTH" \
|
||||
-d '{"message":"Hello from sender"}')
|
||||
assert "POST /notify returns 200 when talk_to_user_enabled=true (default)" "$CODE" "200"
|
||||
|
||||
echo ""
|
||||
echo "--- 1b: Disable talk_to_user ---"
|
||||
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X PATCH "$BASE/workspaces/$SENDER_ID/abilities" \
|
||||
-H "Content-Type: application/json" -H "$ADMIN_AUTH" \
|
||||
-d '{"talk_to_user_enabled": false}')
|
||||
assert "PATCH /abilities talk_to_user_enabled=false returns 200" "$CODE" "200"
|
||||
|
||||
# Verify the flag is reflected in the workspace GET response.
|
||||
WS=$(curl -s "$BASE/workspaces/$SENDER_ID" -H "$SENDER_AUTH")
|
||||
FLAG=$(echo "$WS" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("talk_to_user_enabled","MISSING"))')
|
||||
assert "GET /workspaces/:id reflects talk_to_user_enabled=false" "$FLAG" "False"
|
||||
|
||||
echo ""
|
||||
echo "--- 1c: /notify blocked when talk_to_user disabled ---"
|
||||
BODY=$(curl -s -w "" -X POST "$BASE/workspaces/$SENDER_ID/notify" \
|
||||
-H "Content-Type: application/json" -H "$SENDER_AUTH" \
|
||||
-d '{"message":"Should be blocked"}')
|
||||
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$SENDER_ID/notify" \
|
||||
-H "Content-Type: application/json" -H "$SENDER_AUTH" \
|
||||
-d '{"message":"Should be blocked"}')
|
||||
assert "POST /notify returns 403 when talk_to_user_enabled=false" "$CODE" "403"
|
||||
|
||||
ERR=$(echo "$BODY" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("error",""))' 2>/dev/null || echo "")
|
||||
assert_contains "403 body contains talk_to_user_disabled error code" "$ERR" "talk_to_user_disabled"
|
||||
|
||||
HINT=$(echo "$BODY" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("hint",""))' 2>/dev/null || echo "")
|
||||
assert_contains "403 body contains delegate_task hint" "$HINT" "delegate_task"
|
||||
|
||||
echo ""
|
||||
echo "--- 1d: Re-enable talk_to_user and verify /notify works again ---"
|
||||
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X PATCH "$BASE/workspaces/$SENDER_ID/abilities" \
|
||||
-H "Content-Type: application/json" -H "$ADMIN_AUTH" \
|
||||
-d '{"talk_to_user_enabled": true}')
|
||||
assert "PATCH /abilities talk_to_user_enabled=true returns 200" "$CODE" "200"
|
||||
|
||||
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$SENDER_ID/notify" \
|
||||
-H "Content-Type: application/json" -H "$SENDER_AUTH" \
|
||||
-d '{"message":"Re-enabled, should work"}')
|
||||
assert "POST /notify returns 200 after re-enabling talk_to_user" "$CODE" "200"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "=== Part 2: broadcast ability ==="
|
||||
|
||||
echo ""
|
||||
echo "--- 2a: Broadcast blocked by default (broadcast_enabled=false) ---"
|
||||
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$SENDER_ID/broadcast" \
|
||||
-H "Content-Type: application/json" -H "$SENDER_AUTH" \
|
||||
-d '{"message":"Should be blocked"}')
|
||||
assert "POST /broadcast returns 403 when broadcast_enabled=false (default)" "$CODE" "403"
|
||||
|
||||
echo ""
|
||||
echo "--- 2b: Enable broadcast ---"
|
||||
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X PATCH "$BASE/workspaces/$SENDER_ID/abilities" \
|
||||
-H "Content-Type: application/json" -H "$ADMIN_AUTH" \
|
||||
-d '{"broadcast_enabled": true}')
|
||||
assert "PATCH /abilities broadcast_enabled=true returns 200" "$CODE" "200"
|
||||
|
||||
WS=$(curl -s "$BASE/workspaces/$SENDER_ID" -H "$SENDER_AUTH")
|
||||
FLAG=$(echo "$WS" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("broadcast_enabled","MISSING"))')
|
||||
assert "GET /workspaces/:id reflects broadcast_enabled=true" "$FLAG" "True"
|
||||
|
||||
echo ""
|
||||
echo "--- 2c: Successful broadcast fan-out ---"
|
||||
BCAST=$(curl -s -X POST "$BASE/workspaces/$SENDER_ID/broadcast" \
|
||||
-H "Content-Type: application/json" -H "$SENDER_AUTH" \
|
||||
-d '{"message":"Org-wide notice: scheduled maintenance in 5 minutes."}')
|
||||
BSTATUS=$(echo "$BCAST" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("status",""))' 2>/dev/null || echo "")
|
||||
BDELIVERED=$(echo "$BCAST" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("delivered","-1"))' 2>/dev/null || echo "-1")
|
||||
assert "POST /broadcast returns status=sent" "$BSTATUS" "sent"
|
||||
|
||||
# delivered count must be >= 1 (the receiver workspace).
|
||||
echo " INFO — broadcast delivered=$BDELIVERED"
|
||||
if python3 -c "import sys; sys.exit(0 if int('$BDELIVERED') >= 1 else 1)" 2>/dev/null; then
|
||||
echo " PASS — delivered count >= 1"
|
||||
PASS=$((PASS+1))
|
||||
else
|
||||
echo " FAIL — expected delivered >= 1, got $BDELIVERED"
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- 2d: Receiver activity log has broadcast_receive entry ---"
|
||||
RECEIVER_TOKEN=$(e2e_mint_test_token "$RECEIVER_ID")
|
||||
[ -n "$RECEIVER_TOKEN" ] || { echo "Failed to mint receiver token"; exit 1; }
|
||||
RECEIVER_AUTH="Authorization: Bearer $RECEIVER_TOKEN"
|
||||
|
||||
ACT=$(curl -s -H "$RECEIVER_AUTH" "$BASE/workspaces/$RECEIVER_ID/activity?source=agent&limit=20")
|
||||
ROW=$(echo "$ACT" | python3 -c '
|
||||
import json, sys
|
||||
rows = json.load(sys.stdin) or []
|
||||
for r in rows:
|
||||
if r.get("activity_type") == "broadcast_receive":
|
||||
print(json.dumps(r))
|
||||
break
|
||||
')
|
||||
[ -n "$ROW" ] || {
|
||||
echo " FAIL — could not find broadcast_receive row in receiver activity"
|
||||
FAIL=$((FAIL+1))
|
||||
}
|
||||
|
||||
if [ -n "$ROW" ]; then
|
||||
# Message is stored in summary field.
|
||||
MSG=$(echo "$ROW" | python3 -c 'import json,sys;r=json.load(sys.stdin);print(r.get("summary",""))')
|
||||
assert_contains "broadcast_receive row summary has original message" "$MSG" "scheduled maintenance"
|
||||
# Sender ID is stored in source_id field.
|
||||
SRC=$(echo "$ROW" | python3 -c 'import json,sys;r=json.load(sys.stdin);print(r.get("source_id",""))')
|
||||
assert "broadcast_receive row source_id is sender workspace" "$SRC" "$SENDER_ID"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- 2e: Sender activity log has broadcast_sent entry ---"
|
||||
ACT_SENDER=$(curl -s -H "$SENDER_AUTH" "$BASE/workspaces/$SENDER_ID/activity?limit=20")
|
||||
SENT_ROW=$(echo "$ACT_SENDER" | python3 -c '
|
||||
import json, sys
|
||||
rows = json.load(sys.stdin) or []
|
||||
for r in rows:
|
||||
if r.get("activity_type") == "broadcast_sent":
|
||||
print(json.dumps(r))
|
||||
break
|
||||
')
|
||||
[ -n "$SENT_ROW" ] || {
|
||||
echo " FAIL — could not find broadcast_sent row in sender activity"
|
||||
FAIL=$((FAIL+1))
|
||||
}
|
||||
|
||||
if [ -n "$SENT_ROW" ]; then
|
||||
# Delivered count is baked into the summary field (no response_body for sender row).
|
||||
SUMMARY=$(echo "$SENT_ROW" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("summary",""))')
|
||||
assert_contains "broadcast_sent summary mentions workspace count" "$SUMMARY" "workspace"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- 2f: Sender does NOT receive a broadcast_receive entry ---"
|
||||
SELF_RECV=$(echo "$ACT_SENDER" | python3 -c '
|
||||
import json, sys
|
||||
rows = json.load(sys.stdin) or []
|
||||
for r in rows:
|
||||
if r.get("activity_type") == "broadcast_receive":
|
||||
print("found")
|
||||
break
|
||||
')
|
||||
assert_not_contains "sender has no broadcast_receive in own activity log" "${SELF_RECV:-}" "found"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "--- 2g: Empty message is rejected ---"
|
||||
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$SENDER_ID/broadcast" \
|
||||
-H "Content-Type: application/json" -H "$SENDER_AUTH" \
|
||||
-d '{"message":""}')
|
||||
assert "POST /broadcast with empty message returns 400" "$CODE" "400"
|
||||
|
||||
echo ""
|
||||
echo "--- 2h: Partial PATCH does not clobber other flags ---"
|
||||
# Set talk_to_user=false, then patch only broadcast — talk_to_user must stay false.
|
||||
curl -s -o /dev/null -X PATCH "$BASE/workspaces/$SENDER_ID/abilities" \
|
||||
-H "Content-Type: application/json" -H "$ADMIN_AUTH" \
|
||||
-d '{"talk_to_user_enabled": false}'
|
||||
curl -s -o /dev/null -X PATCH "$BASE/workspaces/$SENDER_ID/abilities" \
|
||||
-H "Content-Type: application/json" -H "$ADMIN_AUTH" \
|
||||
-d '{"broadcast_enabled": false}'
|
||||
WS=$(curl -s "$BASE/workspaces/$SENDER_ID" -H "$SENDER_AUTH")
|
||||
TUF=$(echo "$WS" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("talk_to_user_enabled","MISSING"))')
|
||||
BEF=$(echo "$WS" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("broadcast_enabled","MISSING"))')
|
||||
assert "partial PATCH preserves talk_to_user_enabled=false" "$TUF" "False"
|
||||
assert "partial PATCH sets broadcast_enabled=false" "$BEF" "False"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "=== Results: $PASS passed, $FAIL failed ==="
|
||||
[ "$FAIL" -eq 0 ]
|
||||
@@ -482,6 +482,13 @@ func (h *ActivityHandler) Notify(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrTalkToUserDisabled) {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "talk_to_user_disabled",
|
||||
"hint": "This workspace is not allowed to send messages directly to the user. Forward your update to a parent workspace using delegate_task — they may be able to reach the user.",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -63,31 +63,6 @@ func TestSessionSearchReturnsActivityAndMemory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionSearch_DBError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
mock.ExpectQuery("WITH session_items AS").
|
||||
WillReturnError(context.DeadlineExceeded)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-123/session-search?q=test", bytes.NewBufferString(""))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-123"}}
|
||||
|
||||
handler.SessionSearch(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500 on DB error, got %d", w.Code)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Activity List source filter ----------
|
||||
|
||||
func TestActivityList_SourceCanvas(t *testing.T) {
|
||||
@@ -489,9 +464,9 @@ func TestNotify_PersistsToActivityLogsForReloadRecovery(t *testing.T) {
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
|
||||
// Workspace existence check
|
||||
mock.ExpectQuery(`SELECT name FROM workspaces`).
|
||||
mock.ExpectQuery(`SELECT name, talk_to_user_enabled FROM workspaces`).
|
||||
WithArgs("ws-notify").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("DD"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("DD", true))
|
||||
|
||||
// Persistence INSERT — verify shape
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).
|
||||
@@ -536,9 +511,9 @@ func TestNotify_WithAttachments_PersistsFilePartsForReload(t *testing.T) {
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
|
||||
mock.ExpectQuery(`SELECT name FROM workspaces`).
|
||||
mock.ExpectQuery(`SELECT name, talk_to_user_enabled FROM workspaces`).
|
||||
WithArgs("ws-attach").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("DD"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("DD", true))
|
||||
|
||||
// Capture the JSONB arg so we can assert on the persisted shape
|
||||
// AFTER the call (must include parts[].kind=file so reload
|
||||
@@ -665,9 +640,9 @@ func TestNotify_DBFailure_StillBroadcastsAnd200(t *testing.T) {
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
|
||||
mock.ExpectQuery(`SELECT name FROM workspaces`).
|
||||
mock.ExpectQuery(`SELECT name, talk_to_user_enabled FROM workspaces`).
|
||||
WithArgs("ws-x").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("DD"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("DD", true))
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).
|
||||
WillReturnError(fmt.Errorf("simulated db hiccup"))
|
||||
|
||||
|
||||
@@ -54,6 +54,11 @@ import (
|
||||
// timeout) surface as wrapped errors and should be treated as 503.
|
||||
var ErrWorkspaceNotFound = errors.New("agent_message: workspace not found")
|
||||
|
||||
// ErrTalkToUserDisabled is returned when the workspace has
|
||||
// talk_to_user_enabled=false. Callers surface HTTP 403 so the Python tool
|
||||
// can detect it and suggest forwarding to a parent workspace.
|
||||
var ErrTalkToUserDisabled = errors.New("agent_message: talk_to_user disabled")
|
||||
|
||||
// AgentMessageAttachment is one file attached to an agent → user
|
||||
// message. Identical to handlers.NotifyAttachment in field set; kept
|
||||
// distinct so the writer's API doesn't import a handler type with HTTP
|
||||
@@ -107,16 +112,20 @@ func (w *AgentMessageWriter) Send(
|
||||
// notify call surfaced as "workspace not found" and masked real
|
||||
// incidents in the alert path.
|
||||
var wsName string
|
||||
var talkToUserEnabled bool
|
||||
err := w.db.QueryRowContext(ctx,
|
||||
`SELECT name FROM workspaces WHERE id = $1 AND status != 'removed'`,
|
||||
`SELECT name, talk_to_user_enabled FROM workspaces WHERE id = $1 AND status != 'removed'`,
|
||||
workspaceID,
|
||||
).Scan(&wsName)
|
||||
).Scan(&wsName, &talkToUserEnabled)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrWorkspaceNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("agent_message: workspace lookup: %w", err)
|
||||
}
|
||||
if !talkToUserEnabled {
|
||||
return ErrTalkToUserDisabled
|
||||
}
|
||||
|
||||
// 2. Build broadcast payload + WS-emit. Same shape that ChatTab's
|
||||
// AGENT_MESSAGE handler in canvas/src/store/canvas-events.ts has
|
||||
|
||||
@@ -88,9 +88,9 @@ func TestAgentMessageWriter_Send_Success_NoAttachments(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
||||
|
||||
mock.ExpectQuery("SELECT name FROM workspaces").
|
||||
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
|
||||
WithArgs("ws-1").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("CEO Ryan PC"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("CEO Ryan PC", true))
|
||||
|
||||
mock.ExpectExec(`INSERT INTO activity_logs.*'a2a_receive'.*'notify'`).
|
||||
WithArgs(
|
||||
@@ -116,9 +116,9 @@ func TestAgentMessageWriter_Send_Success_WithAttachments(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
||||
|
||||
mock.ExpectQuery("SELECT name FROM workspaces").
|
||||
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
|
||||
WithArgs("ws-att").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Ryan"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("Ryan", true))
|
||||
|
||||
mock.ExpectExec(`INSERT INTO activity_logs.*'a2a_receive'.*'notify'`).
|
||||
WithArgs(
|
||||
@@ -173,9 +173,9 @@ func TestAgentMessageWriter_Send_WorkspaceNotFound(t *testing.T) {
|
||||
emitter := &capturingEmitter{}
|
||||
w := NewAgentMessageWriter(db.DB, emitter)
|
||||
|
||||
mock.ExpectQuery("SELECT name FROM workspaces").
|
||||
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
|
||||
WithArgs("ws-missing").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}))
|
||||
|
||||
err := w.Send(context.Background(), "ws-missing", "lost in the void", nil)
|
||||
if !errors.Is(err, ErrWorkspaceNotFound) {
|
||||
@@ -202,9 +202,9 @@ func TestAgentMessageWriter_Send_DBInsertFailureStillReturnsNil(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
||||
|
||||
mock.ExpectQuery("SELECT name FROM workspaces").
|
||||
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
|
||||
WithArgs("ws-dbfail").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("CEO Ryan PC"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("CEO Ryan PC", true))
|
||||
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).
|
||||
WillReturnError(errors.New("transient db error"))
|
||||
@@ -223,9 +223,9 @@ func TestAgentMessageWriter_Send_PreviewTruncation(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
||||
|
||||
mock.ExpectQuery("SELECT name FROM workspaces").
|
||||
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
|
||||
WithArgs("ws-trunc").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Ryan"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("Ryan", true))
|
||||
|
||||
longMsg := strings.Repeat("x", 200)
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).
|
||||
@@ -263,9 +263,9 @@ func TestAgentMessageWriter_Send_BroadcastsAgentMessageEvent(t *testing.T) {
|
||||
emitter := &capturingEmitter{}
|
||||
w := NewAgentMessageWriter(db.DB, emitter)
|
||||
|
||||
mock.ExpectQuery("SELECT name FROM workspaces").
|
||||
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
|
||||
WithArgs("ws-bc").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Workspace Name"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("Workspace Name", true))
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
@@ -315,7 +315,7 @@ func TestAgentMessageWriter_Send_DBErrorOnLookupReturnsWrapped(t *testing.T) {
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
||||
|
||||
transientErr := errors.New("connection refused")
|
||||
mock.ExpectQuery("SELECT name FROM workspaces").
|
||||
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
|
||||
WithArgs("ws-dbdown").
|
||||
WillReturnError(transientErr)
|
||||
|
||||
@@ -350,9 +350,9 @@ func TestAgentMessageWriter_Send_NonASCIIMessagePersists(t *testing.T) {
|
||||
// the byte-slice bug.
|
||||
msg := strings.Repeat("你", 200)
|
||||
|
||||
mock.ExpectQuery("SELECT name FROM workspaces").
|
||||
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
|
||||
WithArgs("ws-cjk").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("CEO Ryan PC"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("CEO Ryan PC", true))
|
||||
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).
|
||||
WithArgs(
|
||||
@@ -395,9 +395,9 @@ func TestAgentMessageWriter_Send_OmitsAttachmentsKeyWhenEmpty(t *testing.T) {
|
||||
emitter := &capturingEmitter{}
|
||||
w := NewAgentMessageWriter(db.DB, emitter)
|
||||
|
||||
mock.ExpectQuery("SELECT name FROM workspaces").
|
||||
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
|
||||
WithArgs("ws-noatt").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("X"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("X", true))
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
|
||||
@@ -543,33 +543,6 @@ func TestDelegationRecord_RejectsInvalidUUID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelegationRecord_DBInsertFails(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
h := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WillReturnError(fmt.Errorf("connection refused"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
|
||||
body := `{"target_id":"550e8400-e29b-41d4-a716-446655440001","task":"hello","delegation_id":"del-xyz"}`
|
||||
c.Request = httptest.NewRequest("POST", "/delegations/record", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Record(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500 on DB insert failure, got %d", w.Code)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelegationUpdateStatus_CompletedInsertsResultRow(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
@@ -230,20 +230,21 @@ func TestWorkspaceList_WithData(t *testing.T) {
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
// 21 cols — see scanWorkspaceRow for order (max_concurrent_tasks
|
||||
// lands between active_tasks and last_error_rate).
|
||||
// 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",
|
||||
"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",
|
||||
}
|
||||
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)).
|
||||
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))
|
||||
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)
|
||||
|
||||
@@ -407,21 +407,21 @@ func TestWorkspaceList(t *testing.T) {
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", "/tmp/configs")
|
||||
|
||||
// 21 cols: `max_concurrent_tasks` added between active_tasks and
|
||||
// last_error_rate (see scanWorkspaceRow + COALESCE(w.max_concurrent_tasks, 1)
|
||||
// in workspace.go). Column order must match that scan exactly.
|
||||
// 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",
|
||||
"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",
|
||||
}
|
||||
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)).
|
||||
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))
|
||||
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)
|
||||
@@ -1135,13 +1135,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",
|
||||
}
|
||||
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),
|
||||
nil, int64(0), false, true,
|
||||
))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -751,9 +751,9 @@ func TestMCPHandler_SendMessageToUser_DBErrorLogsAndStill200s(t *testing.T) {
|
||||
t.Setenv("MOLECULE_MCP_ALLOW_SEND_MESSAGE", "true")
|
||||
h, mock := newMCPHandler(t)
|
||||
|
||||
mock.ExpectQuery("SELECT name FROM workspaces").
|
||||
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
|
||||
WithArgs("ws-err").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("CEO Ryan PC"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("CEO Ryan PC", true))
|
||||
|
||||
// INSERT fails — must NOT abort the tool response.
|
||||
mock.ExpectExec(`INSERT INTO activity_logs.*'a2a_receive'.*'notify'`).
|
||||
@@ -802,9 +802,9 @@ func TestMCPHandler_SendMessageToUser_ResponseBodyShape(t *testing.T) {
|
||||
|
||||
const userMessage = "Hi there from the agent"
|
||||
|
||||
mock.ExpectQuery("SELECT name FROM workspaces").
|
||||
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
|
||||
WithArgs("ws-shape").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("CEO Ryan PC"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("CEO Ryan PC", true))
|
||||
|
||||
// Capture the response_body argument and assert its exact shape.
|
||||
mock.ExpectExec(`INSERT INTO activity_logs.*'a2a_receive'.*'notify'`).
|
||||
@@ -861,9 +861,9 @@ func TestMCPHandler_SendMessageToUser_PersistsToActivityLog(t *testing.T) {
|
||||
// before it does anything else. Returning a name lets the
|
||||
// broadcast payload populate; the test doesn't assert on the
|
||||
// broadcast (no observable WS in this fake), only on the DB.
|
||||
mock.ExpectQuery("SELECT name FROM workspaces").
|
||||
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
|
||||
WithArgs("ws-msg").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("CEO Ryan PC"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("CEO Ryan PC", true))
|
||||
|
||||
// The persistence INSERT — pin the exact shape so a future
|
||||
// refactor that switches columns or drops `method='notify'`
|
||||
|
||||
@@ -591,7 +591,7 @@ func scanWorkspaceRow(rows interface {
|
||||
var id, name, role, status, url, sampleError, currentTask, runtime, workspaceDir string
|
||||
var tier, activeTasks, maxConcurrentTasks, uptimeSeconds int
|
||||
var errorRate, x, y float64
|
||||
var collapsed bool
|
||||
var collapsed, broadcastEnabled, talkToUserEnabled bool
|
||||
var parentID *string
|
||||
var agentCard []byte
|
||||
var budgetLimit sql.NullInt64
|
||||
@@ -600,7 +600,7 @@ func scanWorkspaceRow(rows interface {
|
||||
err := rows.Scan(&id, &name, &role, &tier, &status, &agentCard, &url,
|
||||
&parentID, &activeTasks, &maxConcurrentTasks, &errorRate, &sampleError, &uptimeSeconds,
|
||||
¤tTask, &runtime, &workspaceDir, &x, &y, &collapsed,
|
||||
&budgetLimit, &monthlySpend)
|
||||
&budgetLimit, &monthlySpend, &broadcastEnabled, &talkToUserEnabled)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -624,6 +624,8 @@ func scanWorkspaceRow(rows interface {
|
||||
"x": x,
|
||||
"y": y,
|
||||
"collapsed": collapsed,
|
||||
"broadcast_enabled": broadcastEnabled,
|
||||
"talk_to_user_enabled": talkToUserEnabled,
|
||||
}
|
||||
|
||||
// budget_limit: nil when no limit set, int64 otherwise
|
||||
@@ -659,7 +661,8 @@ const workspaceListQuery = `
|
||||
COALESCE(w.current_task, ''), COALESCE(w.runtime, 'langgraph'),
|
||||
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.budget_limit, COALESCE(w.monthly_spend, 0),
|
||||
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'
|
||||
@@ -719,7 +722,8 @@ func (h *WorkspaceHandler) Get(c *gin.Context) {
|
||||
COALESCE(w.current_task, ''), COALESCE(w.runtime, 'langgraph'),
|
||||
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.budget_limit, COALESCE(w.monthly_spend, 0),
|
||||
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
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package handlers
|
||||
|
||||
// workspace_abilities.go — PATCH /workspaces/:id/abilities
|
||||
//
|
||||
// Allows users and admin agents to toggle two workspace-level ability flags:
|
||||
//
|
||||
// broadcast_enabled — workspace may POST /broadcast to send org-wide messages
|
||||
// talk_to_user_enabled — workspace may deliver canvas chat messages via
|
||||
// send_message_to_user / POST /notify
|
||||
//
|
||||
// Gated behind AdminAuth so workspace agents cannot self-modify their own
|
||||
// ability flags (that would let any agent grant itself broadcast rights or
|
||||
// suppress its own chat-silence constraint).
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AbilitiesPayload carries the subset of ability flags the caller wants to
|
||||
// update. Fields are pointers so that the handler can distinguish "caller
|
||||
// supplied false" from "caller omitted the field" (omitempty semantics).
|
||||
type AbilitiesPayload struct {
|
||||
BroadcastEnabled *bool `json:"broadcast_enabled"`
|
||||
TalkToUserEnabled *bool `json:"talk_to_user_enabled"`
|
||||
}
|
||||
|
||||
// PatchAbilities handles PATCH /workspaces/:id/abilities (AdminAuth).
|
||||
func PatchAbilities(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := validateWorkspaceID(id); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var body AbilitiesPayload
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
if body.BroadcastEnabled == nil && body.TalkToUserEnabled == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "at least one ability field required"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var exists bool
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM workspaces WHERE id = $1 AND status != 'removed')`, id,
|
||||
).Scan(&exists); err != nil || !exists {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if body.BroadcastEnabled != nil {
|
||||
if _, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE workspaces SET broadcast_enabled = $2, updated_at = now() WHERE id = $1`,
|
||||
id, *body.BroadcastEnabled,
|
||||
); err != nil {
|
||||
log.Printf("PatchAbilities broadcast_enabled for %s: %v", id, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if body.TalkToUserEnabled != nil {
|
||||
if _, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE workspaces SET talk_to_user_enabled = $2, updated_at = now() WHERE id = $1`,
|
||||
id, *body.TalkToUserEnabled,
|
||||
); err != nil {
|
||||
log.Printf("PatchAbilities talk_to_user_enabled for %s: %v", id, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "updated"})
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package handlers
|
||||
|
||||
// workspace_broadcast.go — POST /workspaces/:id/broadcast
|
||||
//
|
||||
// Allows a workspace with broadcast_enabled=true to send a message to every
|
||||
// non-removed agent workspace in the SAME ORG. The message is:
|
||||
//
|
||||
// • Persisted in each recipient's activity_logs (type='broadcast_receive')
|
||||
// so poll-mode agents pick it up via GET /activity.
|
||||
// • Broadcast via WebSocket BROADCAST_MESSAGE event so canvas panels can
|
||||
// show a real-time banner for each recipient workspace.
|
||||
//
|
||||
// The sender's own workspace logs a 'broadcast_sent' activity row for
|
||||
// traceability.
|
||||
//
|
||||
// Auth: WorkspaceAuth (the agent triggers this with its own bearer token).
|
||||
// The handler re-validates broadcast_enabled inside the DB lookup to prevent
|
||||
// TOCTOU — the middleware only proved the token is valid, not the ability.
|
||||
//
|
||||
// Org isolation (OFFSEC-015): recipients are scoped to the sender's org using
|
||||
// a recursive CTE that walks the parent_id chain to find the org root. This
|
||||
// prevents a compromised or misconfigured workspace from broadcasting to
|
||||
// workspaces in other tenants' orgs.
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// BroadcastHandler is constructed once and shared across requests.
|
||||
type BroadcastHandler struct {
|
||||
broadcaster *events.Broadcaster
|
||||
}
|
||||
|
||||
// NewBroadcastHandler creates a BroadcastHandler.
|
||||
func NewBroadcastHandler(b *events.Broadcaster) *BroadcastHandler {
|
||||
return &BroadcastHandler{broadcaster: b}
|
||||
}
|
||||
|
||||
// Broadcast handles POST /workspaces/:id/broadcast.
|
||||
func (h *BroadcastHandler) Broadcast(c *gin.Context) {
|
||||
senderID := c.Param("id")
|
||||
if err := validateWorkspaceID(senderID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Message string `json:"message" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "message is required"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Verify sender exists and has broadcast_enabled=true.
|
||||
var senderName string
|
||||
var broadcastEnabled bool
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'`,
|
||||
senderID,
|
||||
).Scan(&senderName, &broadcastEnabled)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||
return
|
||||
}
|
||||
if !broadcastEnabled {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "broadcast_disabled",
|
||||
"hint": "This workspace does not have the broadcast ability. Ask a user or admin to enable it via PATCH /workspaces/:id/abilities.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Find the sender's org root by walking the parent_id chain.
|
||||
// Workspaces with parent_id = NULL are org roots; every other workspace
|
||||
// belongs to the org identified by its topmost ancestor.
|
||||
var orgRootID string
|
||||
err = db.DB.QueryRowContext(ctx, `
|
||||
WITH RECURSIVE org_chain AS (
|
||||
SELECT id, parent_id, id AS root_id
|
||||
FROM workspaces
|
||||
WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT w.id, w.parent_id, c.root_id
|
||||
FROM workspaces w
|
||||
JOIN org_chain c ON w.id = c.parent_id
|
||||
)
|
||||
SELECT root_id FROM org_chain WHERE parent_id IS NULL LIMIT 1
|
||||
`, senderID).Scan(&orgRootID)
|
||||
if err != nil {
|
||||
log.Printf("Broadcast: org root lookup for %s: %v", senderID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
// Collect all non-removed agent workspaces in the SAME ORG (same root_id),
|
||||
// excluding the sender itself.
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
WITH RECURSIVE org_chain AS (
|
||||
SELECT id, parent_id, id AS root_id
|
||||
FROM workspaces
|
||||
WHERE parent_id IS NULL
|
||||
UNION ALL
|
||||
SELECT w.id, w.parent_id, c.root_id
|
||||
FROM workspaces w
|
||||
JOIN org_chain c ON w.parent_id = c.id
|
||||
)
|
||||
SELECT c.id
|
||||
FROM org_chain c
|
||||
WHERE c.root_id = $1
|
||||
AND c.id != $2
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM workspaces w
|
||||
WHERE w.id = c.id AND w.status != 'removed'
|
||||
)
|
||||
`, orgRootID, senderID)
|
||||
if err != nil {
|
||||
log.Printf("Broadcast: recipient query failed for %s: %v", senderID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var recipientIDs []string
|
||||
for rows.Next() {
|
||||
var rid string
|
||||
if rows.Scan(&rid) == nil {
|
||||
recipientIDs = append(recipientIDs, rid)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("Broadcast: recipient rows error for %s: %v", senderID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
broadcastPayload := map[string]interface{}{
|
||||
"message": body.Message,
|
||||
"sender_id": senderID,
|
||||
"sender": senderName,
|
||||
}
|
||||
|
||||
// Persist broadcast_receive in each recipient's activity log + emit WS event.
|
||||
delivered := 0
|
||||
for _, rid := range recipientIDs {
|
||||
if _, err := db.DB.ExecContext(ctx, `
|
||||
INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, summary, status)
|
||||
VALUES ($1, 'broadcast_receive', 'broadcast', $2, $3, 'ok')
|
||||
`, rid, senderID, "Broadcast from "+senderName+": "+broadcastTruncate(body.Message, 120)); err != nil {
|
||||
log.Printf("Broadcast: activity_logs insert for recipient %s: %v", rid, err)
|
||||
continue
|
||||
}
|
||||
h.broadcaster.BroadcastOnly(rid, "BROADCAST_MESSAGE", broadcastPayload)
|
||||
delivered++
|
||||
}
|
||||
|
||||
// Record the send on the sender's own log.
|
||||
if _, err := db.DB.ExecContext(ctx, `
|
||||
INSERT INTO activity_logs (workspace_id, activity_type, method, summary, status)
|
||||
VALUES ($1, 'broadcast_sent', 'broadcast', $2, 'ok')
|
||||
`, senderID, "Broadcast sent to "+strconv.Itoa(delivered)+" workspace(s)"); err != nil {
|
||||
log.Printf("Broadcast: sender activity_log for %s: %v", senderID, err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "sent",
|
||||
"delivered": delivered,
|
||||
})
|
||||
}
|
||||
|
||||
func broadcastTruncate(s string, max int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= max {
|
||||
return s
|
||||
}
|
||||
return string(runes[:max]) + "…"
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// -------- Org-scoped recipient query tests (OFFSEC-015) --------
|
||||
|
||||
// TestBroadcast_OrgScopedRecipients verifies that a broadcast from Org-A does
|
||||
// NOT reach workspaces belonging to Org-B. This is the core regression test
|
||||
// for OFFSEC-015: the original query had no org filter, so a workspace in
|
||||
// Org-A could broadcast to every non-removed workspace in the entire DB,
|
||||
// including workspaces owned by other tenants.
|
||||
func TestBroadcast_OrgScopedRecipients(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
// Org-A structure:
|
||||
// org-a-root (parent_id = NULL) ← sender
|
||||
// ├── ws-a-child
|
||||
// Org-B structure:
|
||||
// org-b-root (parent_id = NULL)
|
||||
// └── ws-b-child
|
||||
senderID := "00000000-0000-0000-0000-000000000001" // org-a-root
|
||||
wsAChild := "00000000-0000-0000-0000-000000000002"
|
||||
// ws-b-child is in Org-B (different root); the org-scoped query MUST NOT include it.
|
||||
|
||||
// 1. Sender lookup
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Org-A Root", true))
|
||||
|
||||
// 2. Org root lookup — sender is its own root (parent_id = NULL)
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(senderID))
|
||||
|
||||
// 3. Org-scoped recipient query — MUST include org filter so ws-b-child is NOT included.
|
||||
// The query joins on org_chain.root_id = orgRootID, which scopes to Org-A only.
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID, senderID). // orgRootID, senderID (EXCLUDED)
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(wsAChild)) // only Org-A child
|
||||
|
||||
// Activity log inserts
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(wsAChild, senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
||||
body := `{"message":"hello from org-a"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if resp["status"] != "sent" {
|
||||
t.Errorf("expected status 'sent', got %v", resp["status"])
|
||||
}
|
||||
// ws-b-child is in a DIFFERENT org — the org-scoped query MUST NOT include it.
|
||||
// If it were included, the mock would have an unmet expectation.
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet mock expectations — cross-org workspace was included in broadcast: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcast_OrgScoped_OrgRootSender verifies that when the sender IS the
|
||||
// org root (parent_id = NULL), broadcasts still reach sibling workspaces.
|
||||
func TestBroadcast_OrgScoped_OrgRootSender(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
senderID := "00000000-0000-0000-0000-000000000001" // org-a-root
|
||||
siblingID := "00000000-0000-0000-0000-000000000002"
|
||||
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Root Agent", true))
|
||||
|
||||
// Sender is the org root — CTE returns sender's own ID as root
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(senderID))
|
||||
|
||||
// Recipients in same org, excluding sender
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID, senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(siblingID))
|
||||
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(siblingID, senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
||||
body := `{"message":"hello siblings"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcast_OrgScoped_ChildWorkspaceSender verifies that a non-root child
|
||||
// workspace can broadcast to siblings in the same org.
|
||||
func TestBroadcast_OrgScoped_ChildWorkspaceSender(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
orgRootID := "00000000-0000-0000-0000-000000000001"
|
||||
senderID := "00000000-0000-0000-0000-000000000002" // child workspace
|
||||
siblingID := "00000000-0000-0000-0000-000000000003"
|
||||
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Child Agent", true))
|
||||
|
||||
// Org root lookup — walk up to find org-a-root
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(orgRootID))
|
||||
|
||||
// Recipients: same org, excluding sender
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(orgRootID, senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(siblingID))
|
||||
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(siblingID, senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
||||
body := `{"message":"child broadcasting"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -------- Non-regression cases --------
|
||||
|
||||
func TestBroadcast_NotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
senderID := "00000000-0000-0000-0000-000000000099"
|
||||
// UUID is valid, but no workspace row matches
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
WillReturnError(errors.New("workspace not found"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
||||
body := `{"message":"test"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_Disabled(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
senderID := "00000000-0000-0000-0000-000000000001"
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Disabled Agent", false))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
||||
body := `{"message":"should not send"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 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 unmarshal: %v", err)
|
||||
}
|
||||
if resp["error"] != "broadcast_disabled" {
|
||||
t.Errorf("expected error 'broadcast_disabled', got %v", resp["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_EmptyOrg_NoRecipients(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
senderID := "00000000-0000-0000-0000-000000000001" // org root, only workspace in org
|
||||
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Lone Root", true))
|
||||
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(senderID))
|
||||
|
||||
// No other workspaces in this org
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID, senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}))
|
||||
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
||||
body := `{"message":"hello org"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if resp["delivered"] != float64(0) {
|
||||
t.Errorf("expected delivered=0, got %v", resp["delivered"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_InvalidWorkspaceID(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "not-a-uuid"}}
|
||||
body := `{"message":"test"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/not-a-uuid/broadcast", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_MissingMessage(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "00000000-0000-0000-0000-000000000001"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/00000000-0000-0000-0000-000000000001/broadcast", bytes.NewBufferString("{}"))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcast_OrgRootLookupFails verifies that if the recursive CTE for
|
||||
// finding the org root errors, the handler returns 500 instead of proceeding
|
||||
// with an un-scoped query that would broadcast to all orgs.
|
||||
func TestBroadcast_OrgRootLookupFails(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
senderID := "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Root Agent", true))
|
||||
|
||||
// Org root CTE fails
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID).
|
||||
WillReturnError(context.DeadlineExceeded)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
||||
body := `{"message":"should not broadcast"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
// The recipient query MUST NOT be called — it would broadcast cross-org
|
||||
// if the org root lookup failed silently.
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcast_OrgScoped_SelfBroadcastExcluded verifies that broadcasting
|
||||
// from a workspace does not send a broadcast_receive to the sender itself
|
||||
// (the sender logs broadcast_sent, not broadcast_receive).
|
||||
func TestBroadcast_OrgScoped_SelfBroadcastExcluded(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
senderID := "00000000-0000-0000-0000-000000000001"
|
||||
peerID := "00000000-0000-0000-0000-000000000002"
|
||||
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Root Agent", true))
|
||||
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(senderID))
|
||||
|
||||
// Recipient query MUST exclude sender via id != senderID
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID, senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(peerID))
|
||||
|
||||
// Peer receives broadcast_receive
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(peerID, senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// Sender logs broadcast_sent (NOT broadcast_receive)
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
||||
body := `{"message":"no echo to self"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcast_Truncate tests that messages are truncated with the Unicode ellipsis
|
||||
// TestBroadcast_Truncate tests that messages are truncated with the Unicode ellipsis
|
||||
// character (U+2026) when len(msg) > max. The truncated output is max runes + "…",
|
||||
// so truncating a 48-char string at max=20 produces 21 characters (20 runes + "…").
|
||||
func TestBroadcast_Truncate(t *testing.T) {
|
||||
cases := []struct {
|
||||
msg string
|
||||
max int
|
||||
expect string
|
||||
}{
|
||||
{"short", 120, "short"}, // under max — no truncation
|
||||
// exactly120chars (15) + 105 ones = 120 chars; at max=120 → unchanged
|
||||
{"exactly120chars1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", 120, "exactly120chars111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111…"},
|
||||
// "this is a longer mes" = 20 runes; + "…" = 21 chars
|
||||
{"this is a longer message that needs truncating", 20, "this is a longer mes…"},
|
||||
// at-max boundary: 20 chars at max=20 → no truncation
|
||||
{"exactly twenty chars", 20, "exactly twenty chars"},
|
||||
// over max: 11 chars at max=10 → 10 + "…" = 11
|
||||
{"hello world!", 10, "hello worl…"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
result := broadcastTruncate(tc.msg, tc.max)
|
||||
if result != tc.expect {
|
||||
t.Errorf("broadcastTruncate(%q, %d) = %q; want %q", tc.msg, tc.max, result, tc.expect)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +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",
|
||||
}
|
||||
|
||||
// ==================== GET — financial fields stripped from open endpoint ====================
|
||||
@@ -52,8 +53,10 @@ func TestWorkspaceBudget_Get_NilLimit(t *testing.T) {
|
||||
[]byte(`{}`), "http://localhost:9001",
|
||||
nil, 0, 1, 0.0, "", 0, "", "langgraph", "",
|
||||
0.0, 0.0, false,
|
||||
nil, // budget_limit NULL
|
||||
0)) // monthly_spend 0
|
||||
nil, // budget_limit NULL
|
||||
0, // monthly_spend 0
|
||||
false, // broadcast_enabled
|
||||
true)) // talk_to_user_enabled
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -96,7 +99,8 @@ func TestWorkspaceBudget_Get_WithLimit(t *testing.T) {
|
||||
nil, 0, 1, 0.0, "", 0, "", "langgraph", "",
|
||||
0.0, 0.0, false,
|
||||
int64(500), // budget_limit = $5.00 in DB
|
||||
int64(123))) // monthly_spend = $1.23 in DB
|
||||
int64(123), // monthly_spend = $1.23 in DB
|
||||
false, true)) // broadcast_enabled, talk_to_user_enabled
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
@@ -29,6 +29,7 @@ func TestWorkspaceGet_Success(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",
|
||||
}
|
||||
mock.ExpectQuery("SELECT w.id, w.name").
|
||||
WithArgs("cccccccc-0001-0000-0000-000000000000").
|
||||
@@ -36,7 +37,7 @@ func TestWorkspaceGet_Success(t *testing.T) {
|
||||
AddRow("cccccccc-0001-0000-0000-000000000000", "My Agent", "worker", 1, "online", []byte(`{"name":"test"}`),
|
||||
"http://localhost:8001", nil, 2, 1, 0.05, "", 3600, "working", "langgraph",
|
||||
"", 10.0, 20.0, false,
|
||||
nil, 0))
|
||||
nil, 0, false, true))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -118,6 +119,7 @@ func TestWorkspaceGet_RemovedReturns410(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",
|
||||
}
|
||||
mock.ExpectQuery("SELECT w.id, w.name").
|
||||
WithArgs(id).
|
||||
@@ -125,7 +127,7 @@ func TestWorkspaceGet_RemovedReturns410(t *testing.T) {
|
||||
AddRow(id, "Old Agent", "worker", 1, string(models.StatusRemoved), []byte(`null`),
|
||||
"", nil, 0, 1, 0.0, "", 0, "", "langgraph",
|
||||
"", 0.0, 0.0, false,
|
||||
nil, 0))
|
||||
nil, 0, false, true))
|
||||
mock.ExpectQuery(`SELECT updated_at FROM workspaces`).
|
||||
WithArgs(id).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"updated_at"}).AddRow(removedAt))
|
||||
@@ -181,6 +183,7 @@ func TestWorkspaceGet_RemovedReturns410WithNullRemovedAtOnTimestampFetchFailure(
|
||||
"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",
|
||||
}
|
||||
mock.ExpectQuery("SELECT w.id, w.name").
|
||||
WithArgs(id).
|
||||
@@ -188,7 +191,7 @@ func TestWorkspaceGet_RemovedReturns410WithNullRemovedAtOnTimestampFetchFailure(
|
||||
AddRow(id, "Vanished", "worker", 1, string(models.StatusRemoved), []byte(`null`),
|
||||
"", nil, 0, 1, 0.0, "", 0, "", "langgraph",
|
||||
"", 0.0, 0.0, false,
|
||||
nil, 0))
|
||||
nil, 0, false, true))
|
||||
// Simulate the row vanishing between the two queries.
|
||||
mock.ExpectQuery(`SELECT updated_at FROM workspaces`).
|
||||
WithArgs(id).
|
||||
@@ -243,6 +246,7 @@ func TestWorkspaceGet_RemovedWithIncludeQueryReturns200(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",
|
||||
}
|
||||
mock.ExpectQuery("SELECT w.id, w.name").
|
||||
WithArgs(id).
|
||||
@@ -250,7 +254,7 @@ func TestWorkspaceGet_RemovedWithIncludeQueryReturns200(t *testing.T) {
|
||||
AddRow(id, "Audit Agent", "worker", 1, string(models.StatusRemoved), []byte(`null`),
|
||||
"", nil, 0, 1, 0.0, "", 0, "", "langgraph",
|
||||
"", 0.0, 0.0, false,
|
||||
nil, 0))
|
||||
nil, 0, false, true))
|
||||
// last_outbound_at follow-up query (existing path)
|
||||
mock.ExpectQuery(`SELECT last_outbound_at FROM workspaces`).
|
||||
WithArgs(id).
|
||||
@@ -714,6 +718,7 @@ func TestWorkspaceList_Empty(t *testing.T) {
|
||||
"parent_id", "active_tasks", "last_error_rate", "last_sample_error",
|
||||
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
|
||||
"budget_limit", "monthly_spend",
|
||||
"broadcast_enabled", "talk_to_user_enabled",
|
||||
}))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -1417,6 +1422,7 @@ func TestWorkspaceGet_FinancialFieldsStripped(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",
|
||||
}
|
||||
// Populate with non-zero financial values to confirm they are stripped.
|
||||
mock.ExpectQuery("SELECT w.id, w.name").
|
||||
@@ -1425,7 +1431,7 @@ func TestWorkspaceGet_FinancialFieldsStripped(t *testing.T) {
|
||||
AddRow("cccccccc-0010-0000-0000-000000000000", "Finance Test", "worker", 1, "online", []byte(`{}`),
|
||||
"http://localhost:9001", nil, 0, 1, 0.0, "", 0, "", "langgraph",
|
||||
"", 0.0, 0.0, false,
|
||||
int64(50000), int64(12500))) // budget_limit=500 USD, spend=125 USD
|
||||
int64(50000), int64(12500), false, true)) // budget_limit=500 USD, spend=125 USD
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -1473,6 +1479,7 @@ func TestWorkspaceGet_SensitiveFieldsStripped(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",
|
||||
}
|
||||
mock.ExpectQuery("SELECT w.id, w.name").
|
||||
WithArgs("cccccccc-0955-0000-0000-000000000000").
|
||||
@@ -1485,7 +1492,7 @@ func TestWorkspaceGet_SensitiveFieldsStripped(t *testing.T) {
|
||||
"langgraph",
|
||||
"/home/user/secret-projects/client-work",
|
||||
0.0, 0.0, false,
|
||||
nil, 0))
|
||||
nil, 0, false, true))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
@@ -36,6 +36,15 @@ type Workspace struct {
|
||||
// to activity_logs, agent reads via GET /activity?since_id=). See
|
||||
// migration 045 + RFC #2339.
|
||||
DeliveryMode string `json:"delivery_mode" db:"delivery_mode"`
|
||||
// BroadcastEnabled: when true the workspace may call POST /broadcast to
|
||||
// deliver a message to all non-removed agent workspaces in the org.
|
||||
// Default false — only privileged orchestrators should hold this ability.
|
||||
BroadcastEnabled bool `json:"broadcast_enabled" db:"broadcast_enabled"`
|
||||
// TalkToUserEnabled: when false the workspace's send_message_to_user calls
|
||||
// and POST /notify requests are rejected with HTTP 403 so the agent is
|
||||
// forced to route updates through a parent workspace. Default true
|
||||
// (preserves existing behaviour for all workspaces).
|
||||
TalkToUserEnabled bool `json:"talk_to_user_enabled" db:"talk_to_user_enabled"`
|
||||
// Canvas layout fields (from JOIN)
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
|
||||
@@ -146,6 +146,9 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
|
||||
wsAdmin.GET("/workspaces", wh.List)
|
||||
wsAdmin.POST("/workspaces", wh.Create)
|
||||
wsAdmin.DELETE("/workspaces/:id", wh.Delete)
|
||||
// Ability toggles — admin-only so workspace agents cannot self-modify
|
||||
// broadcast_enabled or talk_to_user_enabled.
|
||||
wsAdmin.PATCH("/workspaces/:id/abilities", handlers.PatchAbilities)
|
||||
// Out-of-band bootstrap signal: CP's watcher POSTs here when it
|
||||
// detects "RUNTIME CRASHED" in a workspace EC2 console output,
|
||||
// so the canvas flips to failed in seconds instead of waiting
|
||||
@@ -201,6 +204,12 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
|
||||
// to 'hibernated'. The workspace auto-wakes on the next A2A message.
|
||||
wsAuth.POST("/hibernate", wh.Hibernate)
|
||||
|
||||
// Broadcast — send a message to all non-removed workspaces in the org.
|
||||
// Requires broadcast_enabled=true on the source workspace (checked
|
||||
// inside the handler). WorkspaceAuth on wsAuth proves token ownership.
|
||||
broadcastH := handlers.NewBroadcastHandler(broadcaster)
|
||||
wsAuth.POST("/broadcast", broadcastH.Broadcast)
|
||||
|
||||
// External-workspace credential lifecycle (issue #319 follow-up to
|
||||
// the Create flow). Both endpoints reject runtime ≠ external with
|
||||
// 400 — see external_rotate.go for the rationale.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE workspaces
|
||||
DROP COLUMN IF EXISTS broadcast_enabled,
|
||||
DROP COLUMN IF EXISTS talk_to_user_enabled;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Workspace abilities: opt-in flags that gate platform-level behaviours.
|
||||
--
|
||||
-- broadcast_enabled (default FALSE): when TRUE the workspace may call
|
||||
-- POST /workspaces/:id/broadcast to send a message to every non-removed
|
||||
-- agent workspace in the org. Off by default — only privileged
|
||||
-- orchestrator workspaces should hold this ability.
|
||||
--
|
||||
-- talk_to_user_enabled (default TRUE): when FALSE the workspace is not
|
||||
-- allowed to deliver messages to the canvas user via send_message_to_user /
|
||||
-- POST /notify. The platform returns HTTP 403 so the agent can forward its
|
||||
-- update to a parent workspace instead. Default TRUE preserves existing
|
||||
-- behaviour for all current workspaces.
|
||||
|
||||
ALTER TABLE workspaces
|
||||
ADD COLUMN IF NOT EXISTS broadcast_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS talk_to_user_enabled BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
@@ -29,6 +29,7 @@ from typing import Callable
|
||||
import inbox
|
||||
|
||||
from a2a_tools import (
|
||||
tool_broadcast_message,
|
||||
tool_chat_history,
|
||||
tool_check_task_status,
|
||||
tool_commit_memory,
|
||||
@@ -160,6 +161,11 @@ async def handle_tool_call(name: str, arguments: dict) -> str:
|
||||
arguments.get("before_ts", ""),
|
||||
source_workspace_id=arguments.get("source_workspace_id") or None,
|
||||
)
|
||||
elif name == "broadcast_message":
|
||||
return await tool_broadcast_message(
|
||||
arguments.get("message", ""),
|
||||
workspace_id=arguments.get("workspace_id") or None,
|
||||
)
|
||||
return f"Unknown tool: {name}"
|
||||
|
||||
|
||||
|
||||
@@ -137,6 +137,7 @@ from a2a_tools_delegation import ( # noqa: E402 (import after the from-a2a_cli
|
||||
# identically.
|
||||
from a2a_tools_messaging import ( # noqa: E402 (import after the top-of-module imports)
|
||||
_upload_chat_files,
|
||||
tool_broadcast_message,
|
||||
tool_chat_history,
|
||||
tool_get_workspace_info,
|
||||
tool_list_peers,
|
||||
|
||||
@@ -101,6 +101,50 @@ async def _upload_chat_files(
|
||||
return uploaded, None
|
||||
|
||||
|
||||
async def tool_broadcast_message(
|
||||
message: str,
|
||||
workspace_id: str | None = None,
|
||||
) -> str:
|
||||
"""Send a broadcast message to ALL agent workspaces in the org.
|
||||
|
||||
Requires the workspace to have broadcast_enabled=true (set by a user or
|
||||
admin via PATCH /workspaces/:id/abilities). Use for urgent org-wide
|
||||
signals — status changes, critical alerts, coordination instructions.
|
||||
Every non-removed workspace receives the message in its activity log so
|
||||
poll-mode agents pick it up, and push-mode canvases get a real-time
|
||||
BROADCAST_MESSAGE WebSocket event.
|
||||
|
||||
Args:
|
||||
message: The broadcast text. Keep it concise — all agents receive
|
||||
this, so avoid lengthy prose that floods every context.
|
||||
workspace_id: Optional. Which registered workspace to send the
|
||||
broadcast from. Single-workspace agents omit this.
|
||||
"""
|
||||
if not message:
|
||||
return "Error: message is required"
|
||||
target_workspace_id = (workspace_id or "").strip() or WORKSPACE_ID
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(
|
||||
f"{PLATFORM_URL}/workspaces/{target_workspace_id}/broadcast",
|
||||
json={"message": message},
|
||||
headers=_auth_headers_for_heartbeat(target_workspace_id),
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
delivered = data.get("delivered", "?")
|
||||
return f"Broadcast sent to {delivered} workspace(s)"
|
||||
if resp.status_code == 403:
|
||||
try:
|
||||
hint = resp.json().get("hint", "")
|
||||
except Exception:
|
||||
hint = ""
|
||||
return f"Error: broadcast ability not enabled.{(' ' + hint) if hint else ''}"
|
||||
return f"Error: platform returned {resp.status_code}"
|
||||
except Exception as e:
|
||||
return f"Error sending broadcast: {e}"
|
||||
|
||||
|
||||
async def tool_send_message_to_user(
|
||||
message: str,
|
||||
attachments: list[str] | None = None,
|
||||
@@ -151,6 +195,20 @@ async def tool_send_message_to_user(
|
||||
if uploaded:
|
||||
return f"Message sent to user with {len(uploaded)} attachment(s)"
|
||||
return "Message sent to user"
|
||||
if resp.status_code == 403:
|
||||
try:
|
||||
body = resp.json()
|
||||
if body.get("error") == "talk_to_user_disabled":
|
||||
hint = body.get("hint", "")
|
||||
return (
|
||||
"Error: this workspace is not allowed to send messages "
|
||||
"directly to the user (talk_to_user is disabled). "
|
||||
+ (hint + " " if hint else "")
|
||||
+ "Use delegate_task to forward your update to a parent "
|
||||
"or supervisor workspace that can reach the user."
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return f"Error: platform returned {resp.status_code}"
|
||||
except Exception as e:
|
||||
return f"Error sending message: {e}"
|
||||
|
||||
@@ -340,6 +340,10 @@ _CLI_A2A_COMMAND_KEYWORDS: dict[str, str | None] = {
|
||||
"delegate_task_async": "delegate --async",
|
||||
"check_task_status": "status",
|
||||
"get_workspace_info": "info",
|
||||
# `broadcast_message` is not exposed via the CLI subprocess interface
|
||||
# today — it's an MCP-first capability. If a2a_cli grows a `broadcast`
|
||||
# subcommand, map it here and the alignment test will gate the change.
|
||||
"broadcast_message": None,
|
||||
# `send_message_to_user` is not exposed via the CLI subprocess
|
||||
# interface today — it requires a structured `attachments` field
|
||||
# that wouldn't survive a positional-arg shell invocation cleanly.
|
||||
|
||||
@@ -51,6 +51,7 @@ from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
from a2a_tools import (
|
||||
tool_broadcast_message,
|
||||
tool_chat_history,
|
||||
tool_check_task_status,
|
||||
tool_commit_memory,
|
||||
@@ -288,6 +289,44 @@ _GET_WORKSPACE_INFO = ToolSpec(
|
||||
section=A2A_SECTION,
|
||||
)
|
||||
|
||||
_BROADCAST_MESSAGE = ToolSpec(
|
||||
name="broadcast_message",
|
||||
short=(
|
||||
"Send a message to ALL agent workspaces in the org simultaneously. "
|
||||
"Requires broadcast_enabled=true on this workspace (set by user/admin)."
|
||||
),
|
||||
when_to_use=(
|
||||
"Use for urgent, org-wide signals: critical status changes, emergency "
|
||||
"stop instructions, coordinated task announcements. Every non-removed "
|
||||
"workspace receives the message in its activity log (poll-mode agents "
|
||||
"see it on their next poll; push-mode canvases get a real-time banner). "
|
||||
"This tool returns an error if broadcast_enabled is false — a user or "
|
||||
"admin must enable it via the workspace abilities settings first."
|
||||
),
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"The broadcast text. Keep it concise — every agent in the "
|
||||
"org receives this in their activity feed."
|
||||
),
|
||||
},
|
||||
"workspace_id": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional. Multi-workspace mode: the registered workspace "
|
||||
"to broadcast from. Single-workspace agents omit this."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["message"],
|
||||
},
|
||||
impl=tool_broadcast_message,
|
||||
section=A2A_SECTION,
|
||||
)
|
||||
|
||||
_SEND_MESSAGE_TO_USER = ToolSpec(
|
||||
name="send_message_to_user",
|
||||
short=(
|
||||
@@ -603,6 +642,7 @@ TOOLS: list[ToolSpec] = [
|
||||
_CHECK_TASK_STATUS,
|
||||
_LIST_PEERS,
|
||||
_GET_WORKSPACE_INFO,
|
||||
_BROADCAST_MESSAGE,
|
||||
_SEND_MESSAGE_TO_USER,
|
||||
# Inbox (standalone-only; in-container returns informational error)
|
||||
_WAIT_FOR_MESSAGE,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
- **check_task_status**: Poll the status of a task started with delegate_task_async; returns result when done.
|
||||
- **list_peers**: List the workspaces this agent can communicate with — name, ID, status, role for each.
|
||||
- **get_workspace_info**: Get this workspace's own info — ID, name, role, tier, parent, status.
|
||||
- **broadcast_message**: Send a message to ALL agent workspaces in the org simultaneously. Requires broadcast_enabled=true on this workspace (set by user/admin).
|
||||
- **send_message_to_user**: Send a message directly to the user's canvas chat — pushed instantly via WebSocket. Use this to: (1) acknowledge a task immediately ('Got it, I'll start working on this'), (2) send interim progress updates while doing long work, (3) deliver follow-up results after delegation completes, (4) attach files (zip, pdf, csv, image) for the user to download via the `attachments` field (NEVER paste file URLs in `message`). The message appears in the user's chat as if you're proactively reaching out.
|
||||
- **wait_for_message**: Block until the next inbound message (canvas user OR peer agent) arrives, or until ``timeout_secs`` elapses.
|
||||
- **inbox_peek**: List pending inbound messages without removing them.
|
||||
@@ -26,6 +27,9 @@ Call this first when you need to delegate but don't know the target's ID. Access
|
||||
### get_workspace_info
|
||||
Use to introspect your own identity (e.g. before reporting back to the user, or to determine whether you're a tier-0 root that can write GLOBAL memory).
|
||||
|
||||
### broadcast_message
|
||||
Use for urgent, org-wide signals: critical status changes, emergency stop instructions, coordinated task announcements. Every non-removed workspace receives the message in its activity log (poll-mode agents see it on their next poll; push-mode canvases get a real-time banner). This tool returns an error if broadcast_enabled is false — a user or admin must enable it via the workspace abilities settings first.
|
||||
|
||||
### send_message_to_user
|
||||
Use proactively across the lifecycle of a task — early to acknowledge, mid-flight to update, late to deliver. Never paste file URLs in the message body — always pass absolute paths in `attachments` so the platform serves them as download chips (works on SaaS where external file hosts are unreachable).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user