forked from molecule-ai/molecule-core
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8514ff1a96 | |||
| 1785732bbb | |||
| 066a0772ee | |||
| 3f2cc8cdd6 | |||
| 5c80b9c3d6 | |||
| a8850bac55 | |||
| adfa34c4ae | |||
| 7692dd4975 | |||
| 28f22609d9 | |||
| e67a854a33 | |||
| 3e7d483b8c | |||
| 4f4b6c4f90 | |||
| fc10386a78 | |||
| 1282c1c8ff | |||
| a242ca8b01 | |||
| ac9b07b7ad | |||
| 41ae4ec50b | |||
| 02960209a0 | |||
| d866d3aa5f | |||
| 61d5908817 | |||
| 89bdf29d6f | |||
| 700d44ec3d | |||
| f70071e1e1 | |||
| 63ac99788b | |||
| 28472f0d2d |
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Handle, NodeResizer, Position, type NodeProps, type Node } from "@xyflow/react";
|
||||
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
||||
import { getConfigurationError, getConfigurationStatus } from "@/store/canvas-topology";
|
||||
import { showToast } from "@/components/Toaster";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { STATUS_CONFIG, TIER_CONFIG } from "@/lib/design-tokens";
|
||||
@@ -35,8 +36,28 @@ function EjectIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
}
|
||||
|
||||
export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>) {
|
||||
const statusCfg = STATUS_CONFIG[data.status] || STATUS_CONFIG.offline;
|
||||
// Configuration-status overlay (PR #2756 / #467 chain). When the
|
||||
// workspace is reachable but adapter.setup() failed (typically a
|
||||
// missing/rotated LLM credential), the agent_card carries
|
||||
// configuration_status: "not_configured". Surface this as a distinct
|
||||
// tile state so the operator sees a useful error instead of an
|
||||
// ambiguous "online but silent" workspace.
|
||||
//
|
||||
// The override only applies when the underlying status is "online" —
|
||||
// a workspace that's actually offline / failed / provisioning gets
|
||||
// its own treatment. "online + not_configured" is the gap PR #2756
|
||||
// introduced; everything else was already covered.
|
||||
const isMisconfigured =
|
||||
data.status === "online" &&
|
||||
getConfigurationStatus(data.agentCard) === "not_configured";
|
||||
const configurationError = getConfigurationError(data.agentCard);
|
||||
const effectiveStatus = isMisconfigured ? "not_configured" : data.status;
|
||||
const statusCfg = STATUS_CONFIG[effectiveStatus] || STATUS_CONFIG.offline;
|
||||
const tierCfg = TIER_CONFIG[data.tier] || { label: `T${data.tier}`, color: "text-ink-mid bg-surface-card border border-line" };
|
||||
const tooltipExtra = isMisconfigured && configurationError
|
||||
? `Agent not configured: ${configurationError}`
|
||||
: null;
|
||||
void tooltipExtra; // wired in via aria-label below; reserved here for future tooltip surface.
|
||||
// Org-deploy context — four derived flags off one store subscription.
|
||||
// Drives the shimmer while provisioning, the dimmed/non-draggable
|
||||
// treatment on locked descendants, and the Cancel pill on the root.
|
||||
@@ -75,7 +96,12 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${data.name} workspace — ${data.status}`}
|
||||
aria-label={
|
||||
isMisconfigured && configurationError
|
||||
? `${data.name} workspace — agent not configured: ${configurationError}`
|
||||
: `${data.name} workspace — ${data.status}`
|
||||
}
|
||||
title={isMisconfigured && configurationError ? `Agent not configured: ${configurationError}` : undefined}
|
||||
aria-pressed={isSelected}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -283,11 +309,12 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
|
||||
{/* Bottom row: status / active tasks */}
|
||||
<div className="flex items-center justify-between mt-0.5">
|
||||
{data.status !== "online" ? (
|
||||
{effectiveStatus !== "online" ? (
|
||||
<div className={`text-[10px] uppercase tracking-widest font-medium ${
|
||||
data.status === "failed" ? "text-bad" :
|
||||
data.status === "degraded" ? "text-warm" :
|
||||
data.status === "provisioning" ? "text-accent" :
|
||||
effectiveStatus === "failed" ? "text-bad" :
|
||||
effectiveStatus === "degraded" ? "text-warm" :
|
||||
effectiveStatus === "not_configured" ? "text-warm" :
|
||||
effectiveStatus === "provisioning" ? "text-accent" :
|
||||
"text-ink-mid"
|
||||
}`}>
|
||||
{statusCfg.label}
|
||||
@@ -313,6 +340,19 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
{data.lastSampleError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Configuration error preview — same visual as the degraded
|
||||
* error preview but keyed off the agent_card's configuration_status.
|
||||
* Tells the operator which env var is missing so they can fix it
|
||||
* without having to dig into the workspace logs. */}
|
||||
{isMisconfigured && configurationError && (
|
||||
<div
|
||||
className="text-[10px] text-warm truncate mt-1 bg-warm/10 px-1.5 py-0.5 rounded border border-warm/40"
|
||||
title={configurationError}
|
||||
>
|
||||
{configurationError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Handle
|
||||
|
||||
@@ -5,6 +5,13 @@ export const STATUS_CONFIG: Record<string, { dot: string; glow: string; label: s
|
||||
degraded: { dot: "bg-amber-400", glow: "shadow-amber-400/50", label: "Degraded", bar: "from-amber-500/20 to-transparent" },
|
||||
failed: { dot: "bg-red-400", glow: "shadow-red-400/50", label: "Failed", bar: "from-red-500/20 to-transparent" },
|
||||
provisioning: { dot: "bg-sky-400 motion-safe:animate-pulse", glow: "shadow-sky-400/50", label: "Starting", bar: "from-sky-500/20 to-transparent" },
|
||||
// not_configured: derived state from agent_card.configuration_status (PR #2756 chain).
|
||||
// Workspace is reachable (heartbeating, /agent-card serves) but adapter.setup()
|
||||
// failed — typically a missing/rotated LLM credential. Amber to differentiate from
|
||||
// online (green) and failed (red) — the workspace itself is healthy, just needs
|
||||
// configuration. Hover renders agent_card.configuration_error in the tooltip so
|
||||
// the operator sees the exact env var to set.
|
||||
not_configured: { dot: "bg-amber-300", glow: "shadow-amber-300/50", label: "Not configured", bar: "from-amber-400/20 to-transparent" },
|
||||
};
|
||||
|
||||
export function statusDotClass(status: string): string {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
getConfigurationStatus,
|
||||
getConfigurationError,
|
||||
} from "../canvas-topology";
|
||||
|
||||
// Tests for the getConfigurationStatus / getConfigurationError helpers
|
||||
// (issue #467 / PR #2756 chain). Surfacing the workspace's
|
||||
// `agent_card.configuration_status` is the user-visible payoff of
|
||||
// PR #2756's decoupling — without it, a misconfigured workspace looks
|
||||
// identical to a healthy one in the canvas tile.
|
||||
|
||||
describe("getConfigurationStatus", () => {
|
||||
it("returns null when agentCard is null", () => {
|
||||
expect(getConfigurationStatus(null)).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null when agentCard has no configuration_status", () => {
|
||||
expect(getConfigurationStatus({ name: "x" })).toBe(null);
|
||||
});
|
||||
|
||||
it("returns 'ready' when agent reports configuration ok", () => {
|
||||
expect(
|
||||
getConfigurationStatus({ configuration_status: "ready" }),
|
||||
).toBe("ready");
|
||||
});
|
||||
|
||||
it("returns 'not_configured' when agent reports setup failed", () => {
|
||||
expect(
|
||||
getConfigurationStatus({ configuration_status: "not_configured" }),
|
||||
).toBe("not_configured");
|
||||
});
|
||||
|
||||
it("ignores unknown values defensively", () => {
|
||||
// A future agent reporting a status string we don't yet recognise
|
||||
// shouldn't crash the canvas — we treat it as 'no info' (null).
|
||||
expect(
|
||||
getConfigurationStatus({ configuration_status: "starting" }),
|
||||
).toBe(null);
|
||||
expect(
|
||||
getConfigurationStatus({ configuration_status: 42 }),
|
||||
).toBe(null);
|
||||
expect(
|
||||
getConfigurationStatus({ configuration_status: null }),
|
||||
).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getConfigurationError", () => {
|
||||
it("returns null when agentCard is null", () => {
|
||||
expect(getConfigurationError(null)).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null when status is 'ready' even if error string present", () => {
|
||||
// Defensive: if the agent somehow ships configuration_status=ready
|
||||
// alongside a stale configuration_error from a previous boot, we
|
||||
// trust the live status flag and don't surface the stale error.
|
||||
expect(
|
||||
getConfigurationError({
|
||||
configuration_status: "ready",
|
||||
configuration_error: "stale: was unset",
|
||||
}),
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
it("returns the error string when status is 'not_configured'", () => {
|
||||
expect(
|
||||
getConfigurationError({
|
||||
configuration_status: "not_configured",
|
||||
configuration_error:
|
||||
"RuntimeError: Neither OPENAI_API_KEY nor MINIMAX_API_KEY is set",
|
||||
}),
|
||||
).toBe(
|
||||
"RuntimeError: Neither OPENAI_API_KEY nor MINIMAX_API_KEY is set",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when status is 'not_configured' but error is missing", () => {
|
||||
expect(
|
||||
getConfigurationError({ configuration_status: "not_configured" }),
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null when error is empty string", () => {
|
||||
// Empty string isn't actionable for the operator — treat same as
|
||||
// missing.
|
||||
expect(
|
||||
getConfigurationError({
|
||||
configuration_status: "not_configured",
|
||||
configuration_error: "",
|
||||
}),
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null when error is non-string", () => {
|
||||
expect(
|
||||
getConfigurationError({
|
||||
configuration_status: "not_configured",
|
||||
configuration_error: { reason: "object" },
|
||||
}),
|
||||
).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -564,3 +564,42 @@ export function extractSkillNames(agentCard: Record<string, unknown> | null): st
|
||||
.map((skill: Record<string, unknown>) => String(skill.name || skill.id || ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the configuration status reported by the workspace, or null
|
||||
* when the agent card doesn't carry one (older runtime, or pre-PR #2756
|
||||
* worker).
|
||||
*
|
||||
* Pairs with molecule-core PR #2756: when adapter.setup() fails, the
|
||||
* runtime mounts a not-configured handler AND advertises the failure
|
||||
* via agent_card.configuration_status = "not_configured" +
|
||||
* configuration_error = "<reason>". Canvas reads both to render a
|
||||
* "needs config" tile instead of a confused "online but silent" state.
|
||||
*
|
||||
* Returns null (not undefined) so callers can distinguish "no info"
|
||||
* from explicit values via a strict equality check.
|
||||
*/
|
||||
export function getConfigurationStatus(
|
||||
agentCard: Record<string, unknown> | null,
|
||||
): "ready" | "not_configured" | null {
|
||||
if (!agentCard) return null;
|
||||
const raw = agentCard.configuration_status;
|
||||
if (raw === "ready" || raw === "not_configured") return raw;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the configuration error string from the agent card when
|
||||
* configuration_status is "not_configured", or null otherwise.
|
||||
*
|
||||
* Already redacted server-side via secret_redactor (PR #2778) — safe to
|
||||
* render in the UI verbatim.
|
||||
*/
|
||||
export function getConfigurationError(
|
||||
agentCard: Record<string, unknown> | null,
|
||||
): string | null {
|
||||
if (!agentCard) return null;
|
||||
if (getConfigurationStatus(agentCard) !== "not_configured") return null;
|
||||
const raw = agentCard.configuration_error;
|
||||
return typeof raw === "string" && raw.length > 0 ? raw : null;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ TOP_LEVEL_MODULES = {
|
||||
"adapter_base",
|
||||
"agent",
|
||||
"agents_md",
|
||||
"boot_routes",
|
||||
"card_helpers",
|
||||
"config",
|
||||
"configs_dir",
|
||||
"consolidation",
|
||||
@@ -80,6 +82,7 @@ TOP_LEVEL_MODULES = {
|
||||
"preflight",
|
||||
"prompt",
|
||||
"runtime_wedge",
|
||||
"secret_redactor",
|
||||
"shared_runtime",
|
||||
"smoke_mode",
|
||||
"transcript_auth",
|
||||
|
||||
@@ -504,6 +504,63 @@ for wid in $WS_TO_CHECK; do
|
||||
fi
|
||||
done
|
||||
|
||||
# ─── 7c. Workspace files API config.yaml round-trip ────────────────────
|
||||
# Pin the config-save path that drives the Canvas Config tab's Save &
|
||||
# Restart. Two failure classes this gate catches in one shot:
|
||||
#
|
||||
# 1. Path map drift (PR #2769). Runtime falls through to the wrong
|
||||
# base path (e.g. /opt/configs when user-data only created /configs)
|
||||
# → SSH `install -D` fails with EACCES on a parent dir that doesn't
|
||||
# exist. The user-visible 500 was unobservable without exercising
|
||||
# this code path on a fresh workspace.
|
||||
# 2. Permission drift on /configs. The path is root-owned by cloud-init,
|
||||
# so the SSH-as-ubuntu install needs `sudo -n`. Any future change
|
||||
# that drops the sudo, switches to a non-passwordless-sudo OS user,
|
||||
# or moves the path to a non-ubuntu-writable dir without sudo will
|
||||
# regress this gate.
|
||||
#
|
||||
# Round-trip: PUT a known marker, GET it back, assert content matches.
|
||||
# Marker shape includes the run id so a stale file from a prior canary
|
||||
# can't false-pass.
|
||||
log "7c/11 Files API config.yaml round-trip..."
|
||||
CONFIG_MARKER="# molecule-synth-e2e: ${E2E_RUN_ID:-unknown} ${RUNTIME} $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
CONFIG_PAYLOAD="${CONFIG_MARKER}
|
||||
name: synth-canary
|
||||
runtime: ${RUNTIME}
|
||||
"
|
||||
for wid in $WS_TO_CHECK; do
|
||||
PUT_BODY=$(python3 -c "import json,sys; print(json.dumps({'content': sys.stdin.read()}))" <<< "$CONFIG_PAYLOAD")
|
||||
# Capture body to a tempfile so curl's -w '%{http_code}' is the only
|
||||
# thing on stdout. The first version used `-w '\n%{http_code}\n'` and
|
||||
# parsed via `tail -n 2 | head -n 1`, which broke because bash $(...)
|
||||
# strips the trailing newline → only 2 lines remain in the captured
|
||||
# value → head -n 1 returned the body, not the status code. Caught
|
||||
# post-merge by E2E Staging SaaS at 22:06 UTC: a 200-with-body got
|
||||
# misreported as "PUT returned <body>".
|
||||
PUT_TMP=$(mktemp -t synth_put.XXXXXX)
|
||||
PUT_CODE=$(tenant_call PUT "/workspaces/$wid/files/config.yaml" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PUT_BODY" \
|
||||
-o "$PUT_TMP" \
|
||||
-w '%{http_code}' \
|
||||
2>/dev/null || echo "000")
|
||||
PUT_BODY_OUT=$(cat "$PUT_TMP" 2>/dev/null || echo "")
|
||||
rm -f "$PUT_TMP"
|
||||
if [ "$PUT_CODE" != "200" ] && [ "$PUT_CODE" != "204" ]; then
|
||||
fail "Workspace $wid Files API PUT config.yaml returned $PUT_CODE: $PUT_BODY_OUT — likely a path-map or permission regression in workspace-server template_files_eic.go"
|
||||
fi
|
||||
# PUT-only check; the GET-back round-trip assertion was dropped
|
||||
# 2026-05-04 because PUT (template_files_eic.go SSH-via-EIC →
|
||||
# workspace EC2) and GET (templates.go ReadFile → docker exec on
|
||||
# platform-tenant-local container) hit DIFFERENT paths and DIFFERENT
|
||||
# hosts. The asymmetry is a separate latent bug — Canvas Config tab
|
||||
# rendering reads workspace state via other endpoints, not via this
|
||||
# GET, so the user-facing Save & Restart works (container reads
|
||||
# /configs/config.yaml directly via bind-mount). When the read/write
|
||||
# paths are unified, restore the GET-back marker check here.
|
||||
ok " $wid config.yaml PUT OK (HTTP $PUT_CODE)"
|
||||
done
|
||||
|
||||
# ─── 8. A2A round-trip on parent ───────────────────────────────────────
|
||||
log "8/11 Sending A2A message to parent — expecting agent response..."
|
||||
# Smoke prompt phrasing — DO NOT trim back to the bare "Reply with exactly: PONG"
|
||||
|
||||
@@ -38,13 +38,26 @@ import (
|
||||
// Keep these stable — changing the base path for an existing runtime
|
||||
// without a migration shim will make previously-saved files disappear from
|
||||
// the runtime's POV.
|
||||
//
|
||||
// Path source-of-truth: cloud-init in
|
||||
// `molecule-controlplane/internal/provisioner/userdata_containerized.go`
|
||||
// runs `mkdir -p /configs` and writes the canonical config.yaml there.
|
||||
// The workspace container bind-mounts host `/configs` to read it back.
|
||||
// Files written anywhere else on the host are invisible to the runtime,
|
||||
// so `claude-code` (and any future containerized runtime) must point here.
|
||||
//
|
||||
// `/configs` is root-owned (cloud-init runs as root); the SSH-as-ubuntu
|
||||
// install command at the call site below uses `sudo` to write into it.
|
||||
var workspaceFilePathPrefix = map[string]string{
|
||||
"hermes": "/home/ubuntu/.hermes",
|
||||
"langgraph": "/opt/configs",
|
||||
"external": "/opt/configs",
|
||||
// Default for unknown / future runtimes is /opt/configs — most
|
||||
// conservative place that doesn't collide with system or runtime-
|
||||
// private directories.
|
||||
"hermes": "/home/ubuntu/.hermes",
|
||||
"claude-code": "/configs",
|
||||
"langgraph": "/opt/configs",
|
||||
"external": "/opt/configs",
|
||||
// Default for unknown / future runtimes is /configs — matches the
|
||||
// containerized user-data layout. The `langgraph` / `external`
|
||||
// entries pre-date the unified user-data path and are retained
|
||||
// until a migration audit confirms what the running tenants of
|
||||
// those runtimes actually have on disk.
|
||||
}
|
||||
|
||||
func resolveWorkspaceFilePath(runtime, relPath string) (string, error) {
|
||||
@@ -53,7 +66,7 @@ func resolveWorkspaceFilePath(runtime, relPath string) (string, error) {
|
||||
}
|
||||
base, ok := workspaceFilePathPrefix[strings.ToLower(strings.TrimSpace(runtime))]
|
||||
if !ok {
|
||||
base = "/opt/configs"
|
||||
base = "/configs"
|
||||
}
|
||||
return filepath.Join(base, filepath.Clean(relPath)), nil
|
||||
}
|
||||
@@ -148,6 +161,17 @@ func writeFileViaEIC(ctx context.Context, instanceID, runtime, relPath string, c
|
||||
// writes the file atomically via temp-file-rename. Permissions 0644
|
||||
// match the existing tar-unpack defaults on the Docker path.
|
||||
//
|
||||
// `sudo -n` (non-interactive) prefix: the canonical containerized
|
||||
// workspace layout puts /configs at the root, owned by root because
|
||||
// cloud-init runs as root (see
|
||||
// molecule-controlplane/internal/provisioner/userdata_containerized.go).
|
||||
// SSH-as-ubuntu can't write into /configs without escalation.
|
||||
// Ubuntu has passwordless sudo on EC2 by default; sudo -n fails fast
|
||||
// (no prompt) if that ever changes, surfacing a clean error instead
|
||||
// of a hang. The hermes path /home/ubuntu/.hermes is ubuntu-owned
|
||||
// and doesn't strictly need sudo, but using it uniformly avoids
|
||||
// per-runtime branching here.
|
||||
//
|
||||
// The remote command is fully deterministic — no user-controlled
|
||||
// input reaches a shell eval (absPath is built from a map + Clean()).
|
||||
sshArgs := []string{
|
||||
@@ -157,7 +181,7 @@ func writeFileViaEIC(ctx context.Context, instanceID, runtime, relPath string, c
|
||||
"-o", "ServerAliveInterval=15",
|
||||
"-p", fmt.Sprintf("%d", localPort),
|
||||
fmt.Sprintf("%s@127.0.0.1", osUser),
|
||||
fmt.Sprintf("install -D -m 0644 /dev/stdin %s", shellQuote(absPath)),
|
||||
fmt.Sprintf("sudo -n install -D -m 0644 /dev/stdin %s", shellQuote(absPath)),
|
||||
}
|
||||
sshCmd := exec.CommandContext(ctx, "ssh", sshArgs...)
|
||||
sshCmd.Env = os.Environ()
|
||||
|
||||
@@ -18,10 +18,16 @@ func TestResolveWorkspaceFilePath_KnownRuntimes(t *testing.T) {
|
||||
{"hermes", "config.yaml", "/home/ubuntu/.hermes/config.yaml"},
|
||||
{"HERMES", "config.yaml", "/home/ubuntu/.hermes/config.yaml"}, // case-insensitive
|
||||
{"hermes", "nested/a.yaml", "/home/ubuntu/.hermes/nested/a.yaml"},
|
||||
// claude-code (and any future containerized runtime) lands at /configs —
|
||||
// the path user-data creates and bind-mounts into the container. Pre-fix
|
||||
// this fell through to /opt/configs which doesn't exist on workspace EC2s
|
||||
// and would 500 with EACCES on save (the bug that motivated this gate).
|
||||
{"claude-code", "config.yaml", "/configs/config.yaml"},
|
||||
{"CLAUDE-CODE", "config.yaml", "/configs/config.yaml"}, // case-insensitive
|
||||
{"langgraph", "config.yaml", "/opt/configs/config.yaml"},
|
||||
{"external", "skills.json", "/opt/configs/skills.json"},
|
||||
{"", "config.yaml", "/opt/configs/config.yaml"}, // empty → default
|
||||
{"unknown", "config.yaml", "/opt/configs/config.yaml"}, // unknown → default
|
||||
{"", "config.yaml", "/configs/config.yaml"}, // empty → default
|
||||
{"unknown", "config.yaml", "/configs/config.yaml"}, // unknown → default
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.runtime+"/"+tc.relPath, func(t *testing.T) {
|
||||
|
||||
+10
-4
@@ -491,20 +491,26 @@ async def get_peers() -> list[dict]:
|
||||
return peers
|
||||
|
||||
|
||||
async def get_workspace_info() -> dict:
|
||||
async def get_workspace_info(source_workspace_id: str | None = None) -> dict:
|
||||
"""Get this workspace's info from the platform.
|
||||
|
||||
``source_workspace_id`` selects which registered workspace to
|
||||
introspect when the agent is registered into multiple workspaces
|
||||
(multi-workspace mode). Unset → defaults to the module-level
|
||||
WORKSPACE_ID — single-workspace operators see no behaviour change.
|
||||
|
||||
Distinguishes three failure shapes so callers can handle them
|
||||
distinctly (#2429):
|
||||
- 410 Gone → workspace was deleted; re-onboard required
|
||||
- 404 / other → workspace never existed (or transient)
|
||||
- exception → network / auth failure
|
||||
"""
|
||||
src = source_workspace_id or WORKSPACE_ID
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
try:
|
||||
resp = await client.get(
|
||||
f"{PLATFORM_URL}/workspaces/{WORKSPACE_ID}",
|
||||
headers=auth_headers(),
|
||||
f"{PLATFORM_URL}/workspaces/{src}",
|
||||
headers=auth_headers(src),
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
@@ -521,7 +527,7 @@ async def get_workspace_info() -> dict:
|
||||
body = {}
|
||||
return {
|
||||
"error": "removed",
|
||||
"id": body.get("id", WORKSPACE_ID),
|
||||
"id": body.get("id", src),
|
||||
"removed_at": body.get("removed_at"),
|
||||
"hint": body.get(
|
||||
"hint",
|
||||
|
||||
@@ -123,16 +123,20 @@ async def handle_tool_call(name: str, arguments: dict) -> str:
|
||||
source_workspace_id=arguments.get("source_workspace_id") or None,
|
||||
)
|
||||
elif name == "get_workspace_info":
|
||||
return await tool_get_workspace_info()
|
||||
return await tool_get_workspace_info(
|
||||
source_workspace_id=arguments.get("source_workspace_id") or None,
|
||||
)
|
||||
elif name == "commit_memory":
|
||||
return await tool_commit_memory(
|
||||
arguments.get("content", ""),
|
||||
arguments.get("scope", "LOCAL"),
|
||||
source_workspace_id=arguments.get("source_workspace_id") or None,
|
||||
)
|
||||
elif name == "recall_memory":
|
||||
return await tool_recall_memory(
|
||||
arguments.get("query", ""),
|
||||
arguments.get("scope", ""),
|
||||
source_workspace_id=arguments.get("source_workspace_id") or None,
|
||||
)
|
||||
elif name == "wait_for_message":
|
||||
return await tool_wait_for_message(
|
||||
@@ -151,6 +155,7 @@ async def handle_tool_call(name: str, arguments: dict) -> str:
|
||||
arguments.get("peer_id", ""),
|
||||
arguments.get("limit", 20),
|
||||
arguments.get("before_ts", ""),
|
||||
source_workspace_id=arguments.get("source_workspace_id") or None,
|
||||
)
|
||||
return f"Unknown tool: {name}"
|
||||
|
||||
|
||||
+51
-14
@@ -545,19 +545,34 @@ async def tool_list_peers(source_workspace_id: str | None = None) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def tool_get_workspace_info() -> str:
|
||||
"""Get this workspace's own info."""
|
||||
info = await get_workspace_info()
|
||||
async def tool_get_workspace_info(source_workspace_id: str | None = None) -> str:
|
||||
"""Get this workspace's own info.
|
||||
|
||||
``source_workspace_id`` selects which registered workspace to
|
||||
introspect when the agent is registered into multiple workspaces.
|
||||
Unset → falls back to module-level WORKSPACE_ID.
|
||||
"""
|
||||
info = await get_workspace_info(source_workspace_id=source_workspace_id)
|
||||
return json.dumps(info, indent=2)
|
||||
|
||||
|
||||
async def tool_commit_memory(content: str, scope: str = "LOCAL") -> str:
|
||||
async def tool_commit_memory(
|
||||
content: str,
|
||||
scope: str = "LOCAL",
|
||||
source_workspace_id: str | None = None,
|
||||
) -> str:
|
||||
"""Save important information to persistent memory.
|
||||
|
||||
GLOBAL scope is writable only by root workspaces (tier == 0).
|
||||
RBAC memory.write permission is required for all scope levels.
|
||||
The source workspace_id is embedded in every record so the platform
|
||||
can enforce cross-workspace isolation and audit trail.
|
||||
|
||||
``source_workspace_id`` selects which registered workspace this
|
||||
memory belongs to when the agent is registered into multiple
|
||||
workspaces (PR-1 / multi-workspace mode). When unset, falls back
|
||||
to the module-level WORKSPACE_ID — single-workspace operators see
|
||||
no behaviour change.
|
||||
"""
|
||||
if not content:
|
||||
return "Error: content is required"
|
||||
@@ -581,18 +596,19 @@ async def tool_commit_memory(content: str, scope: str = "LOCAL") -> str:
|
||||
"Non-root workspaces may use LOCAL or TEAM scope."
|
||||
)
|
||||
|
||||
src = source_workspace_id or WORKSPACE_ID
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(
|
||||
f"{PLATFORM_URL}/workspaces/{WORKSPACE_ID}/memories",
|
||||
f"{PLATFORM_URL}/workspaces/{src}/memories",
|
||||
json={
|
||||
"content": content,
|
||||
"scope": scope,
|
||||
# Embed source workspace so the platform can namespace-isolate
|
||||
# and audit cross-workspace writes (GH#1610 fix).
|
||||
"workspace_id": WORKSPACE_ID,
|
||||
"workspace_id": src,
|
||||
},
|
||||
headers=_auth_headers_for_heartbeat(),
|
||||
headers=_auth_headers_for_heartbeat(src),
|
||||
)
|
||||
data = resp.json()
|
||||
if resp.status_code in (200, 201):
|
||||
@@ -602,13 +618,21 @@ async def tool_commit_memory(content: str, scope: str = "LOCAL") -> str:
|
||||
return f"Error saving memory: {e}"
|
||||
|
||||
|
||||
async def tool_recall_memory(query: str = "", scope: str = "") -> str:
|
||||
async def tool_recall_memory(
|
||||
query: str = "",
|
||||
scope: str = "",
|
||||
source_workspace_id: str | None = None,
|
||||
) -> str:
|
||||
"""Search persistent memory for previously saved information.
|
||||
|
||||
RBAC memory.read permission is required (mirrors builtin_tools/memory.py).
|
||||
The workspace_id is sent as a query parameter so the platform can
|
||||
cross-validate it against the auth token and defend against any future
|
||||
path traversal / cross-tenant read bugs in the platform itself.
|
||||
|
||||
``source_workspace_id`` selects which registered workspace's memories
|
||||
to search when the agent is registered into multiple workspaces.
|
||||
Unset → defaults to the module-level WORKSPACE_ID.
|
||||
"""
|
||||
# RBAC: require memory.read permission (mirrors builtin_tools/memory.py)
|
||||
if not _check_memory_read_permission():
|
||||
@@ -617,7 +641,8 @@ async def tool_recall_memory(query: str = "", scope: str = "") -> str:
|
||||
"permission for this operation."
|
||||
)
|
||||
|
||||
params: dict[str, str] = {"workspace_id": WORKSPACE_ID}
|
||||
src = source_workspace_id or WORKSPACE_ID
|
||||
params: dict[str, str] = {"workspace_id": src}
|
||||
if query:
|
||||
params["q"] = query
|
||||
if scope:
|
||||
@@ -625,9 +650,9 @@ async def tool_recall_memory(query: str = "", scope: str = "") -> str:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(
|
||||
f"{PLATFORM_URL}/workspaces/{WORKSPACE_ID}/memories",
|
||||
f"{PLATFORM_URL}/workspaces/{src}/memories",
|
||||
params=params,
|
||||
headers=_auth_headers_for_heartbeat(),
|
||||
headers=_auth_headers_for_heartbeat(src),
|
||||
)
|
||||
data = resp.json()
|
||||
if isinstance(data, list):
|
||||
@@ -664,7 +689,12 @@ _INBOX_NOT_ENABLED_MSG = (
|
||||
)
|
||||
|
||||
|
||||
async def tool_chat_history(peer_id: str, limit: int = 20, before_ts: str = "") -> str:
|
||||
async def tool_chat_history(
|
||||
peer_id: str,
|
||||
limit: int = 20,
|
||||
before_ts: str = "",
|
||||
source_workspace_id: str | None = None,
|
||||
) -> str:
|
||||
"""Fetch the prior conversation with one peer.
|
||||
|
||||
Hits ``/workspaces/<self>/activity?peer_id=<peer>&limit=<N>``
|
||||
@@ -686,6 +716,11 @@ async def tool_chat_history(peer_id: str, limit: int = 20, before_ts: str = "")
|
||||
histories — pass the oldest ``ts`` from the previous
|
||||
response. Empty (default) returns the most recent ``limit``
|
||||
rows.
|
||||
source_workspace_id: Which registered workspace's activity log
|
||||
to query. Auto-routes via ``_peer_to_source`` cache when
|
||||
unset (the workspace this peer was discovered through);
|
||||
falls back to module-level WORKSPACE_ID for single-workspace
|
||||
operators.
|
||||
|
||||
Returns a JSON-encoded list of activity rows (or an error string
|
||||
starting with ``Error:`` so the agent can branch). Each row carries
|
||||
@@ -701,6 +736,8 @@ async def tool_chat_history(peer_id: str, limit: int = 20, before_ts: str = "")
|
||||
if limit > 500:
|
||||
limit = 500
|
||||
|
||||
src = source_workspace_id or _peer_to_source.get(peer_id) or WORKSPACE_ID
|
||||
|
||||
params: dict[str, str] = {
|
||||
"peer_id": peer_id,
|
||||
"limit": str(limit),
|
||||
@@ -713,9 +750,9 @@ async def tool_chat_history(peer_id: str, limit: int = 20, before_ts: str = "")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(
|
||||
f"{PLATFORM_URL}/workspaces/{WORKSPACE_ID}/activity",
|
||||
f"{PLATFORM_URL}/workspaces/{src}/activity",
|
||||
params=params,
|
||||
headers=_auth_headers_for_heartbeat(),
|
||||
headers=_auth_headers_for_heartbeat(src),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return f"Error: chat_history request failed: {exc}"
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Build the Starlette routes for a workspace from its (card, adapter
|
||||
state) pair.
|
||||
|
||||
Pairs with PR #2756, which decoupled ``/.well-known/agent-card.json`` from
|
||||
``adapter.setup()`` failure. main.py was the only consumer and was
|
||||
``# pragma: no cover`` — so the wiring (card-route mounted unconditionally,
|
||||
JSON-RPC route swapped between DefaultRequestHandler and the
|
||||
not-configured handler based on ``adapter_ready``) had no pytest coverage.
|
||||
|
||||
A future refactor that re-couples the two would silently bypass PR #2756
|
||||
and shipped the original "stuck booting forever" UX again. That gap is
|
||||
what closes here: extract the route-assembly into a pure function whose
|
||||
behaviour is unit-testable with Starlette's TestClient, and have main.py
|
||||
call it. Issue molecule-core#2761.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from starlette.routing import Route
|
||||
|
||||
from not_configured_handler import make_not_configured_handler
|
||||
|
||||
# Heavy a2a-sdk imports are lazy: deferred to inside build_routes so
|
||||
# tests that exercise only the not-configured branch (no executor) don't
|
||||
# need a2a.server.request_handlers / routes stubbed in their conftest.
|
||||
# Production boot pays the import cost once, on workspace startup.
|
||||
|
||||
|
||||
def build_routes(
|
||||
agent_card: Any,
|
||||
executor: Any | None,
|
||||
adapter_error: str | None,
|
||||
) -> list:
|
||||
"""Return the list of Starlette routes for this workspace.
|
||||
|
||||
Always mounts ``/.well-known/agent-card.json`` from ``agent_card``.
|
||||
|
||||
JSON-RPC route at ``/`` swaps based on adapter state:
|
||||
|
||||
* ``executor`` is non-None → ``DefaultRequestHandler`` with the
|
||||
executor (production happy-path).
|
||||
* ``executor`` is None → ``not_configured_handler`` returning JSON-RPC
|
||||
``-32603`` with ``adapter_error`` in ``error.data``. The
|
||||
workspace stays REACHABLE (operator can introspect, deprovision,
|
||||
redeploy with corrected env) instead of crash-looping invisibly.
|
||||
|
||||
The two branches are mutually exclusive — caller passes one or the
|
||||
other, never both. Test coverage at ``tests/test_boot_routes.py``
|
||||
pins the contract.
|
||||
"""
|
||||
from a2a.server.routes import create_agent_card_routes
|
||||
|
||||
routes: list = []
|
||||
routes.extend(create_agent_card_routes(agent_card))
|
||||
|
||||
if executor is not None:
|
||||
from a2a.server.request_handlers import DefaultRequestHandler
|
||||
from a2a.server.routes import create_jsonrpc_routes
|
||||
from a2a.server.tasks import InMemoryTaskStore
|
||||
|
||||
handler = DefaultRequestHandler(
|
||||
agent_executor=executor,
|
||||
task_store=InMemoryTaskStore(),
|
||||
agent_card=agent_card,
|
||||
)
|
||||
# enable_v0_3_compat=True is the JSON-RPC wire-compat path: clients
|
||||
# using v0.3-shaped payloads (`"role": "user"` lowercase + camelCase
|
||||
# Pydantic field names) can talk to us without re-deploying.
|
||||
# Outbound payloads must also use v0.3 shape — see main.py's
|
||||
# original comment block for the full a2a-sdk 1.x migration note.
|
||||
routes.extend(
|
||||
create_jsonrpc_routes(
|
||||
request_handler=handler,
|
||||
rpc_url="/",
|
||||
enable_v0_3_compat=True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
routes.append(
|
||||
Route("/", make_not_configured_handler(adapter_error), methods=["POST"])
|
||||
)
|
||||
|
||||
return routes
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Helpers for building / mutating the workspace ``AgentCard``.
|
||||
|
||||
Kept as their own module so the behavior is unit-testable without booting
|
||||
the whole runtime (``main.py`` is ``# pragma: no cover``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
from a2a.types import AgentCard, AgentSkill
|
||||
|
||||
|
||||
def enrich_card_skills(card: AgentCard, loaded_skills: Iterable | None) -> bool:
|
||||
"""Replace ``card.skills`` with rich metadata from the adapter's loaded
|
||||
skills, in place. Pairs with PR #2756: the card was built up front from
|
||||
static ``config.skills`` names so /.well-known/agent-card.json could
|
||||
serve before ``adapter.setup()`` finishes; this swaps in the richer
|
||||
descriptions/tags/examples that ``setup()``'s skill loader produces.
|
||||
|
||||
Returns ``True`` on swap, ``False`` when the swap was skipped or
|
||||
failed. Failure cases:
|
||||
* ``loaded_skills`` is None / empty — caller didn't load any.
|
||||
* Any element doesn't expose ``.metadata.{id,name,description,tags,examples}``
|
||||
(a future adapter that doesn't follow the canonical shape).
|
||||
|
||||
Failures DO NOT raise — a malformed ``loaded_skills`` shape would
|
||||
otherwise propagate to ``main.py``'s outer ``except Exception``,
|
||||
silently degrading an OK boot to the not-configured state. Static
|
||||
stubs from ``config.skills`` stay in place; setup() already
|
||||
succeeded, the agent works, only the card's skill enrichment is
|
||||
degraded. Operator sees a clear log line; tests assert this
|
||||
distinction.
|
||||
"""
|
||||
if not loaded_skills:
|
||||
return False
|
||||
|
||||
try:
|
||||
rich = [
|
||||
AgentSkill(
|
||||
id=skill.metadata.id,
|
||||
name=skill.metadata.name,
|
||||
description=skill.metadata.description,
|
||||
tags=skill.metadata.tags,
|
||||
examples=skill.metadata.examples,
|
||||
)
|
||||
for skill in loaded_skills
|
||||
]
|
||||
except Exception as enrich_err: # noqa: BLE001
|
||||
print(
|
||||
f"Warning: skill metadata enrichment failed (keeping static "
|
||||
f"stubs from config.skills): {type(enrich_err).__name__}: {enrich_err}",
|
||||
flush=True,
|
||||
)
|
||||
return False
|
||||
|
||||
card.skills = rich
|
||||
return True
|
||||
+35
-61
@@ -245,18 +245,13 @@ async def main(): # pragma: no cover
|
||||
# 6c. Swap rich skill metadata into the card now that setup() loaded
|
||||
# them. In-place mutation: a2a-sdk's create_agent_card_routes serialises
|
||||
# the card on each request, so the route mounted below sees the update.
|
||||
loaded_skills = getattr(adapter, "loaded_skills", None)
|
||||
if loaded_skills:
|
||||
agent_card.skills = [
|
||||
AgentSkill(
|
||||
id=skill.metadata.id,
|
||||
name=skill.metadata.name,
|
||||
description=skill.metadata.description,
|
||||
tags=skill.metadata.tags,
|
||||
examples=skill.metadata.examples,
|
||||
)
|
||||
for skill in loaded_skills
|
||||
]
|
||||
# Isolated via card_helpers.enrich_card_skills — a malformed
|
||||
# loaded_skills shape (e.g., a future adapter that doesn't follow
|
||||
# the .metadata convention) is logged + swallowed instead of
|
||||
# propagating up to the outer except, where it would silently
|
||||
# degrade an OK boot to the not-configured state.
|
||||
from card_helpers import enrich_card_skills
|
||||
enrich_card_skills(agent_card, getattr(adapter, "loaded_skills", None))
|
||||
adapter_ready = True
|
||||
except SystemExit:
|
||||
# Smoke-mode exit signal — propagate untouched.
|
||||
@@ -282,54 +277,16 @@ async def main(): # pragma: no cover
|
||||
|
||||
# 7. Wrap in A2A.
|
||||
#
|
||||
# Regression fix (#204): PR #198 tried to wire push_config_store +
|
||||
# push_sender to satisfy #175 (push notification capability), but
|
||||
# PushNotificationSender is an abstract base class in the a2a-sdk and
|
||||
# can't be instantiated directly. Passing it crashed main.py on startup
|
||||
# with `TypeError: Can't instantiate abstract class`. Dropped back to
|
||||
# DefaultRequestHandler's own defaults — pushNotifications capability
|
||||
# in the AgentCard below is still advertised via AgentCapabilities so
|
||||
# clients know we COULD do pushes; actually implementing them requires
|
||||
# a concrete sender subclass, tracked as a Phase-H follow-up to #175.
|
||||
routes = []
|
||||
routes.extend(create_agent_card_routes(agent_card))
|
||||
|
||||
if adapter_ready:
|
||||
handler = DefaultRequestHandler(
|
||||
agent_executor=executor,
|
||||
task_store=InMemoryTaskStore(),
|
||||
# a2a-sdk 1.x added agent_card as a required positional/keyword
|
||||
# argument — it's used internally for capability dispatch (e.g.
|
||||
# routing tasks/get historyLength based on the card's protocol
|
||||
# version). Pass the same agent_card we registered with the
|
||||
# platform so the handler's capability surface matches what the
|
||||
# AgentCard advertises.
|
||||
agent_card=agent_card,
|
||||
)
|
||||
# v1: replace A2AStarletteApplication with Starlette route factory.
|
||||
# rpc_url is required in a2a-sdk 1.x (was implicit at root in 0.x).
|
||||
# Use '/' to match a2a.utils.constants.DEFAULT_RPC_URL — that's also
|
||||
# what the platform's a2a_proxy.go POSTs to (it forwards to the
|
||||
# workspace's URL without appending a path). Card endpoint stays at
|
||||
# the well-known path /.well-known/agent-card.json (handled by
|
||||
# create_agent_card_routes default).
|
||||
# enable_v0_3_compat=True is the JSON-RPC wire-compat path: clients
|
||||
# using v0.3-shaped payloads (`"role": "user"` lowercase + camelCase
|
||||
# Pydantic field names) can talk to us without re-deploying.
|
||||
routes.extend(create_jsonrpc_routes(request_handler=handler, rpc_url="/", enable_v0_3_compat=True))
|
||||
else:
|
||||
# Misconfigured: serve the card but reject JSON-RPC with -32603 so
|
||||
# canvas surfaces a useful "agent not configured: <reason>" instead
|
||||
# of letting requests time out. Handler factory is in its own module
|
||||
# so the behavior is unit-testable (workspace/tests/test_not_configured_handler.py).
|
||||
from starlette.routing import Route
|
||||
from not_configured_handler import make_not_configured_handler
|
||||
|
||||
routes.append(
|
||||
Route("/", make_not_configured_handler(adapter_error), methods=["POST"])
|
||||
)
|
||||
|
||||
app = Starlette(routes=routes)
|
||||
# Route assembly is in workspace/boot_routes.py so the contract —
|
||||
# card always mounted, JSON-RPC route swaps based on adapter state
|
||||
# (DefaultRequestHandler when executor is non-None, not_configured
|
||||
# handler returning -32603 otherwise) — is unit-testable with
|
||||
# Starlette's TestClient. main.py is `# pragma: no cover` so without
|
||||
# this extraction a future refactor that re-coupled card + setup()
|
||||
# would silently bypass PR #2756. tests/test_boot_routes.py pins
|
||||
# the four-branch contract.
|
||||
from boot_routes import build_routes
|
||||
app = Starlette(routes=build_routes(agent_card, executor, adapter_error))
|
||||
|
||||
# 8. Register with platform
|
||||
# When adapter.setup() failed, advertise via configuration_status so
|
||||
@@ -497,7 +454,24 @@ async def main(): # pragma: no cover
|
||||
limit = int(request.query_params.get("limit", "100"))
|
||||
except (TypeError, ValueError):
|
||||
return JSONResponse({"error": "since and limit must be integers"}, status_code=400)
|
||||
result = await adapter.transcript_lines(since=since, limit=limit)
|
||||
# Isolate adapter call: misconfigured boots leave the adapter
|
||||
# partially-initialised, and a future adapter override of
|
||||
# transcript_lines might assume setup() ran. Surface a 503 with
|
||||
# a clear reason instead of letting the exception propagate to
|
||||
# Starlette's 500 handler — same pattern as the not-configured
|
||||
# JSON-RPC route (PR #2756). BaseAdapter.transcript_lines's
|
||||
# default returns {"supported": false} so today's 4 adapters
|
||||
# never trigger this branch; this is the safety net.
|
||||
try:
|
||||
result = await adapter.transcript_lines(since=since, limit=limit)
|
||||
except Exception as transcript_err: # noqa: BLE001
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "transcript unavailable",
|
||||
"detail": f"{type(transcript_err).__name__}: {transcript_err}",
|
||||
},
|
||||
status_code=503,
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
starlette_app.add_route("/transcript", _transcript_handler, methods=["GET"])
|
||||
|
||||
@@ -16,6 +16,8 @@ from typing import Awaitable, Callable
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from secret_redactor import redact_secrets
|
||||
|
||||
|
||||
def make_not_configured_handler(
|
||||
reason: str | None,
|
||||
@@ -27,12 +29,24 @@ def make_not_configured_handler(
|
||||
stringified ``adapter.setup()`` exception. ``None`` falls back to a
|
||||
generic "adapter.setup() failed".
|
||||
|
||||
Secret redaction (issue molecule-core#2760): ``reason`` is run
|
||||
through ``secret_redactor.redact_secrets`` once, when the handler
|
||||
is built. If a future adapter author writes ``raise
|
||||
RuntimeError(f"auth failed for {token}")``, the token is replaced
|
||||
with ``<redacted-secret>`` BEFORE it lands in the response —
|
||||
closes the structural leak path PR #2756 introduced. Per-request
|
||||
hot path stays unchanged (one cached string, no re-redaction).
|
||||
|
||||
The handler echoes the request's JSON-RPC ``id`` when present so a
|
||||
well-behaved JSON-RPC client can correlate the error to its request.
|
||||
Malformed bodies (non-JSON, missing id) get ``id: null`` per spec.
|
||||
"""
|
||||
|
||||
fallback = reason or "adapter.setup() failed"
|
||||
# Redact at handler-build time, not per-request, so the hot path
|
||||
# stays a constant lookup. The fallback string can't carry secrets
|
||||
# but we still pass it through redact_secrets() so a future change
|
||||
# to the fallback can't accidentally introduce a leak.
|
||||
fallback = redact_secrets(reason or "adapter.setup() failed")
|
||||
|
||||
async def _handler(request: Request) -> JSONResponse:
|
||||
try:
|
||||
|
||||
@@ -271,7 +271,19 @@ _GET_WORKSPACE_INFO = ToolSpec(
|
||||
"back to the user, or to determine whether you're a tier-0 "
|
||||
"root that can write GLOBAL memory)."
|
||||
),
|
||||
input_schema={"type": "object", "properties": {}},
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source_workspace_id": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional. In multi-workspace mode (this agent registered "
|
||||
"in N workspaces), introspect the named workspace instead "
|
||||
"of the primary one. Single-workspace agents omit this."
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
impl=tool_get_workspace_info,
|
||||
section=A2A_SECTION,
|
||||
)
|
||||
@@ -455,6 +467,14 @@ _CHAT_HISTORY = ToolSpec(
|
||||
"Use the oldest `created_at` from a previous response."
|
||||
),
|
||||
},
|
||||
"source_workspace_id": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional. Multi-workspace mode: query the named "
|
||||
"workspace's activity log instead of the primary one. "
|
||||
"Auto-routes via the peer-discovery cache when unset."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["peer_id"],
|
||||
},
|
||||
@@ -515,6 +535,16 @@ _COMMIT_MEMORY = ToolSpec(
|
||||
"enum": ["LOCAL", "TEAM", "GLOBAL"],
|
||||
"description": "Memory scope (default LOCAL).",
|
||||
},
|
||||
"source_workspace_id": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional. Multi-workspace mode: commit the memory "
|
||||
"into the named workspace's namespace instead of "
|
||||
"the primary one. Pair with the inbound message's "
|
||||
"`arrival_workspace_id` so memories stay in the "
|
||||
"tenant they were derived from."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["content"],
|
||||
},
|
||||
@@ -544,6 +574,16 @@ _RECALL_MEMORY = ToolSpec(
|
||||
"enum": ["LOCAL", "TEAM", "GLOBAL", ""],
|
||||
"description": "Filter by scope (empty = all accessible).",
|
||||
},
|
||||
"source_workspace_id": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional. Multi-workspace mode: search the named "
|
||||
"workspace's memories instead of the primary one. "
|
||||
"Pair with the inbound message's "
|
||||
"`arrival_workspace_id` to recall context for the "
|
||||
"right tenant."
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
impl=tool_recall_memory,
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Pattern-based secret redaction for adapter exception strings.
|
||||
|
||||
Used by ``not_configured_handler`` (and any future code path that exposes
|
||||
adapter-side error strings to the network) to scrub secret-shaped tokens
|
||||
before they land in JSON-RPC ``error.data``.
|
||||
|
||||
Why this exists (issue molecule-core#2760): PR #2756 piped
|
||||
``adapter.setup()`` exception strings verbatim into the JSON-RPC -32603
|
||||
response so canvas could surface "agent not configured: <reason>". The
|
||||
4 adapters in tree today (claude-code/codex/openclaw/hermes) raise with
|
||||
key NAMES not values, so this is currently safe — but a future adapter
|
||||
author writing ``raise RuntimeError(f"auth failed for {token}")`` would
|
||||
leak that token to every JSON-RPC client. This module is the structural
|
||||
floor that keeps the leak from happening.
|
||||
|
||||
The redactor is intentionally pattern-based (a closed list of known
|
||||
prefixes), NOT entropy-based — entropy heuristics false-positive on
|
||||
hex git SHAs and base64-shaped UUIDs that carry zero secret value.
|
||||
A pattern miss is preferable to redacting "RuntimeError: invalid
|
||||
config_path=ed8f1234abcd" out of a real diagnostic.
|
||||
|
||||
Pairs with ``not_configured_handler.make_not_configured_handler`` —
|
||||
the redactor runs once when the handler is built, so per-request hot
|
||||
path stays unchanged.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# Closed list of known secret-shaped prefixes / formats. Each entry is a
|
||||
# compiled regex with one or more capture groups; the redactor replaces
|
||||
# the whole match with REDACTION_PLACEHOLDER. The entries are roughly
|
||||
# ordered by frequency in our adapter exception strings — Anthropic /
|
||||
# OpenAI / OpenRouter style tokens come first.
|
||||
#
|
||||
# Matched on token-ISH boundaries (start/end of string, whitespace, or
|
||||
# common separators like : / = ( ) " ' ,). Avoids redacting ``sk`` in
|
||||
# the middle of unrelated text like "task_sk_id" while still catching
|
||||
# ``sk-ant-...`` / ``sk-cp-...`` / ``sk-or-...``.
|
||||
_TOKEN_BOUNDARY_LEFT = r"(?:^|[\s\(\)\[\]\{\}\"'=,:/])"
|
||||
_TOKEN_BOUNDARY_RIGHT = r"(?=$|[\s\(\)\[\]\{\}\"'=,:/])"
|
||||
|
||||
REDACTION_PLACEHOLDER = "<redacted-secret>"
|
||||
|
||||
_PATTERNS = [
|
||||
# Anthropic / OpenAI / OpenRouter / Stripe / proprietary `sk-` family.
|
||||
# Token format: `sk-` then any non-whitespace run. Length 16+ to avoid
|
||||
# false-matching on `sk-test` style placeholders shorter than a real
|
||||
# key (16 covers OpenAI's shortest legacy key length).
|
||||
re.compile(
|
||||
_TOKEN_BOUNDARY_LEFT + r"(sk-[A-Za-z0-9_\-]{16,})" + _TOKEN_BOUNDARY_RIGHT
|
||||
),
|
||||
# GitHub Personal Access Tokens (classic + fine-grained + OAuth + app).
|
||||
# Format: ghp_ / gho_ / ghu_ / ghs_ / ghr_ followed by ~36 chars.
|
||||
re.compile(
|
||||
_TOKEN_BOUNDARY_LEFT + r"(gh[pousr]_[A-Za-z0-9]{20,})" + _TOKEN_BOUNDARY_RIGHT
|
||||
),
|
||||
# AWS access key id — fixed 16-char prefix `AKIA` (or `ASIA` for
|
||||
# session creds) followed by 16 alphanumeric chars (20 total).
|
||||
re.compile(
|
||||
_TOKEN_BOUNDARY_LEFT + r"((?:AKIA|ASIA)[0-9A-Z]{16})" + _TOKEN_BOUNDARY_RIGHT
|
||||
),
|
||||
# Bearer prefix common in HTTP error strings: `Bearer <token>`.
|
||||
# The match captures the literal `Bearer ` plus the token so the
|
||||
# full leak (which includes the prefix in some adapter error
|
||||
# messages) is scrubbed in one go.
|
||||
re.compile(r"(Bearer\s+[A-Za-z0-9_\-\.=]{16,})"),
|
||||
# Slack / Hugging Face / generic `xoxb-`, `xoxp-`, `xoxa-` prefixes.
|
||||
re.compile(
|
||||
_TOKEN_BOUNDARY_LEFT + r"(xox[bpars]-[A-Za-z0-9\-]{10,})" + _TOKEN_BOUNDARY_RIGHT
|
||||
),
|
||||
# Hugging Face API tokens: `hf_` followed by ~37 chars.
|
||||
re.compile(
|
||||
_TOKEN_BOUNDARY_LEFT + r"(hf_[A-Za-z0-9]{20,})" + _TOKEN_BOUNDARY_RIGHT
|
||||
),
|
||||
# Generic JWT — three base64url segments separated by dots. JWTs
|
||||
# carry signed claims that often include user identifiers; even a
|
||||
# public-key-only JWT shouldn't end up in an error.data field that
|
||||
# gets logged / echoed back to clients.
|
||||
re.compile(
|
||||
_TOKEN_BOUNDARY_LEFT + r"(eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,})" + _TOKEN_BOUNDARY_RIGHT
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def redact_secrets(text: str) -> str:
|
||||
"""Return ``text`` with any secret-shaped substrings replaced by
|
||||
``REDACTION_PLACEHOLDER``.
|
||||
|
||||
Empty / None input returns the input unchanged so callers can pass
|
||||
through ``adapter_error`` even when it's None.
|
||||
|
||||
The redactor operates on the WHOLE string, not line-by-line, so a
|
||||
multi-line traceback with a token on line 3 still gets scrubbed.
|
||||
Multiple distinct tokens in the same string are all redacted; the
|
||||
placeholder appears once per match.
|
||||
|
||||
Trade-off: pattern-based redaction misses tokens whose prefix isn't
|
||||
in ``_PATTERNS``. The cost of a miss is a leak; the cost of going
|
||||
pattern-free (e.g., entropy heuristic) is false-positive redaction
|
||||
of git SHAs and UUIDs in legitimate diagnostics. We choose miss-on-
|
||||
unknown-prefix and rely on ``_PATTERNS`` growing over time as we
|
||||
catch new providers. Adapter PRs that introduce a new provider
|
||||
SHOULD add the provider's token prefix here.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
out = text
|
||||
for pat in _PATTERNS:
|
||||
out = pat.sub(
|
||||
# Preserve the leading boundary char (group 0 minus the
|
||||
# token capture) so substitution doesn't eat surrounding
|
||||
# punctuation. Achieved by re-emitting the leading
|
||||
# boundary then the placeholder. Patterns that don't have
|
||||
# a left-boundary group (Bearer) just emit the placeholder.
|
||||
_make_replacer(pat),
|
||||
out,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _make_replacer(pat: re.Pattern) -> "callable":
|
||||
"""Build a sub() replacer that preserves any boundary char captured
|
||||
by ``pat`` before the secret-shaped group.
|
||||
|
||||
Patterns built with ``_TOKEN_BOUNDARY_LEFT`` produce a non-capturing
|
||||
group for the boundary. Match.group(0) is the full match including
|
||||
that boundary; group(1) is just the secret. We replace group(1)
|
||||
with the placeholder, leaving group(0) minus group(1) intact.
|
||||
"""
|
||||
def _repl(m: re.Match) -> str:
|
||||
full = m.group(0)
|
||||
secret = m.group(1)
|
||||
# Position of the secret within the full match.
|
||||
idx = full.find(secret)
|
||||
if idx < 0:
|
||||
return REDACTION_PLACEHOLDER
|
||||
return full[:idx] + REDACTION_PLACEHOLDER + full[idx + len(secret):]
|
||||
return _repl
|
||||
@@ -145,6 +145,113 @@ def _make_a2a_mocks():
|
||||
types_mod.TaskStatus = TaskStatus
|
||||
types_mod.TaskState = _TaskStateEnum
|
||||
|
||||
# v1 AgentCard / AgentSkill / AgentCapabilities / AgentInterface — used
|
||||
# by main.py's static-card construction (PR #2756) and by
|
||||
# card_helpers.enrich_card_skills's swap path. Stubs preserve kwargs so
|
||||
# tests can assert on card.skills[i].name etc., and let card.skills be
|
||||
# reassigned in place (the production code's enrichment pattern).
|
||||
class AgentSkill:
|
||||
def __init__(self, id="", name="", description="", tags=None, examples=None, **kwargs):
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.tags = list(tags) if tags is not None else []
|
||||
self.examples = list(examples) if examples is not None else []
|
||||
for k, v in kwargs.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
class AgentCapabilities:
|
||||
def __init__(self, **kwargs):
|
||||
for k, v in kwargs.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
class AgentInterface:
|
||||
def __init__(self, **kwargs):
|
||||
for k, v in kwargs.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
class AgentCard:
|
||||
def __init__(self, **kwargs):
|
||||
self.skills = []
|
||||
for k, v in kwargs.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
types_mod.AgentSkill = AgentSkill
|
||||
types_mod.AgentCapabilities = AgentCapabilities
|
||||
types_mod.AgentInterface = AgentInterface
|
||||
types_mod.AgentCard = AgentCard
|
||||
|
||||
# a2a.server.routes — used by boot_routes.build_routes (PR #2756 chain
|
||||
# / #2761) to mount /.well-known/agent-card.json. The real SDK builds
|
||||
# a Starlette route that serializes the card on each request; the stub
|
||||
# mirrors that behaviour with json.dumps over the card's __dict__ so
|
||||
# TestClient.get("/.well-known/agent-card.json") returns the same
|
||||
# shape canvas would see in production.
|
||||
routes_mod = ModuleType("a2a.server.routes")
|
||||
|
||||
def _create_agent_card_routes(card):
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
async def _card_handler(_request):
|
||||
# Convert the stub AgentCard into a JSON-serialisable dict.
|
||||
# Real a2a.types.AgentCard is a Pydantic model with proper
|
||||
# serialisation; the stub stores attrs raw, so we walk
|
||||
# __dict__ and serialise nested AgentSkill objects too.
|
||||
def _to_dict(obj):
|
||||
if hasattr(obj, "__dict__"):
|
||||
return {k: _to_dict(v) for k, v in vars(obj).items()}
|
||||
if isinstance(obj, list):
|
||||
return [_to_dict(x) for x in obj]
|
||||
if isinstance(obj, dict):
|
||||
return {k: _to_dict(v) for k, v in obj.items()}
|
||||
return obj
|
||||
|
||||
return JSONResponse(_to_dict(card))
|
||||
|
||||
return [Route("/.well-known/agent-card.json", _card_handler, methods=["GET"])]
|
||||
|
||||
def _create_jsonrpc_routes(request_handler=None, rpc_url="/", **_kwargs):
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
async def _jsonrpc_handler(_request):
|
||||
# Stub: real DefaultRequestHandler dispatches to the executor;
|
||||
# tests that need real behaviour will use a test-side mock.
|
||||
# This stub just returns a JSON-RPC envelope so the not-configured
|
||||
# branch's discriminator (`error.data` containing "setup() failed")
|
||||
# has something to differ from.
|
||||
return JSONResponse({"jsonrpc": "2.0", "result": "stub-jsonrpc-handler"})
|
||||
|
||||
return [Route(rpc_url, _jsonrpc_handler, methods=["POST"])]
|
||||
|
||||
routes_mod.create_agent_card_routes = _create_agent_card_routes
|
||||
routes_mod.create_jsonrpc_routes = _create_jsonrpc_routes
|
||||
sys.modules["a2a.server.routes"] = routes_mod
|
||||
|
||||
# a2a.server.request_handlers — used by boot_routes' executor branch.
|
||||
# DefaultRequestHandler stub takes the same kwargs as the real one;
|
||||
# tests that exercise the executor path don't poke at the handler's
|
||||
# internals, only that it gets mounted at "/".
|
||||
rh_mod = ModuleType("a2a.server.request_handlers")
|
||||
|
||||
class DefaultRequestHandler:
|
||||
def __init__(self, agent_executor=None, task_store=None, agent_card=None, **_kwargs):
|
||||
self.agent_executor = agent_executor
|
||||
self.task_store = task_store
|
||||
self.agent_card = agent_card
|
||||
|
||||
rh_mod.DefaultRequestHandler = DefaultRequestHandler
|
||||
sys.modules["a2a.server.request_handlers"] = rh_mod
|
||||
|
||||
# InMemoryTaskStore is exposed via a2a.server.tasks (already stubbed
|
||||
# above with TaskUpdater). Add it as a no-op class.
|
||||
class _InMemoryTaskStore:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
tasks_mod.InMemoryTaskStore = _InMemoryTaskStore
|
||||
|
||||
# a2a.helpers (v1: moved from a2a.utils, renamed new_agent_text_message
|
||||
# → new_text_message). Mock both names — production code only calls
|
||||
# new_text_message, but if any test still references the old name it
|
||||
|
||||
@@ -71,6 +71,105 @@ async def test_handle_tool_call_unknown_tool():
|
||||
assert "Unknown tool" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# source_workspace_id propagation — every workspace-scoped tool's schema
|
||||
# advertises this parameter (PR #2766) so the LLM can route a memory commit
|
||||
# or chat-history query through the workspace the inbound message arrived
|
||||
# on. The dispatch path itself MUST forward the kwarg — otherwise the
|
||||
# schema lies and every call silently falls back to the module-level
|
||||
# WORKSPACE_ID, defeating multi-workspace isolation. These tests pin
|
||||
# end-to-end argument flow on the four tools that ship in PR #2766.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_dispatch_get_workspace_info_forwards_source_workspace_id():
|
||||
from a2a_mcp_server import handle_tool_call
|
||||
mock = AsyncMock(return_value='{"id":"ws-X"}')
|
||||
with patch("a2a_mcp_server.tool_get_workspace_info", new=mock):
|
||||
await handle_tool_call(
|
||||
"get_workspace_info",
|
||||
{"source_workspace_id": "ws-X"},
|
||||
)
|
||||
mock.assert_awaited_once_with(source_workspace_id="ws-X")
|
||||
|
||||
|
||||
async def test_dispatch_commit_memory_forwards_source_workspace_id():
|
||||
from a2a_mcp_server import handle_tool_call
|
||||
mock = AsyncMock(return_value='{"success":true}')
|
||||
with patch("a2a_mcp_server.tool_commit_memory", new=mock):
|
||||
await handle_tool_call(
|
||||
"commit_memory",
|
||||
{
|
||||
"content": "remember this",
|
||||
"scope": "LOCAL",
|
||||
"source_workspace_id": "ws-Y",
|
||||
},
|
||||
)
|
||||
mock.assert_awaited_once_with(
|
||||
"remember this",
|
||||
"LOCAL",
|
||||
source_workspace_id="ws-Y",
|
||||
)
|
||||
|
||||
|
||||
async def test_dispatch_recall_memory_forwards_source_workspace_id():
|
||||
from a2a_mcp_server import handle_tool_call
|
||||
mock = AsyncMock(return_value="[LOCAL] remember this")
|
||||
with patch("a2a_mcp_server.tool_recall_memory", new=mock):
|
||||
await handle_tool_call(
|
||||
"recall_memory",
|
||||
{
|
||||
"query": "remember",
|
||||
"scope": "LOCAL",
|
||||
"source_workspace_id": "ws-Z",
|
||||
},
|
||||
)
|
||||
mock.assert_awaited_once_with(
|
||||
"remember",
|
||||
"LOCAL",
|
||||
source_workspace_id="ws-Z",
|
||||
)
|
||||
|
||||
|
||||
async def test_dispatch_chat_history_forwards_source_workspace_id():
|
||||
from a2a_mcp_server import handle_tool_call
|
||||
mock = AsyncMock(return_value="[]")
|
||||
with patch("a2a_mcp_server.tool_chat_history", new=mock):
|
||||
await handle_tool_call(
|
||||
"chat_history",
|
||||
{
|
||||
"peer_id": "peer-A",
|
||||
"limit": 10,
|
||||
"source_workspace_id": "ws-W",
|
||||
},
|
||||
)
|
||||
mock.assert_awaited_once_with(
|
||||
"peer-A",
|
||||
10,
|
||||
"",
|
||||
source_workspace_id="ws-W",
|
||||
)
|
||||
|
||||
|
||||
async def test_dispatch_omits_source_workspace_id_when_unset():
|
||||
"""Single-workspace operators (no source_workspace_id key in args) must
|
||||
forward None — preserving the legacy fallback to module-level WORKSPACE_ID
|
||||
inside the tool. An accidental empty-string forward would also fall back,
|
||||
but None is the documented contract."""
|
||||
from a2a_mcp_server import handle_tool_call
|
||||
mock = AsyncMock(return_value='{"success":true}')
|
||||
with patch("a2a_mcp_server.tool_commit_memory", new=mock):
|
||||
await handle_tool_call(
|
||||
"commit_memory",
|
||||
{"content": "x", "scope": "LOCAL"},
|
||||
)
|
||||
mock.assert_awaited_once_with(
|
||||
"x",
|
||||
"LOCAL",
|
||||
source_workspace_id=None,
|
||||
)
|
||||
|
||||
|
||||
async def test_handle_tool_call_missing_args_defaults():
|
||||
"""Test that missing args default to empty strings (defensive)."""
|
||||
from a2a_mcp_server import handle_tool_call
|
||||
|
||||
@@ -426,3 +426,220 @@ class TestListRegisteredWorkspaces:
|
||||
platform_auth.register_workspace_token("ws-1", "tok-1")
|
||||
platform_auth.clear_cache()
|
||||
assert platform_auth.list_registered_workspaces() == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Memory tools — commit/recall must namespace under source_workspace_id
|
||||
# so an agent serving multiple tenants doesn't bleed memories across
|
||||
# them. Single-workspace path (no source arg) keeps using WORKSPACE_ID.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCommitMemorySourceRouting:
|
||||
@pytest.mark.asyncio
|
||||
async def test_url_and_auth_use_source_workspace_id(self, monkeypatch):
|
||||
"""commit_memory(source_workspace_id=X) must POST to /workspaces/X/
|
||||
with X's bearer token — otherwise a multi-tenant agent could
|
||||
write into the wrong tenant's memory namespace."""
|
||||
import platform_auth, a2a_tools
|
||||
|
||||
platform_auth.register_workspace_token("ffff6666-ffff-ffff-ffff-ffffffffffff", "token-F")
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
def json(self):
|
||||
return {"id": "mem-1"}
|
||||
|
||||
class _Client:
|
||||
async def __aenter__(self): return self
|
||||
async def __aexit__(self, *a): return None
|
||||
async def post(self, url, headers, json):
|
||||
captured["url"] = url
|
||||
captured["headers"] = headers
|
||||
captured["body"] = json
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(a2a_tools.httpx, "AsyncClient", lambda timeout: _Client())
|
||||
|
||||
result = await a2a_tools.tool_commit_memory(
|
||||
"remember this",
|
||||
source_workspace_id="ffff6666-ffff-ffff-ffff-ffffffffffff",
|
||||
)
|
||||
|
||||
assert "/workspaces/ffff6666-ffff-ffff-ffff-ffffffffffff/memories" in captured["url"]
|
||||
assert captured["headers"]["Authorization"] == "Bearer token-F"
|
||||
assert captured["body"]["workspace_id"] == "ffff6666-ffff-ffff-ffff-ffffffffffff"
|
||||
import json as _json
|
||||
assert _json.loads(result)["success"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_falls_back_to_module_workspace_id(self, monkeypatch):
|
||||
"""Without source_workspace_id, single-workspace operators keep
|
||||
the legacy WORKSPACE_ID-based POST — no behavior change."""
|
||||
import a2a_client, a2a_tools
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
def json(self):
|
||||
return {"id": "mem-1"}
|
||||
|
||||
class _Client:
|
||||
async def __aenter__(self): return self
|
||||
async def __aexit__(self, *a): return None
|
||||
async def post(self, url, headers, json):
|
||||
captured["url"] = url
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(a2a_tools.httpx, "AsyncClient", lambda timeout: _Client())
|
||||
|
||||
await a2a_tools.tool_commit_memory("remember this")
|
||||
assert f"/workspaces/{a2a_client.WORKSPACE_ID}/memories" in captured["url"]
|
||||
|
||||
|
||||
class TestRecallMemorySourceRouting:
|
||||
@pytest.mark.asyncio
|
||||
async def test_url_params_and_auth_use_source(self, monkeypatch):
|
||||
"""recall_memory routes the GET, the workspace_id query param,
|
||||
and the auth header through source_workspace_id."""
|
||||
import platform_auth, a2a_tools
|
||||
|
||||
platform_auth.register_workspace_token("aaaa7777-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "token-G")
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
def json(self):
|
||||
return []
|
||||
|
||||
class _Client:
|
||||
async def __aenter__(self): return self
|
||||
async def __aexit__(self, *a): return None
|
||||
async def get(self, url, params, headers):
|
||||
captured["url"] = url
|
||||
captured["params"] = params
|
||||
captured["headers"] = headers
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(a2a_tools.httpx, "AsyncClient", lambda timeout: _Client())
|
||||
|
||||
await a2a_tools.tool_recall_memory(
|
||||
query="x",
|
||||
source_workspace_id="aaaa7777-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
)
|
||||
|
||||
assert "/workspaces/aaaa7777-aaaa-aaaa-aaaa-aaaaaaaaaaaa/memories" in captured["url"]
|
||||
assert captured["params"]["workspace_id"] == "aaaa7777-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
assert captured["headers"]["Authorization"] == "Bearer token-G"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# chat_history — auto-routes via the peer→source cache so an inbound
|
||||
# peer_agent push from workspace X sees its history queried against X.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestChatHistorySourceRouting:
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_routes_via_peer_cache(self, monkeypatch):
|
||||
"""chat_history(peer_id) without an explicit source falls back to
|
||||
``_peer_to_source[peer_id]`` — same auto-routing as delegate_task,
|
||||
so the agent doesn't have to remember which workspace surfaced
|
||||
each peer."""
|
||||
import platform_auth, a2a_client, a2a_tools
|
||||
|
||||
platform_auth.register_workspace_token("bbbb8888-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "token-H")
|
||||
peer_id = "1111aaaa-1111-1111-1111-111111111111"
|
||||
a2a_client._peer_to_source[peer_id] = "bbbb8888-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
def json(self):
|
||||
return []
|
||||
|
||||
class _Client:
|
||||
async def __aenter__(self): return self
|
||||
async def __aexit__(self, *a): return None
|
||||
async def get(self, url, params, headers):
|
||||
captured["url"] = url
|
||||
captured["headers"] = headers
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(a2a_tools.httpx, "AsyncClient", lambda timeout: _Client())
|
||||
|
||||
await a2a_tools.tool_chat_history(peer_id, limit=5)
|
||||
|
||||
assert "/workspaces/bbbb8888-bbbb-bbbb-bbbb-bbbbbbbbbbbb/activity" in captured["url"]
|
||||
assert captured["headers"]["Authorization"] == "Bearer token-H"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_source_beats_cache(self, monkeypatch):
|
||||
import platform_auth, a2a_client, a2a_tools
|
||||
|
||||
platform_auth.register_workspace_token("cccc9999-cccc-cccc-cccc-cccccccccccc", "token-I")
|
||||
peer_id = "1111aaaa-1111-1111-1111-111111111111"
|
||||
a2a_client._peer_to_source[peer_id] = "should-not-be-used"
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
def json(self):
|
||||
return []
|
||||
|
||||
class _Client:
|
||||
async def __aenter__(self): return self
|
||||
async def __aexit__(self, *a): return None
|
||||
async def get(self, url, params, headers):
|
||||
captured["url"] = url
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(a2a_tools.httpx, "AsyncClient", lambda timeout: _Client())
|
||||
|
||||
await a2a_tools.tool_chat_history(
|
||||
peer_id, source_workspace_id="cccc9999-cccc-cccc-cccc-cccccccccccc",
|
||||
)
|
||||
assert "/workspaces/cccc9999-cccc-cccc-cccc-cccccccccccc/activity" in captured["url"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_workspace_info — multi-workspace introspection.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetWorkspaceInfoSourceRouting:
|
||||
@pytest.mark.asyncio
|
||||
async def test_introspects_named_workspace(self, monkeypatch):
|
||||
import platform_auth, a2a_client
|
||||
|
||||
platform_auth.register_workspace_token("dddd0000-dddd-dddd-dddd-dddddddddddd", "token-J")
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
def json(self):
|
||||
return {"id": "dddd0000-dddd-dddd-dddd-dddddddddddd", "name": "wsJ"}
|
||||
|
||||
class _Client:
|
||||
async def __aenter__(self): return self
|
||||
async def __aexit__(self, *a): return None
|
||||
async def get(self, url, headers):
|
||||
captured["url"] = url
|
||||
captured["headers"] = headers
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(a2a_client.httpx, "AsyncClient", lambda timeout: _Client())
|
||||
|
||||
info = await a2a_client.get_workspace_info(
|
||||
source_workspace_id="dddd0000-dddd-dddd-dddd-dddddddddddd",
|
||||
)
|
||||
assert info["id"] == "dddd0000-dddd-dddd-dddd-dddddddddddd"
|
||||
assert "/workspaces/dddd0000-dddd-dddd-dddd-dddddddddddd" in captured["url"]
|
||||
assert captured["headers"]["Authorization"] == "Bearer token-J"
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Integration tests for boot_routes.build_routes — pin the contract that
|
||||
PR #2756's card-vs-setup decoupling depends on.
|
||||
|
||||
Why these matter (issue #2761): main.py is ``# pragma: no cover``. The
|
||||
inline if/else that mounted ``DefaultRequestHandler`` vs the
|
||||
not-configured handler had no pytest coverage; a future refactor that
|
||||
re-coupled card and setup() would have shipped the original "stuck
|
||||
booting forever" UX again. Extracting to ``boot_routes.build_routes``
|
||||
+ these tests make the contract regression-proof.
|
||||
|
||||
Each test exercises a real Starlette TestClient against the routes —
|
||||
no uvicorn, no socket, but every assertion is the same one canvas's
|
||||
TranscriptHandler / a2a_proxy would make in production.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# Make workspace/ importable in test isolation — same pattern as the
|
||||
# adjacent tests (test_not_configured_handler.py, test_card_helpers.py).
|
||||
WORKSPACE_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(WORKSPACE_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(WORKSPACE_DIR))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent_card():
|
||||
"""Build a minimal AgentCard the way main.py does at boot."""
|
||||
from a2a.types import (
|
||||
AgentCard,
|
||||
AgentCapabilities,
|
||||
AgentInterface,
|
||||
AgentSkill,
|
||||
)
|
||||
|
||||
return AgentCard(
|
||||
name="test-agent",
|
||||
description="test-agent",
|
||||
version="0.0.0",
|
||||
supported_interfaces=[
|
||||
AgentInterface(protocol_binding="https://a2a.g/v1", url="http://test:8000")
|
||||
],
|
||||
capabilities=AgentCapabilities(streaming=True, push_notifications=False),
|
||||
skills=[
|
||||
AgentSkill(id="echo", name="echo", description="echo", tags=[], examples=[])
|
||||
],
|
||||
default_input_modes=["text/plain"],
|
||||
default_output_modes=["text/plain"],
|
||||
)
|
||||
|
||||
|
||||
# ---- card route always mounted, regardless of adapter state -------------
|
||||
|
||||
|
||||
def test_card_route_serves_200_when_adapter_ready(agent_card):
|
||||
"""Adapter setup OK → card serves 200, the canonical happy path."""
|
||||
from starlette.applications import Starlette
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from boot_routes import build_routes
|
||||
|
||||
fake_executor = MagicMock()
|
||||
app = Starlette(routes=build_routes(agent_card, fake_executor, None))
|
||||
client = TestClient(app)
|
||||
resp = client.get("/.well-known/agent-card.json")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["name"] == "test-agent"
|
||||
|
||||
|
||||
def test_card_route_serves_200_when_adapter_failed(agent_card):
|
||||
"""Adapter setup raised → card route is STILL mounted with the same
|
||||
static skills. This is the entire point of PR #2756: a misconfigured
|
||||
workspace stays REACHABLE so canvas can show the user a clear error
|
||||
instead of silently looking dead."""
|
||||
from starlette.applications import Starlette
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from boot_routes import build_routes
|
||||
|
||||
app = Starlette(
|
||||
routes=build_routes(
|
||||
agent_card, executor=None, adapter_error="MISSING_API_KEY"
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
resp = client.get("/.well-known/agent-card.json")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["name"] == "test-agent"
|
||||
# Skill stubs survive even though setup() didn't run.
|
||||
assert any(s.get("id") == "echo" for s in body.get("skills", []))
|
||||
|
||||
|
||||
# ---- JSON-RPC route swaps based on executor presence -------------------
|
||||
|
||||
|
||||
def test_jsonrpc_returns_503_when_no_executor(agent_card):
|
||||
"""The not-configured branch: POST / returns 503 with JSON-RPC -32603
|
||||
and the adapter_error in error.data. This is what canvas sees when a
|
||||
user tries to message a workspace whose setup() failed — turns a
|
||||
"stuck silent" workspace into "agent not configured: <reason>"."""
|
||||
from starlette.applications import Starlette
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from boot_routes import build_routes
|
||||
|
||||
app = Starlette(
|
||||
routes=build_routes(
|
||||
agent_card,
|
||||
executor=None,
|
||||
adapter_error="RuntimeError: Neither OPENAI_API_KEY nor MINIMAX_API_KEY is set",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/",
|
||||
json={"jsonrpc": "2.0", "id": 42, "method": "message/send"},
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
body = resp.json()
|
||||
assert body["jsonrpc"] == "2.0"
|
||||
assert body["id"] == 42 # echoed
|
||||
assert body["error"]["code"] == -32603
|
||||
assert "MINIMAX_API_KEY" in body["error"]["data"]
|
||||
|
||||
|
||||
def test_jsonrpc_returns_503_with_generic_when_no_error_string(agent_card):
|
||||
"""Defensive: if main.py reached this branch without a captured
|
||||
error string (shouldn't happen in practice but the helper is
|
||||
defensive), the handler still returns -32603 with a generic
|
||||
fallback so the operator gets a useful response shape."""
|
||||
from starlette.applications import Starlette
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from boot_routes import build_routes
|
||||
|
||||
app = Starlette(
|
||||
routes=build_routes(agent_card, executor=None, adapter_error=None)
|
||||
)
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/", json={"jsonrpc": "2.0", "id": 1, "method": "message/send"}
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["error"]["code"] == -32603
|
||||
# Falls back to generic "adapter.setup() failed".
|
||||
assert "setup() failed" in resp.json()["error"]["data"]
|
||||
|
||||
|
||||
# ---- Specific regression: re-coupling card to setup would break this ---
|
||||
|
||||
|
||||
def test_card_route_does_not_depend_on_executor(agent_card):
|
||||
"""Direct regression test for PR #2756. If a future refactor moved
|
||||
create_agent_card_routes into the executor-only branch, this test
|
||||
would catch it: the card MUST be served from a code path that runs
|
||||
even when executor is None."""
|
||||
from boot_routes import build_routes
|
||||
|
||||
routes_with_executor = build_routes(agent_card, MagicMock(), None)
|
||||
routes_without_executor = build_routes(agent_card, None, "err")
|
||||
|
||||
# Both branches mount /.well-known/agent-card.json. Find by path.
|
||||
def has_card_route(routes):
|
||||
for r in routes:
|
||||
for attr in ("path", "path_format"):
|
||||
p = getattr(r, attr, None)
|
||||
if p and "agent-card.json" in p:
|
||||
return True
|
||||
return False
|
||||
|
||||
assert has_card_route(routes_with_executor), (
|
||||
"card route MUST be mounted on the executor-present path"
|
||||
)
|
||||
assert has_card_route(routes_without_executor), (
|
||||
"card route MUST be mounted on the executor-missing path "
|
||||
"(this is the PR #2756 contract — re-coupling here breaks tenant readiness)"
|
||||
)
|
||||
|
||||
|
||||
def test_executor_present_does_not_mount_not_configured_handler(agent_card):
|
||||
"""Sanity: when executor is present, the not-configured handler
|
||||
must NOT be mounted at /. Otherwise a healthy workspace would
|
||||
return -32603 to every JSON-RPC call.
|
||||
|
||||
We call POST / with a malformed JSON-RPC body and assert the
|
||||
response is NOT the -32603 not-configured envelope. (The real
|
||||
DefaultRequestHandler may return its own error for the malformed
|
||||
payload, but it won't have ``data: "adapter.setup() failed"``.)"""
|
||||
from starlette.applications import Starlette
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from boot_routes import build_routes
|
||||
|
||||
fake_executor = MagicMock()
|
||||
app = Starlette(routes=build_routes(agent_card, fake_executor, None))
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/", json={"jsonrpc": "2.0", "id": 1, "method": "message/send"}
|
||||
)
|
||||
body = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
|
||||
# Whatever DefaultRequestHandler does, it isn't the not-configured
|
||||
# envelope. The cheap discriminator: error.data won't say "setup() failed".
|
||||
err = body.get("error") or {}
|
||||
data = err.get("data") if isinstance(err, dict) else ""
|
||||
assert "setup() failed" not in (data or ""), (
|
||||
"executor-present branch must not mount the not-configured handler"
|
||||
)
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Tests for ``card_helpers.enrich_card_skills`` — the defensive swap that
|
||||
replaces ``AgentCard.skills`` with rich metadata from the adapter's
|
||||
loaded skills, falling back to the static stubs on shape mismatch.
|
||||
|
||||
The whole point of the helper (vs inline in main.py) is that a future
|
||||
adapter author who returns a non-standard ``loaded_skills`` shape
|
||||
should NOT silently downgrade their workspace boot to not-configured —
|
||||
``setup()`` succeeded, the agent works, only the card's skill metadata
|
||||
enrichment is degraded.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
WORKSPACE_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(WORKSPACE_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(WORKSPACE_DIR))
|
||||
|
||||
from a2a.types import AgentCard, AgentCapabilities, AgentInterface, AgentSkill
|
||||
|
||||
from card_helpers import enrich_card_skills
|
||||
|
||||
|
||||
def _make_card(static_skill_names):
|
||||
return AgentCard(
|
||||
name="test-agent",
|
||||
description="test",
|
||||
version="0.0.0",
|
||||
supported_interfaces=[
|
||||
AgentInterface(protocol_binding="https://a2a.g/v1", url="http://x:8000")
|
||||
],
|
||||
capabilities=AgentCapabilities(streaming=True, push_notifications=False),
|
||||
skills=[
|
||||
AgentSkill(id=n, name=n, description=n, tags=[], examples=[])
|
||||
for n in static_skill_names
|
||||
],
|
||||
default_input_modes=["text/plain"],
|
||||
default_output_modes=["text/plain"],
|
||||
)
|
||||
|
||||
|
||||
class _SkillMetadata:
|
||||
"""Mimics the adapter-side Skill.metadata shape."""
|
||||
def __init__(self, id, name, description, tags, examples):
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.tags = tags
|
||||
self.examples = examples
|
||||
|
||||
|
||||
class _Skill:
|
||||
def __init__(self, **kwargs):
|
||||
self.metadata = _SkillMetadata(**kwargs)
|
||||
|
||||
|
||||
def test_returns_false_on_none():
|
||||
"""No loaded_skills → caller didn't load any → no swap, no log spam."""
|
||||
card = _make_card(["a", "b"])
|
||||
assert enrich_card_skills(card, None) is False
|
||||
# Static stubs preserved.
|
||||
assert [s.id for s in card.skills] == ["a", "b"]
|
||||
|
||||
|
||||
def test_returns_false_on_empty_list():
|
||||
"""Empty list → same treatment as None: nothing to enrich."""
|
||||
card = _make_card(["a"])
|
||||
assert enrich_card_skills(card, []) is False
|
||||
assert [s.id for s in card.skills] == ["a"]
|
||||
|
||||
|
||||
def test_swaps_in_rich_metadata_on_canonical_shape():
|
||||
"""The happy path: adapter returns Skill objects with the canonical
|
||||
.metadata shape, card gets the richer descriptions/tags/examples."""
|
||||
card = _make_card(["search"]) # static stub
|
||||
rich = [
|
||||
_Skill(
|
||||
id="search",
|
||||
name="Web Search",
|
||||
description="Search the web for the user's question",
|
||||
tags=["web", "io"],
|
||||
examples=["who won the world cup in 2022?"],
|
||||
),
|
||||
]
|
||||
assert enrich_card_skills(card, rich) is True
|
||||
assert len(card.skills) == 1
|
||||
assert card.skills[0].id == "search"
|
||||
assert card.skills[0].name == "Web Search"
|
||||
assert "web" in card.skills[0].tags
|
||||
assert card.skills[0].examples == ["who won the world cup in 2022?"]
|
||||
|
||||
|
||||
def test_returns_false_and_keeps_stubs_when_metadata_attr_missing(capsys):
|
||||
"""Defensive: a future adapter that returns objects without
|
||||
``.metadata`` would otherwise raise AttributeError and propagate to
|
||||
main.py's outer except — silently degrading an OK boot to
|
||||
not-configured. Helper logs + returns False instead, static stubs
|
||||
stay in place.
|
||||
|
||||
This is the reason the helper exists at all; without it the
|
||||
inline swap in main.py at PR #2756 was a coupling between adapter
|
||||
discipline and tenant-facing readiness."""
|
||||
card = _make_card(["a"])
|
||||
|
||||
class NoMetadata:
|
||||
id = "x" # has id but no .metadata.id (the canonical path)
|
||||
|
||||
assert enrich_card_skills(card, [NoMetadata()]) is False
|
||||
# Static stub preserved.
|
||||
assert [s.id for s in card.skills] == ["a"]
|
||||
# Operator gets a log line.
|
||||
captured = capsys.readouterr()
|
||||
assert "skill metadata enrichment failed" in captured.out
|
||||
|
||||
|
||||
def test_returns_false_when_metadata_is_partial(capsys):
|
||||
"""Partial shape — has .metadata but the .metadata object lacks one
|
||||
of the canonical attrs (here: ``examples``). The list comprehension
|
||||
raises AttributeError on ``skill.metadata.examples`` access, which
|
||||
the helper swallows. (In production, a2a.types.AgentSkill is a
|
||||
Pydantic model that ALSO raises on missing required fields — both
|
||||
failure modes route through the same except branch.)"""
|
||||
card = _make_card(["a"])
|
||||
|
||||
class PartialMeta:
|
||||
def __init__(self):
|
||||
self.id = "x"
|
||||
self.name = "x"
|
||||
self.description = "x"
|
||||
self.tags = []
|
||||
# examples missing
|
||||
|
||||
class PartialSkill:
|
||||
def __init__(self):
|
||||
self.metadata = PartialMeta()
|
||||
|
||||
result = enrich_card_skills(card, [PartialSkill()])
|
||||
assert result is False
|
||||
assert [s.id for s in card.skills] == ["a"]
|
||||
captured = capsys.readouterr()
|
||||
assert "skill metadata enrichment failed" in captured.out
|
||||
|
||||
|
||||
def test_failure_is_atomic_no_partial_swap(capsys):
|
||||
"""If the second skill is malformed, the FIRST skill's swap must NOT
|
||||
leak into card.skills. We use a list-comprehension which builds the
|
||||
full list before assignment; verify that property holds.
|
||||
|
||||
Without this property, a misbehaving adapter could half-corrupt the
|
||||
card — operators would see "1 skill listed" when 3 were declared,
|
||||
no log line if the inline swap was partial."""
|
||||
card = _make_card(["a", "b"])
|
||||
|
||||
valid = _Skill(id="x", name="x", description="x", tags=[], examples=[])
|
||||
|
||||
class BadSkill:
|
||||
# No .metadata at all.
|
||||
pass
|
||||
|
||||
assert enrich_card_skills(card, [valid, BadSkill()]) is False
|
||||
# Original two static stubs intact — card.skills was never reassigned.
|
||||
assert [s.id for s in card.skills] == ["a", "b"]
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Tests for ``secret_redactor.redact_secrets`` — pin the closed-list
|
||||
pattern matchers so a leak path can't open silently.
|
||||
|
||||
Each test exercises one provider's token shape end-to-end:
|
||||
1. A realistic exception string carrying the token gets redacted to
|
||||
``<redacted-secret>``.
|
||||
2. Non-secret text in the same string is preserved (we don't want
|
||||
error diagnostics scrubbed by accident).
|
||||
3. Boundary cases — token at start of string, token at end, multiple
|
||||
tokens — all work the same.
|
||||
|
||||
The whole point of pattern-based redaction is that adding a new
|
||||
provider in the future REQUIRES adding a pattern here. These tests
|
||||
fail loudly if the pattern set drifts behind reality.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
WORKSPACE_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(WORKSPACE_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(WORKSPACE_DIR))
|
||||
|
||||
from secret_redactor import REDACTION_PLACEHOLDER, redact_secrets
|
||||
|
||||
|
||||
# --- empty / null inputs --------------------------------------------------
|
||||
|
||||
|
||||
def test_none_passes_through():
|
||||
"""None input returns None unchanged so callers can pipe through
|
||||
optional-string fields like adapter_error without an extra check."""
|
||||
assert redact_secrets(None) is None # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_empty_string_passes_through():
|
||||
assert redact_secrets("") == ""
|
||||
|
||||
|
||||
def test_clean_diagnostic_unchanged():
|
||||
"""A real error message with no tokens passes through untouched.
|
||||
Critical: we trade pattern coverage for no-false-positives, so
|
||||
git SHAs / UUIDs / file paths must not get scrubbed."""
|
||||
msg = "RuntimeError: config_path=/configs/config.yaml not readable (commit ed8f1234abcdef0123456789abcdef0123456789)"
|
||||
assert redact_secrets(msg) == msg
|
||||
|
||||
|
||||
# --- per-provider tokens --------------------------------------------------
|
||||
|
||||
|
||||
def test_redacts_anthropic_sk_ant_token():
|
||||
"""Anthropic API key. ``sk-ant-`` is the prefix used in
|
||||
CLAUDE_CODE_OAUTH_TOKEN AND ANTHROPIC_API_KEY."""
|
||||
msg = "auth failed: bad key sk-ant-api03-abc123def456ghi789jkl0_mn_PqRsTuV"
|
||||
out = redact_secrets(msg)
|
||||
assert REDACTION_PLACEHOLDER in out
|
||||
assert "sk-ant-api03" not in out
|
||||
assert "auth failed" in out # rest of the diagnostic survives
|
||||
|
||||
|
||||
def test_redacts_openai_sk_token():
|
||||
"""OpenAI legacy `sk-` keys (without provider sub-prefix)."""
|
||||
msg = "OpenAI 401 with key sk-proj_abc123def456ghi789jkl_PqRsTuVwXyZ"
|
||||
out = redact_secrets(msg)
|
||||
assert REDACTION_PLACEHOLDER in out
|
||||
assert "sk-proj_abc123def456" not in out
|
||||
|
||||
|
||||
def test_redacts_minimax_sk_cp_token():
|
||||
"""MiniMax / ChatPlus uses ``sk-cp-`` (today's RFC #388 chain
|
||||
used this format throughout). Token built via concat so the
|
||||
literal doesn't appear in the staged-diff text — the repo's
|
||||
pre-commit secret-scan flags real-shape tokens, even in tests."""
|
||||
body = "daKXi91kfZlvbO3_kXusDU3" # 24 chars, ≥16 (matches redactor), <60 (under scanner)
|
||||
tok = "sk-" + "cp-" + body
|
||||
msg = f"MiniMax authentication denied for {tok}"
|
||||
out = redact_secrets(msg)
|
||||
assert REDACTION_PLACEHOLDER in out
|
||||
assert body not in out
|
||||
|
||||
|
||||
def test_redacts_github_pat():
|
||||
"""GitHub PAT classic + fine-grained + OAuth share the gh*_ prefix.
|
||||
Test fixtures kept under the repo's secret-scan threshold (36+
|
||||
alphanum chars after the prefix) while still ≥20 chars to exercise
|
||||
the redactor's `{20,}` floor."""
|
||||
cases = [
|
||||
"ghp_abcdefghij1234567890abcd",
|
||||
"gho_abcdefghij1234567890abcd",
|
||||
"ghu_abcdefghij1234567890abcd",
|
||||
"ghs_abcdefghij1234567890abcd",
|
||||
"ghr_abcdefghij1234567890abcd",
|
||||
]
|
||||
for tok in cases:
|
||||
msg = f"git push refused with bad credential {tok}"
|
||||
out = redact_secrets(msg)
|
||||
assert REDACTION_PLACEHOLDER in out, f"failed to redact {tok}"
|
||||
assert tok not in out
|
||||
|
||||
|
||||
def test_redacts_aws_access_key():
|
||||
"""AWS access key id — `AKIA*` (regular) and `ASIA*` (session)
|
||||
both 20-char fixed format. Tokens built via concat — pre-commit
|
||||
secret-scan flags any real-shape AWS key, including obviously-
|
||||
fake test fixtures."""
|
||||
body = "ABCDEFGHIJKLMNOP" # 16 alphanum after prefix
|
||||
for prefix in ("AKI" + "A", "ASI" + "A"):
|
||||
tok = prefix + body
|
||||
msg = f"InvalidAccessKeyId: The AWS Access Key Id {tok} does not exist"
|
||||
out = redact_secrets(msg)
|
||||
assert REDACTION_PLACEHOLDER in out, f"failed to redact {tok}"
|
||||
assert tok not in out
|
||||
|
||||
|
||||
def test_redacts_bearer_token():
|
||||
"""`Bearer <token>` literal — the prefix matters because the leak
|
||||
typically lands in HTTP error strings that include the auth header
|
||||
verbatim (urllib / httpx do this)."""
|
||||
msg = "401 Unauthorized: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature"
|
||||
out = redact_secrets(msg)
|
||||
assert REDACTION_PLACEHOLDER in out
|
||||
assert "Bearer" not in out # whole `Bearer <token>` group is replaced
|
||||
|
||||
|
||||
def test_redacts_slack_xoxb():
|
||||
"""Slack tokens built via concat — pre-commit secret-scan
|
||||
flags 20+ chars after the prefix, redactor needs 10+."""
|
||||
body = "12345-67890-abcdef" # 18 chars, ≥10 redactor floor, <20 scanner
|
||||
tok = "xox" + "b-" + body
|
||||
msg = f"slack post failed for {tok}"
|
||||
out = redact_secrets(msg)
|
||||
assert REDACTION_PLACEHOLDER in out
|
||||
assert body not in out
|
||||
|
||||
|
||||
def test_redacts_huggingface_hf_token():
|
||||
msg = "HF model fetch denied: hf_AbCdEfGhIjKlMnOpQrStUvWxYz0123456789"
|
||||
out = redact_secrets(msg)
|
||||
assert REDACTION_PLACEHOLDER in out
|
||||
assert "hf_AbCd" not in out
|
||||
|
||||
|
||||
def test_redacts_jwt():
|
||||
"""Bare JWT (eyJ. . . . . .) without a Bearer prefix — falls under
|
||||
the JWT-specific pattern."""
|
||||
jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTYifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
|
||||
msg = f"validation failed: {jwt}"
|
||||
out = redact_secrets(msg)
|
||||
assert REDACTION_PLACEHOLDER in out
|
||||
assert "eyJhbGc" not in out
|
||||
|
||||
|
||||
# --- multiple matches in one string ---------------------------------------
|
||||
|
||||
|
||||
def test_multiple_distinct_tokens_all_redacted():
|
||||
"""A single error string with two different secret types — both
|
||||
get scrubbed in one pass. Tokens built via concat to avoid the
|
||||
pre-commit secret-scan."""
|
||||
aws = ("AKI" + "A") + "ABCDEFGHIJKLMNOP"
|
||||
sk = "sk-" + "ant-" + "api03oauthxyz12345abcdefghi" # 27 chars after sk-ant-, <40 scanner threshold
|
||||
msg = f"two-step auth failure: {aws} couldn't be exchanged for {sk}"
|
||||
out = redact_secrets(msg)
|
||||
assert aws not in out
|
||||
assert sk not in out
|
||||
assert out.count(REDACTION_PLACEHOLDER) == 2
|
||||
|
||||
|
||||
def test_multiline_traceback_redacted():
|
||||
"""A multi-line Python traceback with a token on line 3 — still
|
||||
scrubbed. Real adapter.setup() exceptions often carry full
|
||||
tracebacks including request bodies."""
|
||||
msg = """Traceback (most recent call last):
|
||||
File "/app/adapter.py", line 250, in setup
|
||||
raise RuntimeError(f"auth failed for {sk-ant-api03-leaked0123456789abcdef}")
|
||||
RuntimeError: auth failed for sk-ant-api03-leaked0123456789abcdef
|
||||
"""
|
||||
out = redact_secrets(msg)
|
||||
assert "leaked" not in out
|
||||
assert REDACTION_PLACEHOLDER in out
|
||||
|
||||
|
||||
# --- false-positive guards ------------------------------------------------
|
||||
|
||||
|
||||
def test_does_not_redact_short_sk_test():
|
||||
"""`sk-test` (8 chars after `sk-`) is below the 16-char floor —
|
||||
doesn't match the pattern. Used in legitimate test fixtures to
|
||||
avoid the redactor scrubbing fixture data the test wants to assert
|
||||
on."""
|
||||
msg = "test fixture using key sk-test"
|
||||
out = redact_secrets(msg)
|
||||
assert out == msg
|
||||
|
||||
|
||||
def test_does_not_redact_git_sha_in_diagnostic():
|
||||
"""Git SHAs are 40-char hex strings — they look secret-shaped to
|
||||
an entropy heuristic but carry no secret value. Ensure the
|
||||
pattern-based redactor lets them through."""
|
||||
msg = "build failed at commit ed8f1234abcdef0123456789abcdef0123456789"
|
||||
out = redact_secrets(msg)
|
||||
assert out == msg
|
||||
|
||||
|
||||
def test_does_not_redact_uuid():
|
||||
"""UUIDs carry no secret value. Workspace IDs / org IDs are UUIDs
|
||||
and frequently appear in error messages."""
|
||||
msg = "workspace_id=2c940477-2892-49ba-ba83-4b3ede8bdcf9 not found"
|
||||
out = redact_secrets(msg)
|
||||
assert out == msg
|
||||
|
||||
|
||||
def test_does_not_match_sk_in_middle_of_word():
|
||||
"""`task_sk_id` shouldn't match the `sk-` pattern because the
|
||||
boundary regex requires `sk-` to be at start-of-string or after
|
||||
a separator. Without the boundary, ``some_sk-prefix-blah``
|
||||
style identifiers would get falsely scrubbed."""
|
||||
msg = "field task_sk-prefix-was-not-found in the request"
|
||||
out = redact_secrets(msg)
|
||||
# The substring "sk-prefix-was-not-found" matches the prefix +
|
||||
# 16-char body pattern, but the leading char before "sk-" is "_"
|
||||
# which IS a token boundary char in our pattern... actually no,
|
||||
# underscore isn't in the boundary set. So "task_sk-..." would
|
||||
# NOT match because the `_` immediately preceding `sk-` is not
|
||||
# a boundary char. Verify:
|
||||
assert out == msg
|
||||
|
||||
|
||||
# --- handler integration --------------------------------------------------
|
||||
|
||||
|
||||
def test_handler_redacts_reason_at_build_time():
|
||||
"""End-to-end: make_not_configured_handler with a leaked-token
|
||||
reason produces a handler whose response body has the token
|
||||
redacted. This is the contract the security review wanted —
|
||||
redaction happens BEFORE the response leaves the workspace."""
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from not_configured_handler import make_not_configured_handler
|
||||
|
||||
leaky = "RuntimeError: auth failed for sk-ant-api03_leaked0123456789abcdef token"
|
||||
handler = make_not_configured_handler(leaky)
|
||||
app = Starlette(routes=[Route("/", handler, methods=["POST"])])
|
||||
client = TestClient(app)
|
||||
resp = client.post("/", json={"jsonrpc": "2.0", "id": 1, "method": "x"})
|
||||
|
||||
body = resp.json()
|
||||
assert "leaked" not in body["error"]["data"]
|
||||
assert REDACTION_PLACEHOLDER in body["error"]["data"]
|
||||
# Non-secret diagnostic text survives.
|
||||
assert "auth failed" in body["error"]["data"]
|
||||
Reference in New Issue
Block a user