Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ecd4a9ccb | |||
| 1c62a455b2 | |||
| 76609f4129 | |||
| 8439a066b6 | |||
| d7d376118d | |||
| 026d1c5fae | |||
| 48ad38e795 | |||
| 4bdb10b5e2 | |||
| 6452456f75 | |||
| 4978601032 | |||
| ec3e27a4ec | |||
| 4cc0e32a53 | |||
| e9693e12ff | |||
| bcca139caa | |||
| 6cf6e608d8 | |||
| 6947774e1b | |||
| 9afecfdfc7 |
@@ -118,17 +118,19 @@ _DIRECTIVE_RE = re.compile(
|
||||
def parse_directives(
|
||||
comment_body: str,
|
||||
numeric_aliases: dict[int, str],
|
||||
) -> list[tuple[str, str, str]]:
|
||||
) -> tuple[list[tuple[str, str, str]], list]:
|
||||
"""Extract /sop-ack and /sop-revoke directives from a comment body.
|
||||
|
||||
Returns a list of (kind, canonical_slug, note) tuples where:
|
||||
kind is "sop-ack" or "sop-revoke"
|
||||
canonical_slug is the normalized form (or "" if unparseable)
|
||||
note is the trailing free-text (may be "")
|
||||
Returns (directives, na_directives) where:
|
||||
directives is a list of (kind, canonical_slug, note) tuples
|
||||
kind is "sop-ack" or "sop-revoke"
|
||||
canonical_slug is the normalized form (or "" if unparseable)
|
||||
note is the trailing free-text (may be "")
|
||||
na_directives is reserved for future N/A handling (always [] for now)
|
||||
"""
|
||||
out: list[tuple[str, str, str]] = []
|
||||
if not comment_body:
|
||||
return out
|
||||
return out, []
|
||||
for m in _DIRECTIVE_RE.finditer(comment_body):
|
||||
kind = m.group(1)
|
||||
raw_slug = (m.group(2) or "").strip()
|
||||
@@ -159,7 +161,7 @@ def parse_directives(
|
||||
# If we collapsed multi-word slug into kebab and there's a
|
||||
# trailing-text group too, append it.
|
||||
out.append((kind, canonical, note_from_group))
|
||||
return out
|
||||
return out, []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -249,7 +251,8 @@ def compute_ack_state(
|
||||
user = (c.get("user") or {}).get("login", "")
|
||||
if not user:
|
||||
continue
|
||||
for kind, slug, _note in parse_directives(body, numeric_aliases):
|
||||
directives, _na = parse_directives(body, numeric_aliases)
|
||||
for kind, slug, _note in directives:
|
||||
if not slug:
|
||||
unparseable_per_user[user] = unparseable_per_user.get(user, 0) + 1
|
||||
continue
|
||||
|
||||
+20
-21
@@ -133,7 +133,6 @@ jobs:
|
||||
# the name match works on PRs that don't touch workspace-server/).
|
||||
platform-build:
|
||||
name: Platform (Go)
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
# mc#774 (closed 2026-05-14): Phase 4 flip of the platform-build job.
|
||||
# Phase 4 (#656) originally flipped this to continue-on-error: false based on
|
||||
@@ -154,29 +153,29 @@ jobs:
|
||||
run:
|
||||
working-directory: workspace-server
|
||||
steps:
|
||||
- if: needs.changes.outputs.platform != 'true'
|
||||
- if: false
|
||||
working-directory: .
|
||||
run: echo "No platform/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection."
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
- if: always()
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
- if: always()
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
- if: always()
|
||||
run: go mod download
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
- if: always()
|
||||
run: go build ./cmd/server
|
||||
# CLI (molecli) moved to standalone repo: git.moleculesai.app/molecule-ai/molecule-cli
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
- if: always()
|
||||
run: go vet ./...
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
- if: always()
|
||||
name: Install golangci-lint
|
||||
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
- if: always()
|
||||
name: Run golangci-lint
|
||||
run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./...
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
- if: always()
|
||||
name: Diagnostic — per-package verbose 60s
|
||||
run: |
|
||||
set +e
|
||||
@@ -192,7 +191,7 @@ jobs:
|
||||
echo "::endgroup::"
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
- if: always()
|
||||
name: Run tests with race detection and coverage
|
||||
# Explicit timeout: cold runner cache causes OOM kills at ~4m39s on the
|
||||
# full ./... suite with race detection + coverage. A 10m per-step timeout
|
||||
@@ -200,7 +199,7 @@ jobs:
|
||||
# instead of OOM-killing. The job-level timeout (15m) is a backstop.
|
||||
run: go test -race -timeout 10m -coverprofile=coverage.out ./...
|
||||
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
- if: always()
|
||||
name: Per-file coverage report
|
||||
# Advisory — lists every source file with its coverage so reviewers
|
||||
# can see at-a-glance where gaps are. Sorted ascending so the worst
|
||||
@@ -214,7 +213,7 @@ jobs:
|
||||
END {for (f in s) printf "%6.1f%% %s\n", s[f]/c[f], f}' \
|
||||
| sort -n
|
||||
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
- if: always()
|
||||
name: Check coverage thresholds
|
||||
# Enforces two gates from #1823 Layer 1:
|
||||
# 1. Total floor (25% — ratchet plan in COVERAGE_FLOOR.md).
|
||||
@@ -302,28 +301,28 @@ jobs:
|
||||
# siblings — verified empirically on PR #2314).
|
||||
canvas-build:
|
||||
name: Canvas (Next.js)
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
|
||||
continue-on-error: false
|
||||
defaults:
|
||||
run:
|
||||
working-directory: canvas
|
||||
steps:
|
||||
- if: needs.changes.outputs.canvas != 'true'
|
||||
- if: false
|
||||
working-directory: .
|
||||
run: echo "No canvas/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection."
|
||||
- if: needs.changes.outputs.canvas == 'true'
|
||||
- if: always()
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: needs.changes.outputs.canvas == 'true'
|
||||
- if: always()
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
- if: needs.changes.outputs.canvas == 'true'
|
||||
- if: always()
|
||||
run: rm -f package-lock.json && npm install
|
||||
- if: needs.changes.outputs.canvas == 'true'
|
||||
- if: always()
|
||||
run: npm run build
|
||||
- if: needs.changes.outputs.canvas == 'true'
|
||||
- if: always()
|
||||
name: Run tests with coverage
|
||||
# Coverage instrumentation is configured in canvas/vitest.config.ts
|
||||
# (provider: v8, reporters: text + html + json-summary). Step 2 of
|
||||
@@ -332,7 +331,7 @@ jobs:
|
||||
# tracked in #1815) after the team sees what current coverage is.
|
||||
run: npx vitest run --coverage
|
||||
- name: Upload coverage summary as artifact
|
||||
if: needs.changes.outputs.canvas == 'true' && always()
|
||||
if: always()
|
||||
# Pinned to v3 for Gitea act_runner v0.6 compatibility — v4+ uses
|
||||
# the GHES 3.10+ artifact protocol that Gitea 1.22.x does NOT
|
||||
# implement, surfacing as `GHESNotSupportedError: @actions/artifact
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
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 ]
|
||||
@@ -14,16 +14,18 @@ import (
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/push"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ActivityHandler struct {
|
||||
broadcaster *events.Broadcaster
|
||||
notifier *push.Notifier
|
||||
}
|
||||
|
||||
func NewActivityHandler(b *events.Broadcaster) *ActivityHandler {
|
||||
return &ActivityHandler{broadcaster: b}
|
||||
func NewActivityHandler(b *events.Broadcaster, notifier *push.Notifier) *ActivityHandler {
|
||||
return &ActivityHandler{broadcaster: b, notifier: notifier}
|
||||
}
|
||||
|
||||
// List handles GET /workspaces/:id/activity?type=&source=&limit=&since_secs=&since_id=
|
||||
@@ -476,12 +478,19 @@ func (h *ActivityHandler) Notify(c *gin.Context) {
|
||||
for _, a := range body.Attachments {
|
||||
attachments = append(attachments, AgentMessageAttachment(a))
|
||||
}
|
||||
writer := NewAgentMessageWriter(db.DB, h.broadcaster)
|
||||
writer := NewAgentMessageWriter(db.DB, h.broadcaster, h.notifier)
|
||||
if err := writer.Send(c.Request.Context(), workspaceID, body.Message, attachments); err != nil {
|
||||
if errors.Is(err, ErrWorkspaceNotFound) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestActivityHandler_SinceID_ReturnsNewerASC(t *testing.T) {
|
||||
WillReturnRows(newActivityRows())
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -69,7 +69,7 @@ func TestActivityHandler_SinceID_CursorNotFound_410(t *testing.T) {
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -101,7 +101,7 @@ func TestActivityHandler_SinceID_CrossWorkspaceCursor_410(t *testing.T) {
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -137,7 +137,7 @@ func TestActivityHandler_SinceID_CombinedWithSinceSecs(t *testing.T) {
|
||||
WillReturnRows(newActivityRows())
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
@@ -41,7 +41,7 @@ func TestActivityHandler_SinceSecs_Accepted(t *testing.T) {
|
||||
WillReturnRows(newActivityRows())
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -70,7 +70,7 @@ func TestActivityHandler_SinceSecs_ClampedAt30Days(t *testing.T) {
|
||||
WillReturnRows(newActivityRows())
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -106,7 +106,7 @@ func TestActivityHandler_SinceSecs_InvalidRejected(t *testing.T) {
|
||||
// No DB call expected; bad input must be caught before the query.
|
||||
setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -142,7 +142,7 @@ func TestActivityHandler_SinceSecs_Omitted(t *testing.T) {
|
||||
WillReturnRows(newActivityRows())
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestSessionSearchReturnsActivityAndMemory(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
rows := sqlmock.NewRows([]string{
|
||||
"kind", "id", "workspace_id", "label", "content", "method", "status", "request_body", "response_body", "created_at",
|
||||
@@ -68,7 +68,7 @@ func TestSessionSearchReturnsActivityAndMemory(t *testing.T) {
|
||||
func TestActivityList_SourceCanvas(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
// Expect query with "source_id IS NULL"
|
||||
mock.ExpectQuery(`SELECT .+ FROM activity_logs WHERE workspace_id = .+ AND source_id IS NULL`).
|
||||
@@ -97,7 +97,7 @@ func TestActivityList_SourceCanvas(t *testing.T) {
|
||||
func TestActivityList_SourceAgent(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
// Expect query with "source_id IS NOT NULL"
|
||||
mock.ExpectQuery(`SELECT .+ FROM activity_logs WHERE workspace_id = .+ AND source_id IS NOT NULL`).
|
||||
@@ -126,7 +126,7 @@ func TestActivityList_SourceAgent(t *testing.T) {
|
||||
func TestActivityList_SourceInvalid(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -142,7 +142,7 @@ func TestActivityList_SourceInvalid(t *testing.T) {
|
||||
func TestActivityList_SourceWithType(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
// Both type and source filters
|
||||
mock.ExpectQuery(`SELECT .+ FROM activity_logs WHERE workspace_id = .+ AND activity_type = .+ AND source_id IS NULL`).
|
||||
@@ -181,7 +181,7 @@ const testPeerUUID = "11111111-2222-3333-4444-555555555555"
|
||||
func TestActivityList_PeerIDFilter(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
// peer_id binds twice in the query (source_id OR target_id) but is
|
||||
// added to args once — sqlmock matches positional args, so the
|
||||
@@ -220,7 +220,7 @@ func TestActivityList_PeerIDComposesWithType(t *testing.T) {
|
||||
// of the builder can't silently rearrange placeholders.
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
mock.ExpectQuery(
|
||||
`SELECT .+ FROM activity_logs WHERE workspace_id = .+ AND activity_type = .+ AND source_id IS NOT NULL AND \(source_id = .+ OR target_id = .+\)`,
|
||||
@@ -258,7 +258,7 @@ func TestActivityList_PeerIDRejectsNonUUID(t *testing.T) {
|
||||
// otherwise interpolate the value into the URL or another query.
|
||||
gin.SetMode(gin.TestMode)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
for _, bad := range []string{
|
||||
"not-a-uuid",
|
||||
@@ -292,7 +292,7 @@ func TestActivityList_PeerIDRejectsNonUUID(t *testing.T) {
|
||||
func TestActivityList_BeforeTSFilter(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
cutoff, _ := time.Parse(time.RFC3339, "2026-05-01T00:00:00Z")
|
||||
mock.ExpectQuery(
|
||||
@@ -328,7 +328,7 @@ func TestActivityList_BeforeTSComposesWithPeerID(t *testing.T) {
|
||||
// can't silently drop one filter or reorder placeholders.
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
cutoff, _ := time.Parse(time.RFC3339, "2026-05-01T00:00:00Z")
|
||||
mock.ExpectQuery(
|
||||
@@ -363,7 +363,7 @@ func TestActivityList_BeforeTSComposesWithPeerID(t *testing.T) {
|
||||
func TestActivityList_BeforeTSRejectsInvalidFormat(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
for _, bad := range []string{
|
||||
"yesterday",
|
||||
@@ -400,7 +400,7 @@ func TestActivityReport_AcceptsMemoryWriteType(t *testing.T) {
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -426,7 +426,7 @@ func TestActivityReport_RejectsUnknownType(t *testing.T) {
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -464,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`).
|
||||
@@ -478,7 +478,7 @@ func TestNotify_PersistsToActivityLogsForReloadRecovery(t *testing.T) {
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -511,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
|
||||
@@ -527,7 +527,7 @@ func TestNotify_WithAttachments_PersistsFilePartsForReload(t *testing.T) {
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -593,7 +593,7 @@ func TestNotify_RejectsAttachmentWithEmptyURIOrName(t *testing.T) {
|
||||
// only if the handler unexpectedly queries.
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -640,14 +640,14 @@ 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"))
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -44,6 +44,7 @@ import (
|
||||
"log"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/push"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/textutil"
|
||||
)
|
||||
|
||||
@@ -54,6 +55,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
|
||||
@@ -76,12 +82,14 @@ type AgentMessageAttachment struct {
|
||||
type AgentMessageWriter struct {
|
||||
db *sql.DB
|
||||
broadcaster events.EventEmitter
|
||||
notifier *push.Notifier
|
||||
}
|
||||
|
||||
// NewAgentMessageWriter binds the writer to the platform's DB pool +
|
||||
// WebSocket broadcaster.
|
||||
func NewAgentMessageWriter(db *sql.DB, broadcaster events.EventEmitter) *AgentMessageWriter {
|
||||
return &AgentMessageWriter{db: db, broadcaster: broadcaster}
|
||||
// WebSocket broadcaster. notifier may be nil if push notifications are
|
||||
// not configured.
|
||||
func NewAgentMessageWriter(db *sql.DB, broadcaster events.EventEmitter, notifier *push.Notifier) *AgentMessageWriter {
|
||||
return &AgentMessageWriter{db: db, broadcaster: broadcaster, notifier: notifier}
|
||||
}
|
||||
|
||||
// Send delivers a single agent → user message. Look up + broadcast +
|
||||
@@ -107,16 +115,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
|
||||
@@ -132,7 +144,12 @@ func (w *AgentMessageWriter) Send(
|
||||
}
|
||||
w.broadcaster.BroadcastOnly(workspaceID, string(events.EventAgentMessage), broadcastPayload)
|
||||
|
||||
// 3. Persist for chat-history hydration. response_body shape MUST stay
|
||||
// 3. Send push notifications to mobile devices.
|
||||
if w.notifier != nil {
|
||||
w.notifier.NotifyAgentMessage(ctx, workspaceID, wsName, message)
|
||||
}
|
||||
|
||||
// 4. Persist for chat-history hydration. response_body shape MUST stay
|
||||
// in sync with extractResponseText + extractFilesFromTask in
|
||||
// canvas/src/components/tabs/chat/historyHydration.ts:
|
||||
// - extractResponseText reads body.result (string) → renders text
|
||||
|
||||
@@ -86,11 +86,11 @@ func (c *capturingEmitter) RecordAndBroadcast(_ context.Context, eventType strin
|
||||
// path: workspace lookup, broadcast, INSERT, return nil.
|
||||
func TestAgentMessageWriter_Send_Success_NoAttachments(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster(), nil)
|
||||
|
||||
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(
|
||||
@@ -114,11 +114,11 @@ func TestAgentMessageWriter_Send_Success_NoAttachments(t *testing.T) {
|
||||
// Drift here = chips disappear on chat reload.
|
||||
func TestAgentMessageWriter_Send_Success_WithAttachments(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster(), nil)
|
||||
|
||||
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(
|
||||
@@ -171,11 +171,11 @@ func TestAgentMessageWriter_Send_Success_WithAttachments(t *testing.T) {
|
||||
func TestAgentMessageWriter_Send_WorkspaceNotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
emitter := &capturingEmitter{}
|
||||
w := NewAgentMessageWriter(db.DB, emitter)
|
||||
w := NewAgentMessageWriter(db.DB, emitter, nil)
|
||||
|
||||
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) {
|
||||
@@ -200,11 +200,11 @@ func TestAgentMessageWriter_Send_WorkspaceNotFound(t *testing.T) {
|
||||
// broadcast.
|
||||
func TestAgentMessageWriter_Send_DBInsertFailureStillReturnsNil(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster(), nil)
|
||||
|
||||
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"))
|
||||
@@ -221,11 +221,11 @@ func TestAgentMessageWriter_Send_DBInsertFailureStillReturnsNil(t *testing.T) {
|
||||
// table doesn't carry multi-KB summaries that bloat list queries.
|
||||
func TestAgentMessageWriter_Send_PreviewTruncation(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster(), nil)
|
||||
|
||||
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`).
|
||||
@@ -261,11 +261,11 @@ func TestAgentMessageWriter_Send_PreviewTruncation(t *testing.T) {
|
||||
func TestAgentMessageWriter_Send_BroadcastsAgentMessageEvent(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
emitter := &capturingEmitter{}
|
||||
w := NewAgentMessageWriter(db.DB, emitter)
|
||||
w := NewAgentMessageWriter(db.DB, emitter, nil)
|
||||
|
||||
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))
|
||||
|
||||
@@ -312,10 +312,10 @@ func TestAgentMessageWriter_Send_BroadcastsAgentMessageEvent(t *testing.T) {
|
||||
// real incidents in alerting.
|
||||
func TestAgentMessageWriter_Send_DBErrorOnLookupReturnsWrapped(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster(), nil)
|
||||
|
||||
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)
|
||||
|
||||
@@ -344,15 +344,15 @@ func TestAgentMessageWriter_Send_DBErrorOnLookupReturnsWrapped(t *testing.T) {
|
||||
// coverage. Now it does.
|
||||
func TestAgentMessageWriter_Send_NonASCIIMessagePersists(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster(), nil)
|
||||
|
||||
// 200-rune CJK message — exceeds the 80-rune cap, would have hit
|
||||
// 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(
|
||||
@@ -393,11 +393,11 @@ func TestAgentMessageWriter_Send_NonASCIIMessagePersists(t *testing.T) {
|
||||
func TestAgentMessageWriter_Send_OmitsAttachmentsKeyWhenEmpty(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
emitter := &capturingEmitter{}
|
||||
w := NewAgentMessageWriter(db.DB, emitter)
|
||||
w := NewAgentMessageWriter(db.DB, emitter, nil)
|
||||
|
||||
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))
|
||||
|
||||
|
||||
@@ -116,6 +116,9 @@ func (h *ApprovalsHandler) ListAll(c *gin.Context) {
|
||||
"created_at": createdAt,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ListPendingApprovals rows.Err: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, approvals)
|
||||
}
|
||||
@@ -155,6 +158,9 @@ func (h *ApprovalsHandler) List(c *gin.Context) {
|
||||
"created_at": createdAt,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ListApprovals rows.Err workspace=%s: %v", workspaceID, err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, approvals)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -392,21 +392,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)
|
||||
@@ -631,7 +631,7 @@ func TestActivityHandler_List(t *testing.T) {
|
||||
WillReturnRows(rows)
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -680,7 +680,7 @@ func TestActivityHandler_ListByType(t *testing.T) {
|
||||
WillReturnRows(rows)
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -708,7 +708,7 @@ func TestActivityHandler_Report(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
// Expect the INSERT into activity_logs
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
@@ -737,7 +737,7 @@ func TestActivityHandler_Report_InvalidType(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -965,7 +965,7 @@ func TestActivityHandler_ListEmpty(t *testing.T) {
|
||||
WillReturnRows(sqlmock.NewRows(columns))
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -999,7 +999,7 @@ func TestActivityHandler_ListCustomLimit(t *testing.T) {
|
||||
WillReturnRows(sqlmock.NewRows(columns))
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -1032,7 +1032,7 @@ func TestActivityHandler_ListMaxLimit(t *testing.T) {
|
||||
WillReturnRows(sqlmock.NewRows(columns))
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -1060,7 +1060,7 @@ func TestActivityHandler_ReportAllValidTypes(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
@@ -1091,7 +1091,7 @@ func TestActivityHandler_ReportAllValidTypes(t *testing.T) {
|
||||
func TestActivityHandler_ReportMissingBody(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -1120,13 +1120,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()
|
||||
@@ -1164,7 +1165,7 @@ func TestActivityHandler_Report_SourceIDSpoofRejected(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -1187,7 +1188,7 @@ func TestActivityHandler_Report_MatchingSourceIDAccepted(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
@@ -1217,7 +1218,7 @@ func TestActivityHandler_Report_SourceIDLogInjection(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
@@ -248,6 +248,9 @@ func (h *InstructionsHandler) Resolve(c *gin.Context) {
|
||||
b.WriteString(content)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ResolveInstructions rows.Err workspace=%s: %v", workspaceID, err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"workspace_id": workspaceID,
|
||||
@@ -258,6 +261,7 @@ func (h *InstructionsHandler) Resolve(c *gin.Context) {
|
||||
func scanInstructions(rows interface {
|
||||
Next() bool
|
||||
Scan(dest ...interface{}) error
|
||||
Err() error
|
||||
}) []Instruction {
|
||||
var instructions []Instruction
|
||||
for rows.Next() {
|
||||
@@ -269,6 +273,9 @@ func scanInstructions(rows interface {
|
||||
}
|
||||
instructions = append(instructions, inst)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("scanInstructions rows.Err: %v", err)
|
||||
}
|
||||
if instructions == nil {
|
||||
instructions = []Instruction{}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/push"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -84,6 +85,7 @@ type mcpTool struct {
|
||||
type MCPHandler struct {
|
||||
database *sql.DB
|
||||
broadcaster *events.Broadcaster
|
||||
notifier *push.Notifier
|
||||
|
||||
// memv2 is the v2 memory plugin wiring (RFC #2728). nil-safe:
|
||||
// every v2 tool calls memoryV2Available() first and returns a
|
||||
@@ -94,8 +96,9 @@ type MCPHandler struct {
|
||||
|
||||
// NewMCPHandler wires the handler to db and broadcaster.
|
||||
// Pass db.DB and the platform broadcaster at router-setup time.
|
||||
func NewMCPHandler(database *sql.DB, broadcaster *events.Broadcaster) *MCPHandler {
|
||||
return &MCPHandler{database: database, broadcaster: broadcaster}
|
||||
// notifier may be nil if push notifications are not configured.
|
||||
func NewMCPHandler(database *sql.DB, broadcaster *events.Broadcaster, notifier *push.Notifier) *MCPHandler {
|
||||
return &MCPHandler{database: database, broadcaster: broadcaster, notifier: notifier}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
func newMCPHandler(t *testing.T) (*MCPHandler, sqlmock.Sqlmock) {
|
||||
t.Helper()
|
||||
mock := setupTestDB(t)
|
||||
h := NewMCPHandler(db.DB, newTestBroadcaster())
|
||||
h := NewMCPHandler(db.DB, newTestBroadcaster(), nil)
|
||||
return h, mock
|
||||
}
|
||||
|
||||
@@ -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'`
|
||||
|
||||
@@ -392,7 +392,7 @@ func (h *MCPHandler) toolSendMessageToUser(ctx context.Context, workspaceID stri
|
||||
// (the tool args don't accept them); pass nil. If a future tool
|
||||
// schema adds an attachments arg, build []AgentMessageAttachment
|
||||
// and pass through.
|
||||
writer := NewAgentMessageWriter(h.database, h.broadcaster)
|
||||
writer := NewAgentMessageWriter(h.database, h.broadcaster, h.notifier)
|
||||
if err := writer.Send(ctx, workspaceID, message, nil); err != nil {
|
||||
if errors.Is(err, ErrWorkspaceNotFound) {
|
||||
return "", fmt.Errorf("workspace not found")
|
||||
|
||||
@@ -287,7 +287,7 @@ func TestRenderCategoryRoutingYAML_StableOrdering(t *testing.T) {
|
||||
if ai <= 0 || zi <= 0 || mi <= 0 {
|
||||
t.Fatalf("could not locate all keys in output: %s", out)
|
||||
}
|
||||
if !(ai < mi && mi < zi) {
|
||||
if ai >= mi || mi >= zi {
|
||||
t.Errorf("keys not sorted: alpha=%d middle=%d zebra=%d, output:\n%s", ai, mi, zi, out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,9 @@ func (h *TokenHandler) List(c *gin.Context) {
|
||||
}
|
||||
tokens = append(tokens, t)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ListTokens rows.Err workspace=%s: %v", workspaceID, err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"tokens": tokens,
|
||||
|
||||
@@ -595,7 +595,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
|
||||
@@ -604,7 +604,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
|
||||
}
|
||||
@@ -628,6 +628,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
|
||||
@@ -663,7 +665,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'
|
||||
@@ -723,7 +726,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,142 @@
|
||||
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 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.
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Collect all non-removed agent workspaces (excludes the sender itself).
|
||||
rows, err := db.DB.QueryContext(ctx,
|
||||
`SELECT id FROM workspaces WHERE status != 'removed' AND id != $1`,
|
||||
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]) + "…"
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -805,6 +805,9 @@ func loadWorkspaceSecrets(ctx context.Context, workspaceID string) (map[string]s
|
||||
envVars[k] = string(decrypted)
|
||||
}
|
||||
}
|
||||
if err := globalRows.Err(); err != nil {
|
||||
log.Printf("Provisioner: global_secrets rows.Err workspace=%s: %v", workspaceID, err)
|
||||
}
|
||||
}
|
||||
wsRows, err := db.DB.QueryContext(ctx,
|
||||
`SELECT key, encrypted_value, encryption_version FROM workspace_secrets WHERE workspace_id = $1`, workspaceID)
|
||||
@@ -823,6 +826,9 @@ func loadWorkspaceSecrets(ctx context.Context, workspaceID string) (map[string]s
|
||||
envVars[k] = string(decrypted)
|
||||
}
|
||||
}
|
||||
if err := wsRows.Err(); err != nil {
|
||||
log.Printf("Provisioner: workspace_secrets rows.Err workspace=%s: %v", workspaceID, err)
|
||||
}
|
||||
}
|
||||
return envVars, ""
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
@@ -676,6 +680,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()
|
||||
@@ -1379,6 +1384,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").
|
||||
@@ -1387,7 +1393,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)
|
||||
@@ -1435,6 +1441,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").
|
||||
@@ -1447,7 +1454,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)
|
||||
|
||||
@@ -207,7 +207,7 @@ func setupSwapEnv(t *testing.T) (*handlers.MCPHandler, *flatPlugin, sqlmock.Sqlm
|
||||
resolver := namespace.New(db)
|
||||
|
||||
// MCPHandler needs a real *sql.DB; pass the sqlmock-backed one.
|
||||
h := handlers.NewMCPHandler(db, nil).WithMemoryV2(cl, resolver)
|
||||
h := handlers.NewMCPHandler(db, nil, nil).WithMemoryV2(cl, resolver)
|
||||
return h, plugin, mock
|
||||
}
|
||||
|
||||
@@ -430,7 +430,7 @@ func TestE2E_PluginUnreachable_AgentSeesClearError(t *testing.T) {
|
||||
db, _, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
resolver := namespace.New(db)
|
||||
h := handlers.NewMCPHandler(db, nil).WithMemoryV2(cl, resolver)
|
||||
h := handlers.NewMCPHandler(db, nil, nil).WithMemoryV2(cl, resolver)
|
||||
|
||||
_, err := h.Dispatch(context.Background(), "root-1", "commit_memory_v2", map[string]interface{}{
|
||||
"content": "x",
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -4,12 +4,14 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -156,6 +158,11 @@ type cpProvisionRequest struct {
|
||||
Tier int `json:"tier"`
|
||||
PlatformURL string `json:"platform_url"`
|
||||
Env map[string]string `json:"env"`
|
||||
// ConfigFiles are template + generated config files to write into the
|
||||
// EC2 instance's /configs directory. OFFSEC-010: collected by
|
||||
// collectCPConfigFiles which rejects symlinks and non-regular files
|
||||
// before including them. Serialised as base64 to avoid JSON escaping.
|
||||
ConfigFiles map[string]string `json:"config_files,omitempty"`
|
||||
}
|
||||
|
||||
type cpProvisionResponse struct {
|
||||
@@ -179,6 +186,16 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
|
||||
}
|
||||
env["ADMIN_TOKEN"] = p.adminToken
|
||||
}
|
||||
// Collect template files and generated configs, with OFFSEC-010 guards:
|
||||
// - Rejects symlinks at the template root (prevents bypass via symlink traversal)
|
||||
// - Skips symlinks during WalkDir (prevents /etc/passwd etc. inclusion)
|
||||
// - Validates all paths are relative and non-escaping
|
||||
// - Caps total size at 12 KiB to prevent payload bloat
|
||||
configFiles, err := collectCPConfigFiles(cfg)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cp provisioner: collect config files: %w", err)
|
||||
}
|
||||
|
||||
req := cpProvisionRequest{
|
||||
OrgID: p.orgID,
|
||||
WorkspaceID: cfg.WorkspaceID,
|
||||
@@ -186,6 +203,7 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
|
||||
Tier: cfg.Tier,
|
||||
PlatformURL: cfg.PlatformURL,
|
||||
Env: env,
|
||||
ConfigFiles: configFiles,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(req)
|
||||
@@ -237,6 +255,94 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
|
||||
return result.InstanceID, nil
|
||||
}
|
||||
|
||||
const cpConfigFilesMaxBytes = 12 << 10
|
||||
|
||||
// isCPTemplateConfigFile restricts which files from a template directory are
|
||||
// eligible for transport to the control plane. Only config.yaml (the runtime
|
||||
// entrypoint config) and files under prompts/ (system prompts) are needed;
|
||||
// shipping arbitrary files (e.g. adapter.py, Dockerfile) is both unnecessary
|
||||
// and a potential data-exfiltration surface.
|
||||
func isCPTemplateConfigFile(name string) bool {
|
||||
name = filepath.ToSlash(filepath.Clean(name))
|
||||
return name == "config.yaml" || strings.HasPrefix(name, "prompts/")
|
||||
}
|
||||
|
||||
func collectCPConfigFiles(cfg WorkspaceConfig) (map[string]string, error) {
|
||||
files := make(map[string]string)
|
||||
total := 0
|
||||
addFile := func(name string, data []byte) error {
|
||||
name = filepath.ToSlash(filepath.Clean(name))
|
||||
if name == "." || strings.HasPrefix(name, "../") || strings.HasPrefix(name, "/") || strings.Contains(name, "/../") {
|
||||
return fmt.Errorf("invalid config file path %q", name)
|
||||
}
|
||||
total += len(data)
|
||||
if total > cpConfigFilesMaxBytes {
|
||||
return fmt.Errorf("config files exceed %d bytes", cpConfigFilesMaxBytes)
|
||||
}
|
||||
files[name] = base64.StdEncoding.EncodeToString(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfg.TemplatePath != "" {
|
||||
// Reject symlinks on the root itself — WalkDir follows symlinks,
|
||||
// so a symlink TemplatePath that escapes the intended root directory
|
||||
// would bypass the subsequent path-relativization checks below.
|
||||
rootInfo, err := os.Lstat(cfg.TemplatePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("collectCPConfigFiles: lstat template path: %w", err)
|
||||
}
|
||||
if rootInfo.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, fmt.Errorf("collectCPConfigFiles: template path must not be a symlink")
|
||||
}
|
||||
err = filepath.WalkDir(cfg.TemplatePath, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
// Skip symlinks — WalkDir follows them by default, which means
|
||||
// a symlink inside the template dir pointing to /etc/passwd
|
||||
// would be traversed even though the resulting relative-path
|
||||
// check would correctly reject it. Defense-in-depth: don't
|
||||
// follow symlinks at all. (OFFSEC-010)
|
||||
if d.Type()&os.ModeSymlink != 0 {
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(cfg.TemplatePath, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !isCPTemplateConfigFile(rel) {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return addFile(rel, data)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for name, data := range cfg.ConfigFiles {
|
||||
if err := addFile(name, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
// Stop terminates the workspace's EC2 instance via the control plane.
|
||||
//
|
||||
// Looks up the actual EC2 instance_id from the workspaces table before
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package provisioner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -279,6 +283,105 @@ func TestStart_TransportFailureSurfaces(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStart_CollectsConfigFiles — verify that collectCPConfigFiles is called and
|
||||
// its result is included in the cpProvisionRequest sent to the control plane.
|
||||
// Tests the OFFSEC-010 wiring: the function's symlink guards are only effective
|
||||
// if the call site actually invokes it.
|
||||
func TestStart_CollectsConfigFiles(t *testing.T) {
|
||||
tmpl := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(tmpl, "config.yaml"), []byte("name: test\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// adapter.py is within the size limit but is NOT config.yaml or prompts/,
|
||||
// so isCPTemplateConfigFile must exclude it from the transport.
|
||||
if err := os.WriteFile(filepath.Join(tmpl, "adapter.py"), bytes.Repeat([]byte("x"), cpConfigFilesMaxBytes), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var gotBody cpProvisionRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = io.WriteString(w, `{"instance_id":"i-abc123","state":"pending"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := &CPProvisioner{baseURL: srv.URL, orgID: "org-1", httpClient: srv.Client()}
|
||||
_, err := p.Start(context.Background(), WorkspaceConfig{
|
||||
WorkspaceID: "ws-1",
|
||||
Runtime: "python",
|
||||
Tier: 1,
|
||||
PlatformURL: "http://tenant",
|
||||
TemplatePath: tmpl,
|
||||
ConfigFiles: map[string][]byte{"generated.json": []byte(`{"key":"value"}`)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
// config.yaml from TemplatePath must be base64-encoded in ConfigFiles
|
||||
if len(gotBody.ConfigFiles) == 0 {
|
||||
t.Fatal("ConfigFiles is empty: collectCPConfigFiles was not called")
|
||||
}
|
||||
|
||||
// Find config.yaml entry and verify it's valid base64 + correct content
|
||||
var foundTemplate, foundGenerated bool
|
||||
for name, encoded := range gotBody.ConfigFiles {
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
t.Errorf("ConfigFiles[%q] is not valid base64: %v", name, err)
|
||||
continue
|
||||
}
|
||||
if name == "config.yaml" && string(decoded) == "name: test\n" {
|
||||
foundTemplate = true
|
||||
}
|
||||
if name == "generated.json" && string(decoded) == `{"key":"value"}` {
|
||||
foundGenerated = true
|
||||
}
|
||||
}
|
||||
if !foundTemplate {
|
||||
t.Errorf("ConfigFiles missing config.yaml from TemplatePath")
|
||||
}
|
||||
if !foundGenerated {
|
||||
t.Errorf("ConfigFiles missing generated.json from ConfigFiles")
|
||||
}
|
||||
// adapter.py must NOT be in ConfigFiles — isCPTemplateConfigFile filters it out
|
||||
for name := range gotBody.ConfigFiles {
|
||||
if name == "adapter.py" {
|
||||
t.Errorf("adapter.py should not be in ConfigFiles — isCPTemplateConfigFile must filter it out")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStart_SymlinkTemplatePathError — a symlink TemplatePath should cause
|
||||
// collectCPConfigFiles to return an error, which Start must propagate.
|
||||
// Without this wiring, OFFSEC-010's root-symlink guard is dead code.
|
||||
func TestStart_SymlinkTemplatePathError(t *testing.T) {
|
||||
// Create a temp file and a symlink pointing to it
|
||||
tmp := t.TempDir()
|
||||
realFile := filepath.Join(tmp, "real")
|
||||
if err := os.WriteFile(realFile, []byte("data"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
symlink := filepath.Join(tmp, "template_link")
|
||||
if err := os.Symlink(realFile, symlink); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &CPProvisioner{baseURL: "http://unused", orgID: "org-1", httpClient: &http.Client{Timeout: time.Second}}
|
||||
_, err := p.Start(context.Background(), WorkspaceConfig{
|
||||
WorkspaceID: "ws-1",
|
||||
Runtime: "python",
|
||||
TemplatePath: symlink, // symlink root → OFFSEC-010 guard should fire
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for symlink TemplatePath, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "symlink") {
|
||||
t.Errorf("error should mention symlink, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestStop_SendsBothAuthHeaders — verify #118/#130 compliance on the
|
||||
// teardown path. Any call to /cp/workspaces/:id must carry both the
|
||||
// platform-wide shared secret AND the per-tenant admin token, or the
|
||||
@@ -842,3 +945,67 @@ func TestIsRunning_EmptyInstanceIDReturnsFalse(t *testing.T) {
|
||||
t.Errorf("IsRunning with empty instance_id should return running=false, got true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectCPConfigFiles_SkipsSymlinks — WalkDir follows symlinks by default,
|
||||
// but collectCPConfigFiles must skip them so a symlink inside a template dir
|
||||
// pointing outside (e.g. ln -s /etc snapshot) cannot be traversed.
|
||||
// Verifies OFFSEC-010 defense-in-depth fix. (OFFSEC-010)
|
||||
func TestCollectCPConfigFiles_SkipsSymlinks(t *testing.T) {
|
||||
tmpl := t.TempDir()
|
||||
// Write a real file that should be included.
|
||||
if err := os.WriteFile(filepath.Join(tmpl, "config.yaml"), []byte("name: real\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Create a subdir with a file that will be symlinked-outside.
|
||||
sensitiveDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(sensitiveDir, "secret.txt"), []byte("SENSITIVE\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Symlink inside template dir pointing to outside path.
|
||||
symlinkPath := filepath.Join(tmpl, "snapshot")
|
||||
if err := os.Symlink(sensitiveDir, symlinkPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
files, err := collectCPConfigFiles(WorkspaceConfig{TemplatePath: tmpl})
|
||||
if err != nil {
|
||||
t.Fatalf("collectCPConfigFiles: %v", err)
|
||||
}
|
||||
if files == nil {
|
||||
t.Fatal("files should not be nil")
|
||||
}
|
||||
// config.yaml must be present.
|
||||
if _, ok := files["config.yaml"]; !ok {
|
||||
t.Errorf("config.yaml missing from files")
|
||||
}
|
||||
// The symlinked path must NOT be included (even though WalkDir would
|
||||
// traverse it, the d.Type()&os.ModeSymlink guard skips the entry).
|
||||
for k := range files {
|
||||
if strings.Contains(k, "snapshot") || strings.Contains(k, "secret") {
|
||||
t.Errorf("symlink path %q should not be in files — OFFSEC-010 regression", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectCPConfigFiles_RejectsRootSymlink — if cfg.TemplatePath itself is
|
||||
// a symlink, WalkDir would follow it to an arbitrary directory, bypassing the
|
||||
// cfg.TemplatePath boundary. The function must reject this case explicitly.
|
||||
// (OFFSEC-010)
|
||||
func TestCollectCPConfigFiles_RejectsRootSymlink(t *testing.T) {
|
||||
real := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(real, "config.yaml"), []byte("name: real\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(t.TempDir(), "template-link")
|
||||
if err := os.Symlink(real, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := collectCPConfigFiles(WorkspaceConfig{TemplatePath: link})
|
||||
if err == nil {
|
||||
t.Error("collectCPConfigFiles with symlink TemplatePath should return error")
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "symlink") {
|
||||
t.Errorf("expected symlink-related error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Handler exposes HTTP endpoints for push-token management.
|
||||
type Handler struct {
|
||||
repo *Repo
|
||||
}
|
||||
|
||||
// NewHandler creates a push-token HTTP handler.
|
||||
func NewHandler(repo *Repo) *Handler {
|
||||
return &Handler{repo: repo}
|
||||
}
|
||||
|
||||
// RegisterRoutes mounts push-token routes on the given router group.
|
||||
func (h *Handler) RegisterRoutes(rg *gin.RouterGroup) {
|
||||
rg.POST("/push-tokens", h.Create)
|
||||
rg.DELETE("/push-tokens", h.Delete)
|
||||
}
|
||||
|
||||
// Create handles POST /push-tokens.
|
||||
// Body: { "token": "ExponentPushToken[xxx]", "platform": "ios" | "android" }
|
||||
func (h *Handler) Create(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
if _, err := uuid.Parse(workspaceID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace id"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
Platform string `json:"platform" binding:"required,oneof=ios android"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.SaveToken(c.Request.Context(), workspaceID, body.Token, body.Platform); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Delete handles DELETE /push-tokens.
|
||||
// Body: { "token": "ExponentPushToken[xxx]" }
|
||||
func (h *Handler) Delete(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
if _, err := uuid.Parse(workspaceID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace id"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.DeleteToken(c.Request.Context(), workspaceID, body.Token); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Notifier sends push notifications for agent messages.
|
||||
type Notifier struct {
|
||||
repo *Repo
|
||||
sender *Sender
|
||||
}
|
||||
|
||||
// NewNotifier creates a Notifier.
|
||||
func NewNotifier(db *sql.DB, sender *Sender) *Notifier {
|
||||
return &Notifier{
|
||||
repo: NewRepo(db),
|
||||
sender: sender,
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyAgentMessage sends a push notification to all registered devices for a
|
||||
// workspace when an agent sends a message. It runs asynchronously (fire-and-
|
||||
// forget) so the caller's WebSocket broadcast is never blocked.
|
||||
func (n *Notifier) NotifyAgentMessage(ctx context.Context, workspaceID, workspaceName, message string) {
|
||||
if n == nil || n.sender == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Capture values for the goroutine.
|
||||
wsID := workspaceID
|
||||
wsName := workspaceName
|
||||
msg := message
|
||||
|
||||
go func() {
|
||||
// Use a fresh context with timeout so a slow Expo API doesn't
|
||||
// leak the caller's context deadline.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tokens, err := n.repo.GetTokens(ctx, wsID)
|
||||
if err != nil {
|
||||
log.Printf("push: failed to get tokens for workspace %s: %v", wsID, err)
|
||||
return
|
||||
}
|
||||
if len(tokens) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Expo accepts batches of up to ~100 messages; we cap lower to stay
|
||||
// well under the limit.
|
||||
const batchSize = 50
|
||||
for i := 0; i < len(tokens); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(tokens) {
|
||||
end = len(tokens)
|
||||
}
|
||||
|
||||
batch := tokens[i:end]
|
||||
messages := make([]Message, 0, len(batch))
|
||||
for _, t := range batch {
|
||||
messages = append(messages, Message{
|
||||
To: t.Token,
|
||||
Title: wsName,
|
||||
Body: truncate(msg, 100),
|
||||
Data: map[string]string{
|
||||
"type": "agent_message",
|
||||
"workspaceId": wsID,
|
||||
"workspaceSlug": "", // populated by caller if available
|
||||
},
|
||||
Sound: "default",
|
||||
Priority: "high",
|
||||
})
|
||||
}
|
||||
|
||||
results, err := n.sender.Send(ctx, messages)
|
||||
if err != nil {
|
||||
log.Printf("push: send failed for workspace %s: %v", wsID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Remove invalid tokens.
|
||||
for j, r := range results {
|
||||
if ShouldRemoveToken(r) {
|
||||
if delErr := n.repo.DeleteToken(ctx, wsID, batch[j].Token); delErr != nil {
|
||||
log.Printf("push: failed to delete invalid token for workspace %s: %v", wsID, delErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "…"
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSenderSend(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
expoResponse := map[string]interface{}{
|
||||
"data": []map[string]interface{}{
|
||||
{"status": "ok", "id": "abc123"},
|
||||
{"status": "error", "message": "Invalid token", "details": map[string]string{"error": "DeviceNotRegistered"}},
|
||||
},
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
|
||||
var msgs []Message
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&msgs))
|
||||
assert.Len(t, msgs, 2)
|
||||
assert.Equal(t, "ExponentPushToken[test1]", msgs[0].To)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(expoResponse)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
sender := NewSender("")
|
||||
sender.apiURL = server.URL
|
||||
|
||||
results, err := sender.Send(context.Background(), []Message{
|
||||
{To: "ExponentPushToken[test1]", Title: "Test", Body: "Hello"},
|
||||
{To: "ExponentPushToken[test2]", Title: "Test", Body: "World"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, 2)
|
||||
assert.Equal(t, "ok", results[0].Status)
|
||||
assert.Equal(t, "error", results[1].Status)
|
||||
assert.True(t, ShouldRemoveToken(results[1]))
|
||||
}
|
||||
|
||||
func TestSenderSendEmpty(t *testing.T) {
|
||||
sender := NewSender("")
|
||||
results, err := sender.Send(context.Background(), nil)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, results)
|
||||
}
|
||||
|
||||
func TestHandlerCreate_InvalidWorkspaceID(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := NewHandler(NewRepo(nil))
|
||||
|
||||
router := gin.New()
|
||||
group := router.Group("/workspaces/:id")
|
||||
handler.RegisterRoutes(group)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
body := `{"token":"ExponentPushToken[abc]","platform":"ios"}`
|
||||
req, _ := http.NewRequest("POST", "/workspaces/not-a-uuid/push-tokens", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestHandlerCreate(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
mock.ExpectExec("INSERT INTO push_tokens").
|
||||
WithArgs("11111111-1111-1111-1111-111111111111", "ExponentPushToken[abc]", "ios").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
repo := NewRepo(db)
|
||||
handler := NewHandler(repo)
|
||||
|
||||
router := gin.New()
|
||||
group := router.Group("/workspaces/:id")
|
||||
handler.RegisterRoutes(group)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
body := `{"token":"ExponentPushToken[abc]","platform":"ios"}`
|
||||
req, _ := http.NewRequest("POST", "/workspaces/11111111-1111-1111-1111-111111111111/push-tokens", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNoContent, w.Code)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestHandlerCreateInvalidPlatform(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, _, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
handler := NewHandler(NewRepo(db))
|
||||
|
||||
router := gin.New()
|
||||
group := router.Group("/workspaces/:id")
|
||||
handler.RegisterRoutes(group)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
body := `{"token":"ExponentPushToken[abc]","platform":"windows"}`
|
||||
req, _ := http.NewRequest("POST", "/workspaces/11111111-1111-1111-1111-111111111111/push-tokens", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestHandlerDelete_BindingError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := NewHandler(NewRepo(nil))
|
||||
|
||||
router := gin.New()
|
||||
group := router.Group("/workspaces/:id")
|
||||
handler.RegisterRoutes(group)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
body := `{}` // missing required "token" field
|
||||
req, _ := http.NewRequest("DELETE", "/workspaces/11111111-1111-1111-1111-111111111111/push-tokens", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestHandlerDelete_InvalidWorkspaceID(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := NewHandler(NewRepo(nil))
|
||||
|
||||
router := gin.New()
|
||||
group := router.Group("/workspaces/:id")
|
||||
handler.RegisterRoutes(group)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
body := `{"token":"ExponentPushToken[del]"}`
|
||||
req, _ := http.NewRequest("DELETE", "/workspaces/not-a-uuid/push-tokens", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestHandlerDelete(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
mock.ExpectExec("DELETE FROM push_tokens").
|
||||
WithArgs("22222222-2222-2222-2222-222222222222", "ExponentPushToken[del]").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
repo := NewRepo(db)
|
||||
handler := NewHandler(repo)
|
||||
|
||||
router := gin.New()
|
||||
group := router.Group("/workspaces/:id")
|
||||
handler.RegisterRoutes(group)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
body := `{"token":"ExponentPushToken[del]"}`
|
||||
req, _ := http.NewRequest("DELETE", "/workspaces/22222222-2222-2222-2222-222222222222/push-tokens", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNoContent, w.Code)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestHandlerCreate_DBSaveError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
mock.ExpectExec("INSERT INTO push_tokens").
|
||||
WithArgs("11111111-1111-1111-1111-111111111111", "ExponentPushToken[abc]", "ios").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
handler := NewHandler(NewRepo(db))
|
||||
|
||||
router := gin.New()
|
||||
group := router.Group("/workspaces/:id")
|
||||
handler.RegisterRoutes(group)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
body := `{"token":"ExponentPushToken[abc]","platform":"ios"}`
|
||||
req, _ := http.NewRequest("POST", "/workspaces/11111111-1111-1111-1111-111111111111/push-tokens", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestHandlerDelete_DBError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
mock.ExpectExec("DELETE FROM push_tokens").
|
||||
WithArgs("22222222-2222-2222-2222-222222222222", "ExponentPushToken[del]").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
handler := NewHandler(NewRepo(db))
|
||||
|
||||
router := gin.New()
|
||||
group := router.Group("/workspaces/:id")
|
||||
handler.RegisterRoutes(group)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
body := `{"token":"ExponentPushToken[del]"}`
|
||||
req, _ := http.NewRequest("DELETE", "/workspaces/22222222-2222-2222-2222-222222222222/push-tokens", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestSenderSend_HTTPError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// Server that hijacks the connection and closes it before sending a response,
|
||||
// causing the HTTP client to receive a connection-closed error.
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Drain request body so client send completes.
|
||||
io.Copy(io.Discard, r.Body)
|
||||
// Hijack and immediately close — no response written.
|
||||
conn, _, _ := w.(http.Hijacker).Hijack()
|
||||
conn.Close()
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
sender := NewSender("")
|
||||
sender.apiURL = server.URL
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, err := sender.Send(ctx, []Message{
|
||||
{To: "ExponentPushToken[test]", Title: "T", Body: "H"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, strings.Contains(err.Error(), "post:") || strings.Contains(err.Error(), "context"))
|
||||
}
|
||||
|
||||
func TestSenderSend_Non200Response(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
w.Write([]byte(`{"error":"rate limited"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
sender := NewSender("")
|
||||
sender.apiURL = server.URL
|
||||
|
||||
_, err := sender.Send(context.Background(), []Message{
|
||||
{To: "ExponentPushToken[test]", Title: "T", Body: "H"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "expo returned 503")
|
||||
}
|
||||
|
||||
func TestNotifierNotifyAgentMessage_NilGuard(t *testing.T) {
|
||||
// Must not panic when sender is nil.
|
||||
n := NewNotifier(nil, nil)
|
||||
// Should return immediately (nil check passes without panic).
|
||||
n.NotifyAgentMessage(context.Background(), "ws-1", "Test", "Hello world")
|
||||
}
|
||||
|
||||
func TestNotifierNotifyAgentMessage_ZeroTokens(t *testing.T) {
|
||||
// Verify that NotifyAgentMessage does NOT panic when there are zero registered
|
||||
// tokens — it should return early without calling sender.Send().
|
||||
// Note: the fire-and-forget goroutine inside NotifyAgentMessage is not
|
||||
// directly verifiable here without modifying production code; the key assertion
|
||||
// is that no panic occurs and the method returns cleanly.
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
mock.ExpectQuery("SELECT id, workspace_id, token, platform, created_at FROM push_tokens").
|
||||
WithArgs("ws-1").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id", "token", "platform", "created_at"}))
|
||||
|
||||
sender := NewSender("")
|
||||
sender.apiURL = "http://127.0.0.1:1" // unreachable — would error if Send is called
|
||||
|
||||
n := NewNotifier(db, sender)
|
||||
n.NotifyAgentMessage(context.Background(), "ws-1", "Test", "Hello")
|
||||
|
||||
// Give goroutine time to run GetTokens and exit early before closing DB.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
db.Close()
|
||||
}
|
||||
|
||||
func TestRepoGetTokens_DBError(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
mock.ExpectQuery("SELECT id, workspace_id, token, platform, created_at FROM push_tokens").
|
||||
WithArgs("ws-1").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
repo := NewRepo(db)
|
||||
_, err = repo.GetTokens(context.Background(), "ws-1")
|
||||
require.Error(t, err)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestRepoGetTokens_ScanError(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
// Return fewer columns than struct has — causes scan error.
|
||||
mock.ExpectQuery("SELECT id, workspace_id, token, platform, created_at FROM push_tokens").
|
||||
WithArgs("ws-1").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id", "token"}). // missing platform, created_at
|
||||
AddRow("1", "ws-1", "ExponentPushToken[a]"))
|
||||
|
||||
repo := NewRepo(db)
|
||||
_, err = repo.GetTokens(context.Background(), "ws-1")
|
||||
require.Error(t, err) // scan error
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestRepoSaveToken_Error(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
mock.ExpectExec("INSERT INTO push_tokens").
|
||||
WithArgs("ws-1", "ExponentPushToken[xyz]", "android").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
repo := NewRepo(db)
|
||||
err = repo.SaveToken(context.Background(), "ws-1", "ExponentPushToken[xyz]", "android")
|
||||
require.Error(t, err)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestRepoDeleteToken_Error(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
mock.ExpectExec("DELETE FROM push_tokens").
|
||||
WithArgs("ws-1", "ExponentPushToken[xyz]").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
repo := NewRepo(db)
|
||||
err = repo.DeleteToken(context.Background(), "ws-1", "ExponentPushToken[xyz]")
|
||||
require.Error(t, err)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
s string
|
||||
max int
|
||||
want string
|
||||
}{
|
||||
{"short string unchanged", "hello", 10, "hello"},
|
||||
{"exact length unchanged", "hello", 5, "hello"},
|
||||
{"long string truncated", "hello world", 5, "hello…"},
|
||||
{"empty string", "", 5, ""},
|
||||
{"single char at max", "a", 1, "a"},
|
||||
{"multi-byte truncation adds ellipsis", "こんにちは世界", 5, ""},
|
||||
{"truncate with ellipsis ends with ellipsis", "hello world", 5, "hello…"},
|
||||
{"truncate at 1 char", "hello", 1, "h…"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := truncate(tc.s, tc.max)
|
||||
if tc.want == "" {
|
||||
// Multi-byte / edge cases: verify no expansion beyond max+3.
|
||||
assert.True(t, len(got) <= tc.max+3)
|
||||
} else {
|
||||
assert.Equal(t, tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoGetTokens(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
mock.ExpectQuery("SELECT id, workspace_id, token, platform, created_at FROM push_tokens").
|
||||
WithArgs("ws-1").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id", "token", "platform", "created_at"}).
|
||||
AddRow("1", "ws-1", "ExponentPushToken[a]", "ios", "2026-01-01T00:00:00Z").
|
||||
AddRow("2", "ws-1", "ExponentPushToken[b]", "android", "2026-01-01T00:00:00Z"))
|
||||
|
||||
repo := NewRepo(db)
|
||||
tokens, err := repo.GetTokens(context.Background(), "ws-1")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, tokens, 2)
|
||||
assert.Equal(t, "ExponentPushToken[a]", tokens[0].Token)
|
||||
assert.Equal(t, "ios", tokens[0].Platform)
|
||||
assert.Equal(t, "ExponentPushToken[b]", tokens[1].Token)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Token is one registered push token for a workspace.
|
||||
type Token struct {
|
||||
ID string
|
||||
WorkspaceID string
|
||||
Token string
|
||||
Platform string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// Repo reads and writes push tokens in Postgres.
|
||||
type Repo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewRepo creates a token repository backed by db.
|
||||
func NewRepo(db *sql.DB) *Repo {
|
||||
return &Repo{db: db}
|
||||
}
|
||||
|
||||
// SaveToken registers a push token for a workspace. If the same token already
|
||||
// exists for the workspace, it updates the timestamp.
|
||||
func (r *Repo) SaveToken(ctx context.Context, workspaceID, token, platform string) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO push_tokens (workspace_id, token, platform)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (workspace_id, token) DO UPDATE
|
||||
SET updated_at = now()
|
||||
`, workspaceID, token, platform)
|
||||
if err != nil {
|
||||
return fmt.Errorf("push_tokens: save: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteToken removes a push token. Returns nil even if the token did not exist.
|
||||
func (r *Repo) DeleteToken(ctx context.Context, workspaceID, token string) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
DELETE FROM push_tokens
|
||||
WHERE workspace_id = $1 AND token = $2
|
||||
`, workspaceID, token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("push_tokens: delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTokens returns all active push tokens for a workspace.
|
||||
func (r *Repo) GetTokens(ctx context.Context, workspaceID string) ([]Token, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, workspace_id, token, platform, created_at
|
||||
FROM push_tokens
|
||||
WHERE workspace_id = $1
|
||||
`, workspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("push_tokens: list: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tokens []Token
|
||||
for rows.Next() {
|
||||
var t Token
|
||||
if err := rows.Scan(&t.ID, &t.WorkspaceID, &t.Token, &t.Platform, &t.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("push_tokens: scan: %w", err)
|
||||
}
|
||||
tokens = append(tokens, t)
|
||||
}
|
||||
return tokens, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const expoPushAPI = "https://exp.host/--/api/v2/push/send"
|
||||
|
||||
// Message is one Expo push notification.
|
||||
type Message struct {
|
||||
To string `json:"to"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
Data map[string]string `json:"data,omitempty"`
|
||||
Sound string `json:"sound,omitempty"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
}
|
||||
|
||||
// Sender delivers push notifications via the Expo Push Service.
|
||||
type Sender struct {
|
||||
apiURL string
|
||||
httpClient *http.Client
|
||||
expoToken string // optional Expo access token for authenticated requests
|
||||
}
|
||||
|
||||
// NewSender creates a Sender. expoToken may be empty for unauthenticated
|
||||
// requests (sufficient for most use cases).
|
||||
func NewSender(expoToken string) *Sender {
|
||||
return &Sender{
|
||||
apiURL: expoPushAPI,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
expoToken: expoToken,
|
||||
}
|
||||
}
|
||||
|
||||
// SendResult is the per-recipient status from Expo.
|
||||
type SendResult struct {
|
||||
Status string `json:"status"`
|
||||
ID string `json:"id"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Details struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
} `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// expoResponse is the wrapper shape returned by the Expo API.
|
||||
type expoResponse struct {
|
||||
Data []SendResult `json:"data"`
|
||||
}
|
||||
|
||||
// Send fires a batch of push messages. It returns a slice of results in the
|
||||
// same order as the input, plus an error only when the HTTP call itself fails.
|
||||
// Callers should inspect each result's Status field for per-message errors
|
||||
// (e.g. "DeviceNotRegistered" → token should be deleted).
|
||||
func (s *Sender) Send(ctx context.Context, messages []Message) ([]SendResult, error) {
|
||||
if len(messages) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
body, err := json.Marshal(messages)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("push: marshal: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.apiURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("push: new request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Accept-Encoding", "gzip, deflate")
|
||||
if s.expoToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+s.expoToken)
|
||||
}
|
||||
|
||||
res, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("push: post: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("push: expo returned %d", res.StatusCode)
|
||||
}
|
||||
|
||||
var resp expoResponse
|
||||
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("push: decode: %w", err)
|
||||
}
|
||||
return resp.Data, nil
|
||||
}
|
||||
|
||||
// ShouldRemoveToken reports whether a SendResult indicates the token is no
|
||||
// longer valid and should be deleted from the database.
|
||||
func ShouldRemoveToken(r SendResult) bool {
|
||||
return r.Status == "error" && r.Details.Error == "DeviceNotRegistered"
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/pendinguploads"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/plugins"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/push"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/supervised"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/ws"
|
||||
"github.com/docker/docker/client"
|
||||
@@ -146,6 +147,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 +205,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.
|
||||
@@ -318,13 +328,25 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
|
||||
|
||||
// Remaining auth-gated workspace sub-routes — appended to wsAuth group declared above.
|
||||
{
|
||||
// Push notifications (mobile)
|
||||
var pushNotifier *push.Notifier
|
||||
if expoToken := os.Getenv("EXPO_ACCESS_TOKEN"); expoToken != "" {
|
||||
pushNotifier = push.NewNotifier(db.DB, push.NewSender(expoToken))
|
||||
}
|
||||
|
||||
// Activity Logs
|
||||
acth := handlers.NewActivityHandler(broadcaster)
|
||||
acth := handlers.NewActivityHandler(broadcaster, pushNotifier)
|
||||
wsAuth.GET("/activity", acth.List)
|
||||
wsAuth.GET("/session-search", acth.SessionSearch)
|
||||
wsAuth.POST("/activity", acth.Report)
|
||||
wsAuth.POST("/notify", acth.Notify)
|
||||
|
||||
// Push token registration (mobile)
|
||||
if pushNotifier != nil {
|
||||
pushH := push.NewHandler(push.NewRepo(db.DB))
|
||||
pushH.RegisterRoutes(wsAuth)
|
||||
}
|
||||
|
||||
// Chat history — RFC #2945 PR-C (issue #3017) + PR-D (issue
|
||||
// #3026). Server-side rendering of activity_logs rows into
|
||||
// the canonical ChatMessage shape; storage is plugin-shaped
|
||||
@@ -428,7 +450,7 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
|
||||
// opencode session cannot saturate the platform.
|
||||
// C3: commit_memory/recall_memory with scope=GLOBAL → permission error;
|
||||
// send_message_to_user excluded unless MOLECULE_MCP_ALLOW_SEND_MESSAGE=true.
|
||||
mcpH := handlers.NewMCPHandler(db.DB, broadcaster)
|
||||
mcpH := handlers.NewMCPHandler(db.DB, broadcaster, pushNotifier)
|
||||
if memBundle != nil {
|
||||
mcpH.WithMemoryV2(memBundle.Plugin, memBundle.Resolver)
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS push_tokens;
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE push_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL,
|
||||
platform TEXT NOT NULL CHECK (platform IN ('ios', 'android')),
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(workspace_id, token)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_push_tokens_workspace ON push_tokens(workspace_id);
|
||||
@@ -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}"
|
||||
|
||||
@@ -3,9 +3,57 @@
|
||||
import logging
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider routing — type alias + resolver used by individual adapters.
|
||||
# Each adapter defines its own ProviderRegistry with the providers it accepts.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Maps prefix → (ordered_auth_env_vars, default_base_url).
|
||||
ProviderRegistry = dict[str, tuple[tuple[str, ...], str]]
|
||||
|
||||
|
||||
def resolve_provider_routing(
|
||||
model_str: str,
|
||||
env: Mapping[str, str],
|
||||
*,
|
||||
registry: ProviderRegistry,
|
||||
runtime_config: dict[str, Any] | None = None,
|
||||
) -> tuple[str, str, str]:
|
||||
"""Resolve a ``provider:model`` string to ``(api_key, base_url, bare_model_id)``.
|
||||
|
||||
URL precedence (highest to lowest):
|
||||
1. ``<PREFIX>_BASE_URL`` env var
|
||||
2. ``runtime_config["provider_url"]``
|
||||
3. registry default for the prefix
|
||||
|
||||
Unknown prefixes fall back to OPENAI_API_KEY + api.openai.com.
|
||||
Raises RuntimeError when no API key env var is set for the prefix.
|
||||
"""
|
||||
if ":" in model_str:
|
||||
prefix, model_id = model_str.split(":", 1)
|
||||
else:
|
||||
prefix, model_id = "openai", model_str
|
||||
|
||||
env_vars, default_url = registry.get(
|
||||
prefix, (("OPENAI_API_KEY",), "https://api.openai.com/v1")
|
||||
)
|
||||
api_key = next((env[v] for v in env_vars if env.get(v)), "")
|
||||
if not api_key:
|
||||
raise RuntimeError(
|
||||
f"No API key found for provider {prefix!r} "
|
||||
f"(checked: {', '.join(env_vars)}). Set one in workspace secrets."
|
||||
)
|
||||
|
||||
env_url = env.get(f"{prefix.upper()}_BASE_URL", "")
|
||||
config_url = (runtime_config or {}).get("provider_url", "")
|
||||
base_url = env_url or config_url or default_url
|
||||
|
||||
return api_key, base_url, model_id
|
||||
|
||||
from a2a.server.agent_execution import AgentExecutor
|
||||
|
||||
from event_log import DisabledEventLog, EventLogBackend
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -1,153 +1,141 @@
|
||||
"""Unit tests for OpenClaw adapter env-var key selection and provider URL routing.
|
||||
"""Unit tests for resolve_provider_routing in adapter_base.
|
||||
|
||||
The key-selection and URL-routing logic lives inline in OpenClawAdapter.setup()
|
||||
(adapter.py lines 84-92). Since setup() carries heavy subprocess dependencies,
|
||||
these tests isolate the selection logic by reproducing the exact Python expressions
|
||||
from the adapter source — if the adapter's logic changes, these tests must be kept
|
||||
in sync.
|
||||
|
||||
Organisation:
|
||||
TestEnvKeyChain — priority order of the 3 currently supported keys
|
||||
TestProviderUrlMapping — model-prefix → provider URL dict correctness
|
||||
TestNegativeAndFallback — no keys set / unsupported keys
|
||||
xfail stubs — AISTUDIO + QIANFAN documented as not-yet-implemented
|
||||
Covers provider routing, URL-override precedence, and the missing-key error path.
|
||||
Each adapter defines its own registry; this test file defines one inline that
|
||||
mirrors what the openclaw adapter uses.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from adapter_base import ProviderRegistry, resolve_provider_routing
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers — mirror the exact expressions from adapter.py lines 84-92.
|
||||
# Must be kept in sync with the adapter source.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _select_key(env: dict) -> str:
|
||||
"""Mirror line 84: nested os.environ.get priority chain."""
|
||||
return env.get("OPENAI_API_KEY",
|
||||
env.get("GROQ_API_KEY",
|
||||
env.get("OPENROUTER_API_KEY", "")))
|
||||
|
||||
|
||||
_PROVIDER_URLS: dict[str, str] = {
|
||||
"openai": "https://api.openai.com/v1",
|
||||
"groq": "https://api.groq.com/openai/v1",
|
||||
"openrouter": "https://openrouter.ai/api/v1",
|
||||
# Mirror of the registry in openclaw's adapter.py — kept in sync manually.
|
||||
PROVIDER_REGISTRY: ProviderRegistry = {
|
||||
"openai": (("OPENAI_API_KEY",), "https://api.openai.com/v1"),
|
||||
"groq": (("GROQ_API_KEY",), "https://api.groq.com/openai/v1"),
|
||||
"openrouter": (("OPENROUTER_API_KEY",), "https://openrouter.ai/api/v1"),
|
||||
"qianfan": (("QIANFAN_API_KEY", "AISTUDIO_API_KEY"), "https://qianfan.baidubce.com/v2"),
|
||||
"minimax": (("MINIMAX_API_KEY",), "https://api.minimaxi.com/v1"),
|
||||
"moonshot": (("KIMI_API_KEY",), "https://api.moonshot.ai/v1"),
|
||||
}
|
||||
|
||||
|
||||
def _select_url(model: str, runtime_config: dict | None = None) -> str:
|
||||
"""Mirror lines 86-92: model-prefix → provider URL with optional override."""
|
||||
prefix = model.split(":")[0] if ":" in model else "openai"
|
||||
return (runtime_config or {}).get(
|
||||
"provider_url",
|
||||
_PROVIDER_URLS.get(prefix, "https://api.openai.com/v1"),
|
||||
)
|
||||
class TestProviderRouting:
|
||||
|
||||
def test_openai_key_and_url(self):
|
||||
api_key, base_url, model_id = resolve_provider_routing(
|
||||
"openai:gpt-4o", {"OPENAI_API_KEY": "sk-openai"}, registry=PROVIDER_REGISTRY
|
||||
)
|
||||
assert api_key == "sk-openai"
|
||||
assert base_url == "https://api.openai.com/v1"
|
||||
assert model_id == "gpt-4o"
|
||||
|
||||
def test_groq_key_and_url(self):
|
||||
api_key, base_url, model_id = resolve_provider_routing(
|
||||
"groq:llama-3.3-70b", {"GROQ_API_KEY": "sk-groq"}, registry=PROVIDER_REGISTRY
|
||||
)
|
||||
assert api_key == "sk-groq"
|
||||
assert base_url == "https://api.groq.com/openai/v1"
|
||||
assert model_id == "llama-3.3-70b"
|
||||
|
||||
def test_openrouter_key_and_url(self):
|
||||
api_key, base_url, model_id = resolve_provider_routing(
|
||||
"openrouter:anthropic/claude-sonnet-4-5", {"OPENROUTER_API_KEY": "sk-or"}, registry=PROVIDER_REGISTRY
|
||||
)
|
||||
assert api_key == "sk-or"
|
||||
assert base_url == "https://openrouter.ai/api/v1"
|
||||
assert model_id == "anthropic/claude-sonnet-4-5"
|
||||
|
||||
def test_qianfan_primary_key(self):
|
||||
api_key, _, _ = resolve_provider_routing(
|
||||
"qianfan:ernie-4.5", {"QIANFAN_API_KEY": "sk-qf", "AISTUDIO_API_KEY": "sk-ai"}, registry=PROVIDER_REGISTRY
|
||||
)
|
||||
assert api_key == "sk-qf"
|
||||
|
||||
def test_qianfan_fallback_to_aistudio(self):
|
||||
api_key, base_url, _ = resolve_provider_routing(
|
||||
"qianfan:ernie-4.5", {"AISTUDIO_API_KEY": "sk-ai"}, registry=PROVIDER_REGISTRY
|
||||
)
|
||||
assert api_key == "sk-ai"
|
||||
assert base_url == "https://qianfan.baidubce.com/v2"
|
||||
|
||||
def test_minimax_key_and_url(self):
|
||||
api_key, base_url, model_id = resolve_provider_routing(
|
||||
"minimax:MiniMax-M2.7", {"MINIMAX_API_KEY": "sk-mm"}, registry=PROVIDER_REGISTRY
|
||||
)
|
||||
assert api_key == "sk-mm"
|
||||
assert base_url == "https://api.minimaxi.com/v1"
|
||||
assert model_id == "MiniMax-M2.7"
|
||||
|
||||
def test_moonshot_key_and_url(self):
|
||||
api_key, base_url, model_id = resolve_provider_routing(
|
||||
"moonshot:kimi-k2.5", {"KIMI_API_KEY": "sk-kimi"}, registry=PROVIDER_REGISTRY
|
||||
)
|
||||
assert api_key == "sk-kimi"
|
||||
assert base_url == "https://api.moonshot.ai/v1"
|
||||
assert model_id == "kimi-k2.5"
|
||||
|
||||
def test_bare_model_id_defaults_to_openai(self):
|
||||
api_key, base_url, model_id = resolve_provider_routing(
|
||||
"gpt-4o", {"OPENAI_API_KEY": "sk-openai"}, registry=PROVIDER_REGISTRY
|
||||
)
|
||||
assert base_url == "https://api.openai.com/v1"
|
||||
assert model_id == "gpt-4o"
|
||||
|
||||
def test_unknown_prefix_falls_back_to_openai_url(self):
|
||||
api_key, base_url, model_id = resolve_provider_routing(
|
||||
"custom-shim:my-model", {"OPENAI_API_KEY": "sk-openai"}, registry=PROVIDER_REGISTRY
|
||||
)
|
||||
assert base_url == "https://api.openai.com/v1"
|
||||
assert model_id == "my-model"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Env-var key priority chain (3 keys currently in adapter.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestUrlOverridePrecedence:
|
||||
|
||||
class TestEnvKeyChain:
|
||||
def test_env_base_url_beats_registry_default(self):
|
||||
_, base_url, _ = resolve_provider_routing(
|
||||
"minimax:MiniMax-M2.7",
|
||||
{"MINIMAX_API_KEY": "sk-mm", "MINIMAX_BASE_URL": "https://api.minimax.chat/v1"},
|
||||
registry=PROVIDER_REGISTRY,
|
||||
)
|
||||
assert base_url == "https://api.minimax.chat/v1"
|
||||
|
||||
def test_openai_key_selected(self):
|
||||
with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-openai-test"}, clear=True):
|
||||
assert _select_key(os.environ) == "sk-openai-test"
|
||||
def test_runtime_config_provider_url_beats_registry_default(self):
|
||||
_, base_url, _ = resolve_provider_routing(
|
||||
"openai:gpt-4o",
|
||||
{"OPENAI_API_KEY": "sk-openai"},
|
||||
registry=PROVIDER_REGISTRY,
|
||||
runtime_config={"provider_url": "https://proxy.example.com/v1"},
|
||||
)
|
||||
assert base_url == "https://proxy.example.com/v1"
|
||||
|
||||
def test_groq_key_selected_when_openai_absent(self):
|
||||
with patch.dict(os.environ, {"GROQ_API_KEY": "sk-groq-test"}, clear=True):
|
||||
assert _select_key(os.environ) == "sk-groq-test"
|
||||
|
||||
def test_openrouter_key_selected_when_openai_and_groq_absent(self):
|
||||
with patch.dict(os.environ, {"OPENROUTER_API_KEY": "sk-or-test"}, clear=True):
|
||||
assert _select_key(os.environ) == "sk-or-test"
|
||||
|
||||
def test_openai_beats_groq_when_both_set(self):
|
||||
with patch.dict(os.environ, {"OPENAI_API_KEY": "openai", "GROQ_API_KEY": "groq"}, clear=True):
|
||||
assert _select_key(os.environ) == "openai"
|
||||
|
||||
def test_groq_beats_openrouter_when_openai_absent(self):
|
||||
with patch.dict(os.environ, {"GROQ_API_KEY": "groq", "OPENROUTER_API_KEY": "or"}, clear=True):
|
||||
assert _select_key(os.environ) == "groq"
|
||||
def test_env_base_url_beats_runtime_config(self):
|
||||
_, base_url, _ = resolve_provider_routing(
|
||||
"openai:gpt-4o",
|
||||
{"OPENAI_API_KEY": "sk-openai", "OPENAI_BASE_URL": "https://env-wins.com/v1"},
|
||||
registry=PROVIDER_REGISTRY,
|
||||
runtime_config={"provider_url": "https://config-loses.com/v1"},
|
||||
)
|
||||
assert base_url == "https://env-wins.com/v1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Model-prefix → provider URL routing
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestMissingKey:
|
||||
|
||||
class TestProviderUrlMapping:
|
||||
def test_raises_when_no_key_set(self):
|
||||
with pytest.raises(RuntimeError, match="No API key found for provider 'minimax'"):
|
||||
resolve_provider_routing("minimax:MiniMax-M2.7", {}, registry=PROVIDER_REGISTRY)
|
||||
|
||||
def test_openai_prefix_routes_to_openai(self):
|
||||
assert _select_url("openai:gpt-4o") == "https://api.openai.com/v1"
|
||||
|
||||
def test_groq_prefix_routes_to_groq(self):
|
||||
assert _select_url("groq:llama3-70b") == "https://api.groq.com/openai/v1"
|
||||
|
||||
def test_openrouter_prefix_routes_to_openrouter(self):
|
||||
assert _select_url("openrouter:meta-llama/llama-3.3-70b") == "https://openrouter.ai/api/v1"
|
||||
|
||||
def test_runtime_config_override_wins_over_prefix(self):
|
||||
url = _select_url("openai:gpt-4o", {"provider_url": "https://custom.example.com/v1"})
|
||||
assert url == "https://custom.example.com/v1"
|
||||
|
||||
def test_unknown_prefix_falls_back_to_openai(self):
|
||||
assert _select_url("some-unknown-model") == "https://api.openai.com/v1"
|
||||
def test_raises_lists_checked_vars_in_message(self):
|
||||
with pytest.raises(RuntimeError, match="MINIMAX_API_KEY"):
|
||||
resolve_provider_routing("minimax:MiniMax-M2.7", {}, registry=PROVIDER_REGISTRY)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Negative / fallback cases
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestRegistryCompleteness:
|
||||
"""Smoke-check that every provider in the registry has a non-empty entry."""
|
||||
|
||||
class TestNegativeAndFallback:
|
||||
|
||||
def test_no_keys_returns_empty_string(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
assert _select_key(os.environ) == ""
|
||||
|
||||
def test_unsupported_aistudio_key_returns_empty(self):
|
||||
"""Documents that AISTUDIO_API_KEY is NOT yet in the adapter's key chain."""
|
||||
with patch.dict(os.environ, {"AISTUDIO_API_KEY": "sk-ai"}, clear=True):
|
||||
assert _select_key(os.environ) == ""
|
||||
|
||||
def test_unsupported_qianfan_key_returns_empty(self):
|
||||
"""Documents that QIANFAN_API_KEY is NOT yet in the adapter's key chain."""
|
||||
with patch.dict(os.environ, {"QIANFAN_API_KEY": "sk-qf"}, clear=True):
|
||||
assert _select_key(os.environ) == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. AISTUDIO + QIANFAN — xfail stubs (not yet implemented in adapter.py)
|
||||
# These fail now; they should be promoted to passing tests once the adapter
|
||||
# adds AISTUDIO_API_KEY and QIANFAN_API_KEY to its key chain and provider_urls.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason=(
|
||||
"AISTUDIO_API_KEY not yet in openclaw adapter env-var chain — "
|
||||
"add to adapter.py line 84 and provider_urls dict with "
|
||||
"URL https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
),
|
||||
)
|
||||
def test_aistudio_key_routes_to_aistudio_url():
|
||||
with patch.dict(os.environ, {"AISTUDIO_API_KEY": "sk-ai-test"}, clear=True):
|
||||
assert _select_key(os.environ) == "sk-ai-test"
|
||||
assert _select_url("gemini-2.5-flash") == "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason=(
|
||||
"QIANFAN_API_KEY not yet in openclaw adapter env-var chain — "
|
||||
"add to adapter.py line 84 and provider_urls dict with "
|
||||
"URL https://qianfan.baidubce.com/v2"
|
||||
),
|
||||
)
|
||||
def test_qianfan_key_routes_to_qianfan_url():
|
||||
with patch.dict(os.environ, {"QIANFAN_API_KEY": "sk-qf-test"}, clear=True):
|
||||
assert _select_key(os.environ) == "sk-qf-test"
|
||||
assert _select_url("ernie-4.5") == "https://qianfan.baidubce.com/v2"
|
||||
@pytest.mark.parametrize("prefix", PROVIDER_REGISTRY)
|
||||
def test_all_providers_have_key_vars_and_url(self, prefix):
|
||||
env_vars, base_url = PROVIDER_REGISTRY[prefix]
|
||||
assert env_vars, f"{prefix}: env_vars is empty"
|
||||
assert base_url.startswith("https://"), f"{prefix}: base_url looks wrong: {base_url}"
|
||||
|
||||
Reference in New Issue
Block a user