diff --git a/.opencode/plugin/cas-bridge-routing.ts b/.opencode/plugin/cas-bridge-routing.ts index 1acf3f6ae14c..4443e7de0a3f 100644 --- a/.opencode/plugin/cas-bridge-routing.ts +++ b/.opencode/plugin/cas-bridge-routing.ts @@ -62,6 +62,10 @@ Opens a browser; on success tokens are stored under OpenCode's MCP auth store. R 3. \`cas_select_agent\` — save their choice 4. \`cas_safe_delegate\` — run a short business task +**Still available without CAS OAuth:** +- \`cas_pipeline_status\` — public CAS + Synapse \`/health\` (no secrets) +- \`synapse_probe\` — live gateway routing headers (needs \`SYNAPSE_API_KEY\` / \`GPAAS_API_KEY\`; small paid call) + Continue with local agents + other MCPs until connected. `.trim() diff --git a/.opencode/tool/cas-auth-status.ts b/.opencode/tool/cas-auth-status.ts index 917af27b15fe..3d5f64733433 100644 --- a/.opencode/tool/cas-auth-status.ts +++ b/.opencode/tool/cas-auth-status.ts @@ -23,10 +23,13 @@ export default tool({ " opencode mcp auth alterspective-agent", "That opens a browser login, stores tokens under OpenCode MCP auth, then restart the session.", "Optional override: set env CAS_MCP_TOKEN to a bearer (not preferred).", + "Without CAS OAuth you can still use cas_pipeline_status and synapse_probe (live=false free).", ].join("\n") - : source === "oauth" - ? "Connected via OpenCode MCP OAuth. Use cas_list_agents (or cas_safe_list_agents) then cas_select_agent." - : "Connected via CAS_MCP_TOKEN env. Prefer OAuth: opencode mcp auth alterspective-agent", + : source === "oauth-expired" + ? "OAuth token expired. Re-run: opencode mcp auth alterspective-agent" + : source === "oauth" + ? "Connected via OpenCode MCP OAuth. Use cas_safe_list_agents then cas_select_agent." + : "Connected via CAS_MCP_TOKEN env. Prefer OAuth: opencode mcp auth alterspective-agent", ] return lines.join("\n") }, diff --git a/.opencode/tool/cas-bridge-lib.ts b/.opencode/tool/cas-bridge-lib.ts index 54c405b02ece..4bd9ad917246 100644 --- a/.opencode/tool/cas-bridge-lib.ts +++ b/.opencode/tool/cas-bridge-lib.ts @@ -24,17 +24,48 @@ export const RUN_STATUSES = [ export type RunStatus = (typeof RUN_STATUSES)[number] +/** Network timeouts for bridge outbound calls (ms). */ +export const FETCH_TIMEOUT_HEALTH_MS = 5_000 +export const FETCH_TIMEOUT_MCP_MS = 20_000 +export const FETCH_TIMEOUT_REST_MS = 20_000 +export const FETCH_TIMEOUT_PROBE_MS = 15_000 + /** True when a non-empty CAS bearer is available (env or OpenCode OAuth store). */ export function casTokenPresent(): boolean { return Boolean(resolveCasAccessToken()) } -export function casAuthSource(): "env" | "oauth" | "none" { +export type CasAuthSource = "env" | "oauth" | "oauth-expired" | "none" + +/** + * Credential provenance for operator-facing status. + * `oauth-expired` = tokens present but past expiresAt (re-auth needed). + */ +export function casAuthSource(): CasAuthSource { if (process.env.CAS_MCP_TOKEN?.trim()) return "env" - if (readMcpAuthAccessToken(CAS_MCP_SERVER, CAS_MCP_URL)) return "oauth" + const oauth = readMcpAuthAccessTokenDetailed(CAS_MCP_SERVER, CAS_MCP_URL) + if (oauth.token) return "oauth" + if (oauth.expired) return "oauth-expired" return "none" } +/** Shared OAuth-first message when CAS tools cannot run. */ +export function notConnectedMessage(hint?: string): string { + const source = casAuthSource() + const tail = hint ? ` ${hint}` : "" + if (source === "oauth-expired") { + return ( + "CAS OAuth token expired. Re-authenticate: opencode mcp auth alterspective-agent — then retry." + + tail + ) + } + return ( + "Not connected to CAS. Run: opencode mcp auth alterspective-agent — then retry." + + " (Optional override: env CAS_MCP_TOKEN.)" + + tail + ) +} + /** Prefer env override, else OpenCode MCP OAuth tokens for alterspective-agent. */ export function resolveCasAccessToken(): string | undefined { const envToken = process.env.CAS_MCP_TOKEN?.trim() @@ -42,6 +73,26 @@ export function resolveCasAccessToken(): string | undefined { return readMcpAuthAccessToken(CAS_MCP_SERVER, CAS_MCP_URL) } +/** fetch with AbortController timeout; never logs credentials. */ +export async function fetchWithTimeout( + url: string, + init: RequestInit | undefined, + timeoutMs: number, +): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + return await fetch(url, { ...init, signal: controller.signal }) + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + throw new Error(`Request timed out after ${timeoutMs} ms: ${url.replace(/\?.*$/, "")}`) + } + throw err + } finally { + clearTimeout(timer) + } +} + export const DEFAULT_AGENT_ALLOWLIST = [ "drafter", "matter-audit", @@ -207,16 +258,27 @@ type McpAuthEntry = { } export function readMcpAuthAccessToken(mcpName: string, serverUrl: string): string | undefined { + return readMcpAuthAccessTokenDetailed(mcpName, serverUrl).token +} + +export function readMcpAuthAccessTokenDetailed( + mcpName: string, + serverUrl: string, +): { token?: string; expired: boolean } { try { const raw = fs.readFileSync(mcpAuthPath(), "utf8") const data = JSON.parse(raw) as Record const entry = data[mcpName] - if (!entry?.tokens?.accessToken) return undefined - if (entry.serverUrl && normalizeUrl(entry.serverUrl) !== normalizeUrl(serverUrl)) return undefined - if (entry.tokens.expiresAt && entry.tokens.expiresAt * 1000 < Date.now()) return undefined - return entry.tokens.accessToken + if (!entry?.tokens?.accessToken) return { expired: false } + if (entry.serverUrl && normalizeUrl(entry.serverUrl) !== normalizeUrl(serverUrl)) { + return { expired: false } + } + if (entry.tokens.expiresAt && entry.tokens.expiresAt * 1000 < Date.now()) { + return { expired: true } + } + return { token: entry.tokens.accessToken, expired: false } } catch { - return undefined + return { expired: false } } } @@ -250,11 +312,15 @@ export async function callCasMcpTool(name: string, args: Record }, } - const initRes = await fetch(CAS_MCP_URL, { - method: "POST", - headers, - body: JSON.stringify(initBody), - }) + const initRes = await fetchWithTimeout( + CAS_MCP_URL, + { + method: "POST", + headers, + body: JSON.stringify(initBody), + }, + FETCH_TIMEOUT_MCP_MS, + ) if (!initRes.ok) { if (initRes.status === 401 || initRes.status === 403) { @@ -268,22 +334,30 @@ export async function callCasMcpTool(name: string, args: Record const sessionId = initRes.headers.get("mcp-session-id") ?? initRes.headers.get("Mcp-Session-Id") if (sessionId) headers["mcp-session-id"] = sessionId - await fetch(CAS_MCP_URL, { - method: "POST", - headers, - body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }), - }).catch(() => undefined) - - const callRes = await fetch(CAS_MCP_URL, { - method: "POST", - headers, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { name, arguments: args }, - }), - }) + await fetchWithTimeout( + CAS_MCP_URL, + { + method: "POST", + headers, + body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }), + }, + FETCH_TIMEOUT_MCP_MS, + ).catch(() => undefined) + + const callRes = await fetchWithTimeout( + CAS_MCP_URL, + { + method: "POST", + headers, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name, arguments: args }, + }), + }, + FETCH_TIMEOUT_MCP_MS, + ) if (!callRes.ok) { if (callRes.status === 401 || callRes.status === 403) { @@ -352,13 +426,17 @@ export async function callCasRest( } } - const res = await fetch(url.toString(), { - method: init?.method ?? "GET", - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/json", + const res = await fetchWithTimeout( + url.toString(), + { + method: init?.method ?? "GET", + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + }, }, - }) + FETCH_TIMEOUT_REST_MS, + ) const text = await res.text() if (!res.ok) { @@ -403,7 +481,11 @@ export type CasHealthSnapshot = { } export async function fetchCasHealth(): Promise { - const res = await fetch(CAS_HEALTH_URL, { headers: { Accept: "application/json" } }) + const res = await fetchWithTimeout( + CAS_HEALTH_URL, + { headers: { Accept: "application/json" } }, + FETCH_TIMEOUT_HEALTH_MS, + ) if (!res.ok) throw new Error(`CAS /health failed: HTTP ${res.status}`) return (await res.json()) as CasHealthSnapshot } @@ -417,18 +499,21 @@ export type SynapseHealthSnapshot = { } export async function fetchSynapseHealth(): Promise { - const res = await fetch(SYNAPSE_HEALTH_URL, { headers: { Accept: "application/json" } }) + const res = await fetchWithTimeout( + SYNAPSE_HEALTH_URL, + { headers: { Accept: "application/json" } }, + FETCH_TIMEOUT_HEALTH_MS, + ) if (!res.ok) throw new Error(`Synapse /health failed: HTTP ${res.status}`) return (await res.json()) as SynapseHealthSnapshot } -/** Prefer dedicated Synapse keys; never return CAS OAuth tokens here. */ +/** + * Prefer dedicated Synapse inference keys for chat/completions probes. + * Do not use SYNAPSE_MCP_BEARER_TOKEN here — that is MCP-scoped, not chat. + */ export function resolveSynapseApiKey(): string | undefined { - const candidates = [ - process.env.SYNAPSE_API_KEY, - process.env.GPAAS_API_KEY, - process.env.SYNAPSE_MCP_BEARER_TOKEN, - ] + const candidates = [process.env.SYNAPSE_API_KEY, process.env.GPAAS_API_KEY] for (const c of candidates) { const t = c?.trim() if (t) return t @@ -440,10 +525,9 @@ export function synapseKeyPresent(): boolean { return Boolean(resolveSynapseApiKey()) } -export function synapseKeySource(): "SYNAPSE_API_KEY" | "GPAAS_API_KEY" | "SYNAPSE_MCP_BEARER_TOKEN" | "none" { +export function synapseKeySource(): "SYNAPSE_API_KEY" | "GPAAS_API_KEY" | "none" { if (process.env.SYNAPSE_API_KEY?.trim()) return "SYNAPSE_API_KEY" if (process.env.GPAAS_API_KEY?.trim()) return "GPAAS_API_KEY" - if (process.env.SYNAPSE_MCP_BEARER_TOKEN?.trim()) return "SYNAPSE_MCP_BEARER_TOKEN" return "none" } @@ -530,15 +614,19 @@ export async function probeSynapseChat(input: SynapseProbeInput = {}): Promise import { tool } from "@opencode-ai/plugin" -import { callCasMcpTool, casTokenPresent, wrapUntrusted } from "./cas-bridge-lib" +import { callCasMcpTool, casTokenPresent, notConnectedMessage, redactSecrets, wrapUntrusted } from "./cas-bridge-lib" export default tool({ - description: `Cancel an in-flight CAS run by id. Fails closed without CAS_MCP_TOKEN.`, + description: `Cancel an in-flight CAS run by id. Requires CAS OAuth (opencode mcp auth alterspective-agent) or optional CAS_MCP_TOKEN.`, args: { runId: tool.schema.string().describe("CAS run id"), }, async execute(args) { - if (!casTokenPresent()) { - return "CAS bridge degraded: CAS_MCP_TOKEN is not set." - } + if (!casTokenPresent()) return notConnectedMessage() const runId = args.runId.trim() if (!runId || runId.length > 128) return "cas_safe_cancel_run refused: invalid runId" try { @@ -18,7 +16,7 @@ export default tool({ return wrapUntrusted("cas_cancel_run", result) } catch (err) { const message = err instanceof Error ? err.message : String(err) - return `cas_safe_cancel_run error: ${message.replace(/Bearer\s+\S+/gi, "Bearer [redacted]")}` + return `cas_safe_cancel_run error: ${redactSecrets(message)}` } }, }) diff --git a/.opencode/tool/cas-safe-delegate.ts b/.opencode/tool/cas-safe-delegate.ts index cbd408008257..8576b5b3ad62 100644 --- a/.opencode/tool/cas-safe-delegate.ts +++ b/.opencode/tool/cas-safe-delegate.ts @@ -3,7 +3,9 @@ import { tool } from "@opencode-ai/plugin" import { callCasMcpTool, casTokenPresent, + notConnectedMessage, readSelection, + redactSecrets, validateDelegate, wrapUntrusted, } from "./cas-bridge-lib" @@ -20,9 +22,7 @@ export default tool({ background: tool.schema.boolean().optional().describe("If true, enqueue and return run handle immediately"), }, async execute(args, ctx) { - if (!casTokenPresent()) { - return "Not connected to CAS. Run: opencode mcp auth alterspective-agent — then retry." - } + if (!casTokenPresent()) return notConnectedMessage() const selected = readSelection(ctx.directory) const agentId = (args.agentId?.trim() || selected?.agentId || "").trim() @@ -49,8 +49,7 @@ export default tool({ return wrapUntrusted("cas_delegate", result) } catch (err) { const message = err instanceof Error ? err.message : String(err) - // Never echo token material - return `cas_safe_delegate error: ${message.replace(/Bearer\s+\S+/gi, "Bearer [redacted]")}` + return `cas_safe_delegate error: ${redactSecrets(message)}` } }, }) diff --git a/.opencode/tool/cas-safe-get-run.ts b/.opencode/tool/cas-safe-get-run.ts index 11d39f6dfdf8..406dac6ea28d 100644 --- a/.opencode/tool/cas-safe-get-run.ts +++ b/.opencode/tool/cas-safe-get-run.ts @@ -1,24 +1,38 @@ /// import { tool } from "@opencode-ai/plugin" -import { callCasMcpTool, casTokenPresent, wrapUntrusted } from "./cas-bridge-lib" +import { + callCasMcpTool, + casTokenPresent, + formatRunInsights, + notConnectedMessage, + parseCasRunPayload, + redactSecrets, + wrapUntrusted, +} from "./cas-bridge-lib" export default tool({ - description: `Get status/outcome of a CAS run by id (after cas_safe_delegate with background=true). Fails closed without CAS_MCP_TOKEN.`, + description: `Raw CAS run JSON by id (after cas_safe_delegate with background=true). Prefer cas_safe_run_insights for human-readable status/tokens/correlationId; use this only when you need the full unformatted payload.`, args: { runId: tool.schema.string().describe("CAS run id"), }, async execute(args) { - if (!casTokenPresent()) { - return "CAS bridge degraded: CAS_MCP_TOKEN is not set." - } + if (!casTokenPresent()) return notConnectedMessage() const runId = args.runId.trim() if (!runId || runId.length > 128) return "cas_safe_get_run refused: invalid runId" try { const result = await callCasMcpTool("cas_get_run", { runId }) + const run = parseCasRunPayload(result) + if (run) { + return ( + formatRunInsights(run) + + "\n\n(Tip: cas_safe_run_insights is the preferred observability tool; raw JSON below if needed.)\n\n" + + wrapUntrusted("cas_get_run", result) + ) + } return wrapUntrusted("cas_get_run", result) } catch (err) { const message = err instanceof Error ? err.message : String(err) - return `cas_safe_get_run error: ${message.replace(/Bearer\s+\S+/gi, "Bearer [redacted]")}` + return `cas_safe_get_run error: ${redactSecrets(message)}` } }, }) diff --git a/.opencode/tool/cas-safe-list-agents.ts b/.opencode/tool/cas-safe-list-agents.ts index ecf5c74e48a9..852dca9a848e 100644 --- a/.opencode/tool/cas-safe-list-agents.ts +++ b/.opencode/tool/cas-safe-list-agents.ts @@ -1,20 +1,20 @@ /// import { tool } from "@opencode-ai/plugin" -import { callCasMcpTool, casTokenPresent, wrapUntrusted } from "./cas-bridge-lib" +import { callCasMcpTool, casTokenPresent, notConnectedMessage, redactSecrets, wrapUntrusted } from "./cas-bridge-lib" export default tool({ - description: `List CAS agents invocable by the current CAS_MCP_TOKEN principal (org-active + own personal). Use before cas_safe_delegate to pick agentId. Fails closed when CAS_MCP_TOKEN is missing.`, + description: `List CAS agents invocable by the signed-in principal (org-active + own personal). Use before cas_safe_delegate to pick agentId. Requires CAS OAuth or optional CAS_MCP_TOKEN.`, args: {}, async execute() { if (!casTokenPresent()) { - return "Not connected to CAS. Run: opencode mcp auth alterspective-agent — then cas_auth_status / cas_safe_list_agents." + return notConnectedMessage("Then cas_auth_status / cas_safe_list_agents.") } try { const result = await callCasMcpTool("cas_list_agents", {}) return wrapUntrusted("cas_list_agents", result) } catch (err) { const message = err instanceof Error ? err.message : String(err) - return `cas_safe_list_agents error: ${message.replace(/Bearer\s+\S+/gi, "Bearer [redacted]")}` + return `cas_safe_list_agents error: ${redactSecrets(message)}` } }, }) diff --git a/.opencode/tool/cas-safe-list-runs.ts b/.opencode/tool/cas-safe-list-runs.ts index 9d085f652f39..d6269b450b31 100644 --- a/.opencode/tool/cas-safe-list-runs.ts +++ b/.opencode/tool/cas-safe-list-runs.ts @@ -4,6 +4,7 @@ import { callCasMcpTool, casTokenPresent, formatRunList, + notConnectedMessage, parseCasRunListPayload, redactSecrets, wrapUntrusted, @@ -21,9 +22,7 @@ export default tool({ limit: tool.schema.number().optional().describe("Max rows (1–100, default 20)"), }, async execute(args) { - if (!casTokenPresent()) { - return "Not connected to CAS. Run: opencode mcp auth alterspective-agent — then retry." - } + if (!casTokenPresent()) return notConnectedMessage() const status = args.status?.trim() if (status && !(RUN_STATUSES as readonly string[]).includes(status)) { diff --git a/.opencode/tool/cas-safe-run-insights.ts b/.opencode/tool/cas-safe-run-insights.ts index 6311c0632e93..a4f3be2d13b6 100644 --- a/.opencode/tool/cas-safe-run-insights.ts +++ b/.opencode/tool/cas-safe-run-insights.ts @@ -4,21 +4,22 @@ import { callCasMcpTool, casTokenPresent, formatRunInsights, + notConnectedMessage, parseCasRunPayload, redactSecrets, wrapUntrusted, } from "./cas-bridge-lib" export default tool({ - description: `Human-readable insights for a CAS run: status, agent/template, loop turns, Synapse token spend (prompt/completion), wall time, correlationId (Langfuse/logs), children. Prefer over raw cas_get_run for observability. Pass includeRaw=true for full JSON.`, + description: `Human-readable insights for a CAS run: status, agent/template, loop turns, Synapse token spend (prompt/completion), wall time, correlationId (Langfuse/logs), children. Prefer this over raw cas_safe_get_run for observability. Pass includeRaw=true for full JSON.`, args: { - runId: tool.schema.string().describe("CAS run id (from cas_safe_delegate background handle or cas_safe_list_runs)"), + runId: tool.schema + .string() + .describe("CAS run id (from cas_safe_delegate background handle or cas_safe_list_runs)"), includeRaw: tool.schema.boolean().optional().describe("If true, append full run JSON under the summary"), }, async execute(args) { - if (!casTokenPresent()) { - return "Not connected to CAS. Run: opencode mcp auth alterspective-agent — then retry." - } + if (!casTokenPresent()) return notConnectedMessage() const runId = args.runId.trim() if (!runId || runId.length > 128) return "cas_safe_run_insights refused: invalid runId" @@ -26,6 +27,7 @@ export default tool({ const result = await callCasMcpTool("cas_get_run", { runId }) const run = parseCasRunPayload(result) if (run) return formatRunInsights(run, { includeRaw: args.includeRaw === true }) + // Unparsed body may contain agent narrative — fence it. return wrapUntrusted("cas_get_run", result) } catch (err) { const message = err instanceof Error ? err.message : String(err) diff --git a/.opencode/tool/cas-safe-run-trace.ts b/.opencode/tool/cas-safe-run-trace.ts index 8f399ee159b4..dac68a001f9b 100644 --- a/.opencode/tool/cas-safe-run-trace.ts +++ b/.opencode/tool/cas-safe-run-trace.ts @@ -4,34 +4,32 @@ import { callCasRest, casTokenPresent, formatRunTrace, + notConnectedMessage, redactSecrets, - wrapUntrusted, MAX_RESPONSE_CHARS, } from "./cas-bridge-lib" export default tool({ - description: `Fetch the durable CAS end-to-end run trace (GET /api/v1/runs/:id/trace): prompt → tool selection → tool calls/results → response. User-scoped (not admin). Shows how CAS processed the request; pair with synapse_probe for gateway routing.`, + description: `Fetch the durable CAS end-to-end run trace (GET /api/v1/runs/:id/trace): prompt → tool selection → tool calls/results → response. User-scoped (not admin). Pair with synapse_probe for gateway routing.`, args: { runId: tool.schema.string().describe("CAS run id"), }, async execute(args) { - if (!casTokenPresent()) { - return "Not connected to CAS. Run: opencode mcp auth alterspective-agent — then retry." - } + if (!casTokenPresent()) return notConnectedMessage() const runId = args.runId.trim() if (!runId || runId.length > 128) return "cas_safe_run_trace refused: invalid runId" try { const payload = await callCasRest(`/api/v1/runs/${encodeURIComponent(runId)}/trace`) + // formatRunTrace is our summariser (not untrusted agent prose) — return clean text. const formatted = formatRunTrace(payload) if (formatted.length > MAX_RESPONSE_CHARS) { - return wrapUntrusted( - "cas_run_trace", + return ( formatted.slice(0, MAX_RESPONSE_CHARS) + - `\n…[truncated ${formatted.length - MAX_RESPONSE_CHARS} chars]`, + `\n…[truncated ${formatted.length - MAX_RESPONSE_CHARS} chars]` ) } - return wrapUntrusted("cas_run_trace", formatted) + return formatted } catch (err) { const message = err instanceof Error ? err.message : String(err) return `cas_safe_run_trace error: ${redactSecrets(message)}` diff --git a/.opencode/tool/cas-select-agent.ts b/.opencode/tool/cas-select-agent.ts index 8f64f8e7b81d..bd49b1bda931 100644 --- a/.opencode/tool/cas-select-agent.ts +++ b/.opencode/tool/cas-select-agent.ts @@ -1,6 +1,6 @@ /// import { tool } from "@opencode-ai/plugin" -import { agentAllowlist, casTokenPresent, writeSelection } from "./cas-bridge-lib" +import { agentAllowlist, casTokenPresent, notConnectedMessage, writeSelection } from "./cas-bridge-lib" export default tool({ description: `Select the preferred CAS agent for this project after the user picks one from the list. Saves agentId to .opencode/cas-selection.json. Later cas_safe_delegate can use this default when agentId is omitted (pass the selected id explicitly if unsure).`, @@ -10,9 +10,7 @@ export default tool({ description: tool.schema.string().optional().describe("Optional description"), }, async execute(args, ctx) { - if (!casTokenPresent()) { - return "Not connected to CAS. Run: opencode mcp auth alterspective-agent — then cas_auth_status." - } + if (!casTokenPresent()) return notConnectedMessage("Then cas_auth_status.") const agentId = args.agentId.trim() if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(agentId)) { return "cas_select_agent refused: agentId must be kebab-case" diff --git a/.opencode/tool/synapse-probe.ts b/.opencode/tool/synapse-probe.ts index d84beea1cce0..dc8f742e7157 100644 --- a/.opencode/tool/synapse-probe.ts +++ b/.opencode/tool/synapse-probe.ts @@ -1,16 +1,23 @@ /// import { tool } from "@opencode-ai/plugin" import { + fetchSynapseHealth, formatSynapseProbe, probeSynapseChat, redactSecrets, synapseKeyPresent, synapseKeySource, + SYNAPSE_API_BASE, + SYNAPSE_DASHBOARD_URL, } from "./cas-bridge-lib" export default tool({ - description: `Probe the Synapse AI gateway (synapse2-api) with a tiny chat completion and report how it processed the request: x-synapse-served-model, rate-limit remaining/limit, latency, token usage, optional routing headers. Requires SYNAPSE_API_KEY or GPAAS_API_KEY. model=auto shows local-vs-cloud routing; pin a model id to verify pins.`, + description: `Probe the Synapse AI gateway. Default live=true runs a tiny paid chat (~10–20 tokens) and reports x-synapse-served-model, rate limits, latency, usage. Pass live=false for free /health only. Requires SYNAPSE_API_KEY or GPAAS_API_KEY for live probes (not CAS OAuth; not SYNAPSE_MCP_BEARER_TOKEN).`, args: { + live: tool.schema + .boolean() + .optional() + .describe("If false, only hit /health (free). Default true = small paid chat completion for routing headers."), model: tool.schema .string() .optional() @@ -28,12 +35,35 @@ export default tool({ .describe("Optional x-privacy-tier: cloud-ok | local-only"), }, async execute(args) { + const live = args.live !== false + + if (!live) { + try { + const h = await fetchSynapseHealth() + return [ + "Synapse health (free — no chat completion)", + `• status: ${h.status ?? "?"}`, + `• version: ${h.version ?? "?"} sha=${h.sha ?? "?"}`, + `• label: ${h.label ?? h.environment ?? "?"}`, + `• API: ${SYNAPSE_API_BASE}`, + `• dashboard: ${SYNAPSE_DASHBOARD_URL}`, + `• inference key for live probe: ${synapseKeySource() === "none" ? "missing" : "present via " + synapseKeySource()}`, + "", + "Pass live=true (default) for x-synapse-served-model + rate limits (small paid call).", + ].join("\n") + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return `synapse_probe health error: ${redactSecrets(message)}` + } + } + if (!synapseKeyPresent()) { return [ - "synapse_probe: no Synapse API key.", + "synapse_probe: no Synapse inference API key for live chat.", "Set SYNAPSE_API_KEY or GPAAS_API_KEY (named gpaas_/gpapp_ PAT from Keystone / vault).", - "CAS OAuth alone is not enough — Synapse uses a different credential than agent.alterspective.com.au.", + "CAS OAuth alone is not enough. SYNAPSE_MCP_BEARER_TOKEN is MCP-scoped and is not used for chat.", `Current key source: ${synapseKeySource()}`, + "Tip: call with live=false for free /health only.", ].join("\n") } diff --git a/docs/implementation/current/FEAT-CAS-OPENCODE-BRIDGE/status.md b/docs/implementation/current/FEAT-CAS-OPENCODE-BRIDGE/status.md index 0e34cb70cfe4..cf1e7f5d33a7 100644 --- a/docs/implementation/current/FEAT-CAS-OPENCODE-BRIDGE/status.md +++ b/docs/implementation/current/FEAT-CAS-OPENCODE-BRIDGE/status.md @@ -67,3 +67,14 @@ Live (2026-08-02): - Keep **KB-AI-036** updated when bridge tools or hosts change (same PR/session when practical). - Optional: tighten primary agent to deny raw `cas_delegate` and force safe tools only. - Optional: background poller toast for `childRunId` handles. + +## Wave: insights polish (P0/P1) + +| Fix | Detail | +|-----|--------| +| Auth messages | Shared `notConnectedMessage()`; OAuth-first; `oauth-expired` source | +| Timeouts | `fetchWithTimeout` on MCP/REST/health/probe | +| Trace fence | Formatted `cas_safe_run_trace` no longer untrusted-fenced | +| Degraded prompt | Mentions `cas_pipeline_status` + `synapse_probe` without CAS OAuth | +| Probe cost | Cost note; `live=false` free health; MCP bearer not used for chat | +| get_run | Prefers insights formatter + tip toward `cas_safe_run_insights` | diff --git a/packages/opencode/test/plugin/cas-bridge/cas-bridge-routing.test.ts b/packages/opencode/test/plugin/cas-bridge/cas-bridge-routing.test.ts index 1d20ea976a82..15dce186e71e 100644 --- a/packages/opencode/test/plugin/cas-bridge/cas-bridge-routing.test.ts +++ b/packages/opencode/test/plugin/cas-bridge/cas-bridge-routing.test.ts @@ -17,6 +17,7 @@ import { formatSynapseProbe, looksLikeSecret, looksLikeSourceCode, + notConnectedMessage, parseCasRunListPayload, parseCasRunPayload, readSelection, @@ -81,6 +82,8 @@ describe("cas-bridge-routing plugin", () => { expect(output.system.length).toBe(1) expect(output.system[0]).toContain("not connected") expect(output.system[0]).toContain("opencode mcp auth alterspective-agent") + expect(output.system[0]).toContain("cas_pipeline_status") + expect(output.system[0]).toContain("synapse_probe") }) test("routing prompt when token present", async () => { @@ -305,14 +308,50 @@ describe("cas-bridge insights helpers", () => { expect(redactSecrets("Bearer supersecret-token-value")).toContain("[redacted]") expect(redactSecrets("key gpaas_abcdefghijklmnop")).toContain("[redacted]") const prev = process.env.SYNAPSE_API_KEY + const prevG = process.env.GPAAS_API_KEY + const prevM = process.env.SYNAPSE_MCP_BEARER_TOKEN delete process.env.SYNAPSE_API_KEY delete process.env.GPAAS_API_KEY delete process.env.SYNAPSE_MCP_BEARER_TOKEN expect(synapseKeySource()).toBe("none") + // MCP bearer alone is not an inference key for chat probes + process.env.SYNAPSE_MCP_BEARER_TOKEN = "mcp-only" + expect(synapseKeySource()).toBe("none") process.env.GPAAS_API_KEY = "test" expect(synapseKeySource()).toBe("GPAAS_API_KEY") if (prev === undefined) delete process.env.SYNAPSE_API_KEY else process.env.SYNAPSE_API_KEY = prev - delete process.env.GPAAS_API_KEY + if (prevG === undefined) delete process.env.GPAAS_API_KEY + else process.env.GPAAS_API_KEY = prevG + if (prevM === undefined) delete process.env.SYNAPSE_MCP_BEARER_TOKEN + else process.env.SYNAPSE_MCP_BEARER_TOKEN = prevM + }) + + test("notConnectedMessage is OAuth-first", () => { + delete process.env.CAS_MCP_TOKEN + const msg = notConnectedMessage() + expect(msg).toContain("opencode mcp auth alterspective-agent") + expect(msg).toContain("CAS_MCP_TOKEN") + }) + + test("formatSynapseProbe mentions cost", () => { + const text = formatSynapseProbe({ + ok: true, + status: 200, + latencyMs: 10, + requestedModel: "auto", + headers: { + servedModel: "x", + raw: {}, + }, + correlationId: "c1", + }) + expect(text).toContain("Cost:") + expect(text).toContain("tokens") + }) + + test("parseCasRunPayload refuses invalid-shaped blobs for gate tests", () => { + expect(parseCasRunPayload("not json")).toBeUndefined() + expect(parseCasRunPayload("{}")).toBeUndefined() }) })