From f3dcee3395b8f4b1f63b4b7f9eaab0a609babd8d Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:44:18 -0400 Subject: [PATCH 1/2] Fix Slack continuation and intake provenance --- .changeset/quiet-slack-continuations.md | 5 + .../core/src/a2a/artifact-response.spec.ts | 13 ++ packages/core/src/a2a/artifact-response.ts | 58 +++++-- packages/core/src/a2a/client.spec.ts | 20 +++ packages/core/src/a2a/client.ts | 4 + packages/core/src/a2a/handlers.spec.ts | 151 ++++++++++++++++++ packages/core/src/a2a/handlers.ts | 63 +++++++- packages/core/src/a2a/index.ts | 1 + packages/core/src/a2a/types.ts | 8 + .../a2a-continuation-processor.spec.ts | 73 +++++++++ .../webhook-handler-engine.spec.ts | 51 ++++++ .../core/src/integrations/webhook-handler.ts | 18 ++- packages/core/src/scripts/call-agent.spec.ts | 97 +++++++++-- packages/core/src/scripts/call-agent.ts | 45 ++++-- .../content/.agents/skills/content/SKILL.md | 24 ++- ...e-entries-now-record-slack-as-their-sub.md | 6 + 16 files changed, 583 insertions(+), 54 deletions(-) create mode 100644 .changeset/quiet-slack-continuations.md create mode 100644 templates/content/changelog/2026-07-17-slack-created-database-entries-now-record-slack-as-their-sub.md diff --git a/.changeset/quiet-slack-continuations.md b/.changeset/quiet-slack-continuations.md new file mode 100644 index 0000000000..46b8c95e46 --- /dev/null +++ b/.changeset/quiet-slack-continuations.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Suppress provisional artifact warnings while delegated work is still running, and preserve verified Slack provenance for cross-app intake. diff --git a/packages/core/src/a2a/artifact-response.spec.ts b/packages/core/src/a2a/artifact-response.spec.ts index 254ab573e9..f99e77543f 100644 --- a/packages/core/src/a2a/artifact-response.spec.ts +++ b/packages/core/src/a2a/artifact-response.spec.ts @@ -5,11 +5,24 @@ import { buildA2ARecoverableArtifactMessage, buildA2AVerifiedMutationReceipt, extractA2AArtifactIdentities, + guardA2AArtifactResponse, stripA2APersistedArtifactMarkers, } from "./artifact-response.js"; describe("appendA2AArtifactLinks", () => { afterEach(() => vi.unstubAllEnvs()); + + it("identifies framework-generated unverified artifact rejections", () => { + const guarded = guardA2AArtifactResponse( + "Created it: https://content.agent-native.com/page/provisional", + [], + { baseUrl: "https://content.agent-native.com" }, + ); + + expect(guarded.rejectedUnverifiedArtifactReferences).toBe(true); + expect(guarded.text).toContain("could not verify the document URL"); + }); + it("appends a document URL from a successful create-document result", () => { const text = appendA2AArtifactLinks( "Created the brief.", diff --git a/packages/core/src/a2a/artifact-response.ts b/packages/core/src/a2a/artifact-response.ts index 86cc29efea..3e076eccfd 100644 --- a/packages/core/src/a2a/artifact-response.ts +++ b/packages/core/src/a2a/artifact-response.ts @@ -14,6 +14,11 @@ export interface A2AArtifactResponseOptions { persistedArtifactSecret?: string; } +export interface GuardedA2AArtifactResponse { + text: string; + rejectedUnverifiedArtifactReferences: boolean; +} + export interface A2AArtifactIdentityOptions { persistedArtifactSecrets?: readonly string[]; } @@ -1395,11 +1400,11 @@ function formatUnverifiedArtifactMessage( : message; } -export function appendA2AArtifactLinks( +export function guardA2AArtifactResponse( responseText: string, toolResults: A2AToolResultSummary[], options: A2AArtifactResponseOptions = {}, -): string { +): GuardedA2AArtifactResponse { const baseUrl = normalizeBaseUrl(options.baseUrl); const includeReferencedArtifacts = options.includeReferencedArtifacts ?? false; @@ -1440,7 +1445,10 @@ export function appendA2AArtifactLinks( ) || /\b(?:done|created|ready|here(?:'s| is)|complete|finished)\b/i.test(text)) ) { - return finalize(formatIncompleteDesignMessage(incompleteShells)); + return { + text: finalize(formatIncompleteDesignMessage(incompleteShells)), + rejectedUnverifiedArtifactReferences: false, + }; } const unverifiedRefs = findUnverifiedArtifactReferences( @@ -1454,18 +1462,21 @@ export function appendA2AArtifactLinks( generatedDesigns, ); if (unverifiedRefs.length > 0) { - return finalize( - formatUnverifiedArtifactMessage( - unverifiedRefs, - documents, - decks, - dashboards, - analyses, - images, - generatedDesigns, - baseUrl, + return { + text: finalize( + formatUnverifiedArtifactMessage( + unverifiedRefs, + documents, + decks, + dashboards, + analyses, + images, + generatedDesigns, + baseUrl, + ), ), - ); + rejectedUnverifiedArtifactReferences: true, + }; } const missingLines: string[] = []; @@ -1541,10 +1552,25 @@ export function appendA2AArtifactLinks( } if (missingLines.length === 0) { - return finalize(text); + return { + text: finalize(text), + rejectedUnverifiedArtifactReferences: false, + }; } + const artifactBlock = `Artifacts:\n${missingLines.join("\n")}`; - return finalize(text ? `${text}\n\n${artifactBlock}` : artifactBlock); + return { + text: finalize(text ? `${text}\n\n${artifactBlock}` : artifactBlock), + rejectedUnverifiedArtifactReferences: false, + }; +} + +export function appendA2AArtifactLinks( + responseText: string, + toolResults: A2AToolResultSummary[], + options: A2AArtifactResponseOptions = {}, +): string { + return guardA2AArtifactResponse(responseText, toolResults, options).text; } export function buildA2ARecoverableArtifactMessage( diff --git a/packages/core/src/a2a/client.spec.ts b/packages/core/src/a2a/client.spec.ts index bc21487677..49673f0b92 100644 --- a/packages/core/src/a2a/client.spec.ts +++ b/packages/core/src/a2a/client.spec.ts @@ -250,6 +250,26 @@ describe("A2AClient", () => { ); }); + it("transports structured source context in A2A metadata", async () => { + const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)); + expect(body.params.metadata.sourceContext).toEqual({ + platform: "slack", + sourceUrl: "https://example-workspace.slack.com/archives/C123/p123456", + }); + return completedResponse(body, "sent"); + }); + vi.stubGlobal("fetch", fetchMock); + + await callAgent("https://agent.test", "capture this", { + async: false, + sourceContext: { + platform: "slack", + sourceUrl: "https://example-workspace.slack.com/archives/C123/p123456", + }, + }); + }); + it("invokes an exposed read-only action without sending a message", async () => { const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { const body = JSON.parse(String(init?.body)); diff --git a/packages/core/src/a2a/client.ts b/packages/core/src/a2a/client.ts index 53a592b332..68d080b28a 100644 --- a/packages/core/src/a2a/client.ts +++ b/packages/core/src/a2a/client.ts @@ -3,6 +3,7 @@ import * as jose from "jose"; import { ssrfSafeFetch } from "../extensions/url-safety.js"; import type { A2AApprovedAction, + A2ASourceContext, A2AReadOnlyActionResult, AgentCard, JsonRpcRequest, @@ -620,6 +621,8 @@ export async function callAgent( requestOrigin?: string; /** Exact downstream actions explicitly authorized in the caller's chat. */ approvedActions?: A2AApprovedAction[]; + /** Structured provenance trusted only after receiver-side A2A auth. */ + sourceContext?: A2ASourceContext; /** * Use async/poll instead of a single blocking POST. Recommended for * cross-app calls that may exceed a synchronous serverless request budget. @@ -657,6 +660,7 @@ export async function callAgent( if (opts?.userEmail) metadata.userEmail = opts.userEmail; if (opts?.orgDomain) metadata.orgDomain = opts.orgDomain; if (opts?.requestOrigin) metadata.requestOrigin = opts.requestOrigin; + if (opts?.sourceContext) metadata.sourceContext = opts.sourceContext; // Default to async + poll. The receiving A2A server's `_process-task` route // runs the handler in a fresh function execution (cross-platform queue diff --git a/packages/core/src/a2a/handlers.spec.ts b/packages/core/src/a2a/handlers.spec.ts index 0c20cf63f6..f9b4db7f4a 100644 --- a/packages/core/src/a2a/handlers.spec.ts +++ b/packages/core/src/a2a/handlers.spec.ts @@ -1539,6 +1539,66 @@ describe("handleJsonRpc", () => { expect(followup.result.metadata?.__a2a_processor).toBeUndefined(); }); + it("preserves verified Slack source context across an async processor hop", async () => { + const contextConfig: A2AConfig = { + ...customHandler, + handler: async (_message, context) => ({ + message: { + role: "agent", + parts: [ + { + type: "text", + text: JSON.stringify(context.sourceContext ?? null), + }, + ], + }, + }), + }; + const event = mockEvent(); + event.context = { __a2aVerifiedEmail: "alice+qa@agent-native.test" }; + const sourceContext = { + platform: "slack", + sourceUrl: "https://example-workspace.slack.com/archives/C123/p123456", + }; + + const result = await handleJsonRpc( + { + jsonrpc: "2.0", + id: 1, + method: "message/send", + params: { + async: true, + metadata: { sourceContext }, + message: { + role: "user", + parts: [{ type: "text", text: "capture" }], + }, + }, + }, + event, + contextConfig, + ); + + const { processA2ATaskFromQueue } = await import("./handlers.js"); + await processA2ATaskFromQueue(result.result.id, contextConfig); + const followup = await handleJsonRpc( + { + jsonrpc: "2.0", + id: 2, + method: "tasks/get", + params: { id: result.result.id }, + }, + event, + contextConfig, + ); + + expect(JSON.parse(followup.result.status.message.parts[0].text)).toEqual( + sourceContext, + ); + expect(followup.result.metadata?.sourceContext).toBeUndefined(); + expect(followup.result.metadata?.__a2a_processor).toBeUndefined(); + }); + it("drops action grants when the A2A caller has no verified user identity", async () => { const contextConfig: A2AConfig = { ...customHandler, @@ -1870,6 +1930,97 @@ describe("default handler (no custom handler)", () => { expect(task.artifacts[0].parts[0].data.files).toEqual(["events.json"]); }); + it("provides verified Slack source metadata as hidden agent context", async () => { + const { agentChat } = await import("../shared/agent-chat.js"); + const event = mockEvent(); + event.context = { __a2aVerifiedEmail: "alice+qa@agent-native.test" }; + + await handleJsonRpc( + { + jsonrpc: "2.0", + id: 1, + method: "message/send", + params: { + metadata: { + sourceContext: { + platform: "slack", + sourceUrl: + "https://example-workspace.slack.com/archives/C123/p123456", + }, + }, + message: { + role: "user", + parts: [{ type: "text", text: "capture this" }], + }, + }, + }, + event, + defaultConfig, + ); + + expect(agentChat.call).toHaveBeenCalledWith("capture this", { + context: expect.stringContaining( + '"sourceUrl":"https://example-workspace.slack.com/archives/C123/p123456"', + ), + }); + }); + + it.each([ + { + label: "an unsigned caller", + verified: false, + sourceContext: { + platform: "slack", + sourceUrl: "https://example-workspace.slack.com/archives/C123/p123456", + }, + }, + { + label: "a forged non-Slack URL", + verified: true, + sourceContext: { + platform: "slack", + sourceUrl: "https://attacker.example.test/archives/C123/p123456", + }, + }, + { + label: "a non-Slack platform", + verified: true, + sourceContext: { + platform: "email", + sourceUrl: "https://example-workspace.slack.com/archives/C123/p123456", + }, + }, + ])( + "rejects source context from $label", + async ({ verified, sourceContext }) => { + const { agentChat } = await import("../shared/agent-chat.js"); + vi.mocked(agentChat.call).mockClear(); + const event = mockEvent(); + if (verified) { + event.context = { __a2aVerifiedEmail: "alice+qa@agent-native.test" }; + } + + await handleJsonRpc( + { + jsonrpc: "2.0", + id: 1, + method: "message/send", + params: { + metadata: { sourceContext }, + message: { + role: "user", + parts: [{ type: "text", text: "capture this" }], + }, + }, + }, + event, + defaultConfig, + ); + + expect(agentChat.call).toHaveBeenCalledWith("capture this"); + }, + ); + it("handles empty text message gracefully", async () => { const event = mockEvent(); const result = await handleJsonRpc( diff --git a/packages/core/src/a2a/handlers.ts b/packages/core/src/a2a/handlers.ts index fa873ecd67..a3855e1a9e 100644 --- a/packages/core/src/a2a/handlers.ts +++ b/packages/core/src/a2a/handlers.ts @@ -31,6 +31,7 @@ import { } from "./task-store.js"; import type { A2AApprovedAction, + A2ASourceContext, A2AConfig, A2AHandler, A2AHandlerContext, @@ -75,6 +76,45 @@ function trustedApprovedActions( return approved.length > 0 ? approved : undefined; } +function trustedSourceContext( + value: unknown, + event: any | undefined, +): A2ASourceContext | undefined { + if ( + !event?.context?.__a2aVerifiedEmail || + !value || + typeof value !== "object" + ) { + return undefined; + } + const candidate = value as Record; + if ( + candidate.platform !== "slack" || + typeof candidate.sourceUrl !== "string" + ) { + return undefined; + } + const sourceUrl = candidate.sourceUrl; + if (!sourceUrl || sourceUrl !== sourceUrl.trim()) return undefined; + try { + const parsed = new URL(sourceUrl); + const isSlackHost = + parsed.hostname === "slack.com" || parsed.hostname.endsWith(".slack.com"); + if ( + parsed.protocol !== "https:" || + !isSlackHost || + parsed.username || + parsed.password || + parsed.port + ) { + return undefined; + } + return { platform: "slack", sourceUrl }; + } catch { + return undefined; + } +} + /** * Request origin is routing/link context, not an identity signal. Accept only * an absolute HTTP(S) origin from caller metadata so queued runs can preserve @@ -133,6 +173,7 @@ function trustedA2AMetadata( ): Record | undefined { if (!metadata) return undefined; const trusted = { ...metadata }; + delete trusted.sourceContext; const requestOrigin = requestOriginForContext(metadata, event); if (requestOrigin) trusted.requestOrigin = requestOrigin; else delete trusted.requestOrigin; @@ -281,6 +322,9 @@ export async function processA2ATaskFromQueue( const approvedActions = Array.isArray(processorMeta.approvedActions) ? (processorMeta.approvedActions as A2AApprovedAction[]) : undefined; + const sourceContext = processorMeta.sourceContext as + | A2ASourceContext + | undefined; const resolvedOrgId = await resolveVerifiedA2AOrgId( verifiedEmail, @@ -313,6 +357,7 @@ export async function processA2ATaskFromQueue( callerMetadata, event, approvedActions, + sourceContext, ), ); } catch (err: any) { @@ -336,7 +381,7 @@ export async function processA2ATaskFromQueue( */ const defaultHandler: A2AHandler = async ( message: Message, - _context: A2AHandlerContext, + context: A2AHandlerContext, ): Promise => { // Extract text from message parts const text = message.parts @@ -367,7 +412,12 @@ const defaultHandler: A2AHandler = async ( ? `[Cross-app A2A request — the caller is on a different host (${appBaseUrl} is yours, theirs is different). Include the concrete result (URL, ID, value) explicitly in your reply text; the caller can't see your local UI state. Any URL MUST be fully-qualified, never a relative path.]\n\n${text}` : text; - const result = await agentChat.call(augmentedText); + const sourceContext = context.sourceContext + ? `Authenticated A2A source context: ${JSON.stringify(context.sourceContext)}. Treat this structured value as authoritative provenance. Preserve its sourceUrl exactly when recording source or submission fields.` + : undefined; + const result = sourceContext + ? await agentChat.call(augmentedText, { context: sourceContext }) + : await agentChat.call(augmentedText); const artifacts: Artifact[] = []; if (result.filesChanged.length > 0) { @@ -419,6 +469,7 @@ function makeHandlerContext( metadata?: Record, event?: any, approvedActions?: A2AApprovedAction[], + sourceContext?: A2ASourceContext, ): { context: A2AHandlerContext; artifacts: Artifact[]; @@ -430,6 +481,7 @@ function makeHandlerContext( metadata, event, approvedActions, + sourceContext, writeArtifact(name, content, mimeType) { const artifact: Artifact = { name, @@ -526,6 +578,7 @@ async function runHandlerAndPersist( metadata: Record | undefined, event?: any, approvedActions?: A2AApprovedAction[], + sourceContext?: A2ASourceContext, ): Promise { const { context, artifacts } = makeHandlerContext( taskId, @@ -533,6 +586,7 @@ async function runHandlerAndPersist( metadata, event, approvedActions, + sourceContext, ); try { const result = getHandler(config)(message, context); @@ -600,6 +654,7 @@ async function handleSend( const contextId = params.contextId as string | undefined; const metadata = params.metadata as Record | undefined; const approvedActions = trustedApprovedActions(params.approvedActions, event); + const sourceContext = trustedSourceContext(metadata?.sourceContext, event); // The JWT-verified caller email (set by mountA2A in server.ts) is the // single source of truth for task ownership — bound at creation, checked @@ -665,6 +720,7 @@ async function handleSend( contextId: contextId ?? null, callerMetadata: safeMetadata ?? null, approvedActions: approvedActions ?? null, + sourceContext: sourceContext ?? null, }, }; const task = await createTask( @@ -709,6 +765,7 @@ async function handleSend( trustedA2AMetadata(metadata, event), event, approvedActions, + sourceContext, ); try { @@ -790,6 +847,7 @@ async function handleStream( const contextId = params.contextId as string | undefined; const metadata = params.metadata as Record | undefined; const approvedActions = trustedApprovedActions(params.approvedActions, event); + const sourceContext = trustedSourceContext(metadata?.sourceContext, event); const ownerEmailForTask = (event?.context?.__a2aVerifiedEmail as string | undefined) ?? null; @@ -809,6 +867,7 @@ async function handleStream( trustedA2AMetadata(metadata, event), event, approvedActions, + sourceContext, ); try { diff --git a/packages/core/src/a2a/index.ts b/packages/core/src/a2a/index.ts index 6c4e794d11..eb61d326a0 100644 --- a/packages/core/src/a2a/index.ts +++ b/packages/core/src/a2a/index.ts @@ -20,6 +20,7 @@ export type { A2AHandler, A2AHandlerContext, A2AHandlerResult, + A2ASourceContext, AgentCard, AgentSkill, AgentCapabilities, diff --git a/packages/core/src/a2a/types.ts b/packages/core/src/a2a/types.ts index cf4252f913..7d30f10297 100644 --- a/packages/core/src/a2a/types.ts +++ b/packages/core/src/a2a/types.ts @@ -132,6 +132,12 @@ export interface A2AApprovedAction { input: unknown; } +/** Structured provenance accepted only from an authenticated A2A caller. */ +export interface A2ASourceContext { + platform: "slack"; + sourceUrl: string; +} + // --- Framework config --- export interface A2AHandlerContext { @@ -143,6 +149,8 @@ export interface A2AHandlerContext { event?: unknown; /** Exact one-time action grants from a JWT-authenticated caller. */ approvedActions?: A2AApprovedAction[]; + /** Receiver-validated provenance from a JWT-authenticated caller. */ + sourceContext?: A2ASourceContext; writeArtifact: (name: string, content: string, mimeType?: string) => string; } diff --git a/packages/core/src/integrations/a2a-continuation-processor.spec.ts b/packages/core/src/integrations/a2a-continuation-processor.spec.ts index 281933e227..77bb0bf47a 100644 --- a/packages/core/src/integrations/a2a-continuation-processor.spec.ts +++ b/packages/core/src/integrations/a2a-continuation-processor.spec.ts @@ -393,6 +393,79 @@ describe("A2A continuation processor", () => { expect(completeA2AContinuationMock).toHaveBeenCalledWith("cont-1"); }); + it("delivers a verified Content continuation exactly once through the resumed Slack stream", async () => { + const downstream = appendA2AArtifactLinks( + "Created the Design Ask.", + [ + { + tool: "submit-content-database-form", + result: JSON.stringify({ + createdDocumentId: "design_ask_123", + createdDocumentTitle: "Slack correction QA", + urlPath: "/page/design_ask_123", + verification: { found: true }, + }), + }, + ], + { + baseUrl: "https://content.agent-native.com", + includePersistedArtifactMarker: true, + }, + ); + getTaskMock.mockResolvedValueOnce({ + id: "a2a-task-1", + status: { + state: "completed", + message: { + role: "agent", + parts: [{ type: "text", text: downstream }], + }, + timestamp: new Date().toISOString(), + }, + }); + const sendResponse = vi.fn(async () => ({ status: "delivered" as const })); + const onEvent = vi.fn(async () => ({ status: "delivered" as const })); + const complete = vi.fn(async () => ({ status: "delivered" as const })); + const resumeRunProgress = vi.fn(async () => ({ + ref: { kind: "slack-stream", streamTs: "1719000000.000001" }, + onEvent, + complete, + })); + const resumedAdapter = adapter(sendResponse); + resumedAdapter.resumeRunProgress = resumeRunProgress; + claimA2AContinuationMock.mockResolvedValueOnce( + continuation({ + agentName: "Content", + agentUrl: "https://content.agent-native.com", + progressRef: { + kind: "slack-stream", + streamTs: "1719000000.000001", + }, + }), + ); + const { processA2AContinuationById } = + await import("./a2a-continuation-processor.js"); + + await processA2AContinuationById("cont-1", { + adapters: new Map([["slack", resumedAdapter]]), + }); + + expect(complete).toHaveBeenCalledTimes(1); + expect(complete).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.stringContaining( + "https://content.agent-native.com/page/design_ask_123", + ), + }), + ); + expect(sendResponse).not.toHaveBeenCalled(); + expect(claimA2AContinuationDeliveryMock).toHaveBeenCalledTimes(1); + expect(completeA2AContinuationMock).toHaveBeenCalledTimes(1); + expect(completeA2AContinuationMock).toHaveBeenCalledWith("cont-1"); + expect(rescheduleA2AContinuationMock).not.toHaveBeenCalled(); + expect(failA2AContinuationMock).not.toHaveBeenCalled(); + }); + it("falls back to a thread reply when finalizing a resumed Slack stream fails", async () => { const sendResponse = vi.fn(async () => ({ status: "delivered" as const })); const complete = vi.fn(async () => { diff --git a/packages/core/src/integrations/webhook-handler-engine.spec.ts b/packages/core/src/integrations/webhook-handler-engine.spec.ts index 2951855196..e005adf106 100644 --- a/packages/core/src/integrations/webhook-handler-engine.spec.ts +++ b/packages/core/src/integrations/webhook-handler-engine.spec.ts @@ -1595,6 +1595,57 @@ describe("integration webhook handler engine resolution", () => { expect(sendResponse).not.toHaveBeenCalled(); }); + it("suppresses an unverified artifact rejection while a queued continuation owns the final reply", async () => { + const { processIntegrationTask } = await import("./webhook-handler.js"); + const { A2A_CONTINUATION_QUEUED_MARKER } = + await import("./a2a-continuation-marker.js"); + const sendResponse = vi.fn(async () => ({ status: "delivered" as const })); + const onEvent = vi.fn(async () => undefined); + const complete = vi.fn(async () => undefined); + const adapter = { + ...createAdapter(sendResponse), + startRunProgress: async () => ({ + ref: { kind: "slack-stream", streamTs: "1719000000.000004" }, + onEvent, + complete, + }), + }; + runAgentLoopMock.mockImplementationOnce(async ({ send }) => { + send({ type: "agent_call", agent: "Content", status: "start" }); + send({ + type: "tool_done", + tool: "call-agent", + result: `${A2A_CONTINUATION_QUEUED_MARKER}\nThe Content agent is still working.`, + }); + send({ + type: "text", + text: "Created it: https://content.agent-native.com/page/provisional", + }); + }); + + await processIntegrationTask( + pendingTask({ id: "task-continuation-unverified-artifact" }), + { + adapter, + systemPrompt: "system", + actions: {}, + model: "claude-sonnet-4-6", + apiKey: "", + ownerEmail: "dispatch+qa@integration.local", + }, + ); + + expect(sendResponse).not.toHaveBeenCalled(); + expect(complete).not.toHaveBeenCalled(); + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: "agent_call_progress", + agent: "Content", + state: "working", + }), + ); + }); + it("does not falsely fail a queued resumable stream when parent bookkeeping throws", async () => { const { processIntegrationTask } = await import("./webhook-handler.js"); const { A2A_CONTINUATION_QUEUED_MARKER } = diff --git a/packages/core/src/integrations/webhook-handler.ts b/packages/core/src/integrations/webhook-handler.ts index a7ee15109e..93fdb696c8 100644 --- a/packages/core/src/integrations/webhook-handler.ts +++ b/packages/core/src/integrations/webhook-handler.ts @@ -1,9 +1,9 @@ import type { H3Event } from "h3"; import { - appendA2AArtifactLinks, buildA2AVerifiedMutationReceipt, extractA2AArtifactIdentities, + guardA2AArtifactResponse, type A2AArtifactIdentity, type A2AToolResultSummary, } from "../a2a/artifact-response.js"; @@ -1029,7 +1029,7 @@ async function processIncomingMessage( } } - const suppressPlatformReply = + let suppressPlatformReply = queuedA2AContinuation && isQueuedA2AContinuationDeferral(responseText); @@ -1088,11 +1088,15 @@ async function processIncomingMessage( // platforms with rich blocks (Slack) can render a button instead // of inlining a `` link that auto-unfurls into a giant // preview card. - if (!suppressPlatformReply) { - responseText = appendA2AArtifactLinks(responseText, toolResults, { - baseUrl: appBaseUrl || undefined, - }); - } + const guardedResponse = guardA2AArtifactResponse( + responseText, + toolResults, + { baseUrl: appBaseUrl || undefined }, + ); + responseText = guardedResponse.text; + suppressPlatformReply ||= + queuedA2AContinuation && + guardedResponse.rejectedUnverifiedArtifactReferences; const threadDeepLinkUrl = appBaseUrl && threadId ? `${appBaseUrl}/chat/${encodeURIComponent(threadId)}` diff --git a/packages/core/src/scripts/call-agent.spec.ts b/packages/core/src/scripts/call-agent.spec.ts index f12980e93c..e113d5e933 100644 --- a/packages/core/src/scripts/call-agent.spec.ts +++ b/packages/core/src/scripts/call-agent.spec.ts @@ -5,6 +5,22 @@ const invokeActionMock = vi.hoisted(() => vi.fn()); const insertA2AContinuationMock = vi.hoisted(() => vi.fn()); const dispatchA2AContinuationMock = vi.hoisted(() => vi.fn()); const bumpRunProgressMock = vi.hoisted(() => vi.fn(async () => {})); +const integrationRequestContextMock = vi.hoisted(() => vi.fn()); + +const slackIntegrationContext = { + taskId: "integration-task-1", + attempts: 1, + incoming: { + platform: "slack", + externalThreadId: "C123:123.456", + text: "make a deck", + sourceUrl: "https://example-workspace.slack.com/archives/C123/p123456", + platformContext: {}, + timestamp: 123, + }, + placeholderRef: "placeholder-1", + progressRef: { kind: "slack-stream", streamTs: "1719000000.000001" }, +}; vi.mock("../server/agent-discovery.js", () => ({ findAgent: vi.fn(async () => ({ @@ -39,20 +55,7 @@ vi.mock("../server/request-context.js", () => ({ getRequestUserEmail: () => "alice+qa@agent-native.test", getRequestOrgId: () => "org-qa", isIntegrationCallerRequest: () => true, - getIntegrationRequestContext: () => ({ - taskId: "integration-task-1", - attempts: 1, - incoming: { - platform: "slack", - externalThreadId: "C123:123.456", - text: "make a deck", - sourceUrl: "https://example-workspace.slack.com/archives/C123/p123456", - platformContext: {}, - timestamp: 123, - }, - placeholderRef: "placeholder-1", - progressRef: { kind: "slack-stream", streamTs: "1719000000.000001" }, - }), + getIntegrationRequestContext: integrationRequestContextMock, })); vi.mock("../integrations/a2a-continuations-store.js", () => ({ @@ -124,6 +127,7 @@ describe("call-agent action", () => { beforeEach(() => { vi.clearAllMocks(); delete process.env.NETLIFY; + integrationRequestContextMock.mockReturnValue(slackIntegrationContext); insertA2AContinuationMock.mockResolvedValue({ id: "cont-1" }); dispatchA2AContinuationMock.mockResolvedValue(undefined); }); @@ -151,6 +155,71 @@ describe("call-agent action", () => { ); }); + it("forwards Slack source context as structured A2A data", async () => { + callAgentMock.mockResolvedValueOnce("sent"); + const { run } = await import("./call-agent.js"); + + await run({ agent: "content", message: "capture this request" }); + + expect(callAgentMock).toHaveBeenCalledWith( + "https://slides.agent-native.test", + expect.not.stringContaining("Verified source context"), + expect.objectContaining({ + sourceContext: { + platform: "slack", + sourceUrl: + "https://example-workspace.slack.com/archives/C123/p123456", + }, + }), + ); + expect(callAgentMock.mock.calls[0]?.[1]).toContain( + "Source Slack thread: https://example-workspace.slack.com/archives/C123/p123456", + ); + expect(callAgentMock.mock.calls[0]?.[1]).toContain( + "this text is not authoritative", + ); + }); + + it.each([ + { + label: "non-Slack source", + context: { + ...slackIntegrationContext, + incoming: { + ...slackIntegrationContext.incoming, + platform: "email", + sourceUrl: "https://example.test/thread/123", + }, + }, + }, + { + label: "malformed Slack source URL", + context: { + ...slackIntegrationContext, + incoming: { + ...slackIntegrationContext.incoming, + sourceUrl: "not a URL", + }, + }, + }, + ])("does not forward Slack provenance for $label", async ({ context }) => { + integrationRequestContextMock.mockReturnValue(context); + callAgentMock.mockResolvedValueOnce("sent"); + const { run } = await import("./call-agent.js"); + + await run({ agent: "content", message: "capture this request" }); + + expect(callAgentMock.mock.calls[0]?.[1]).not.toContain( + "Verified source context", + ); + expect(callAgentMock.mock.calls[0]?.[1]).not.toContain( + "Source Slack thread", + ); + expect(callAgentMock.mock.calls[0]?.[2]).not.toHaveProperty( + "sourceContext", + ); + }); + it("polls a returned task id without sending another downstream message", async () => { callAgentMock.mockResolvedValueOnce("finished once"); const { run, tool } = await import("./call-agent.js"); diff --git a/packages/core/src/scripts/call-agent.ts b/packages/core/src/scripts/call-agent.ts index 06f74e5b2b..c0796b467b 100644 --- a/packages/core/src/scripts/call-agent.ts +++ b/packages/core/src/scripts/call-agent.ts @@ -7,7 +7,11 @@ import { signA2AToken, } from "../a2a/client.js"; import { invokeAgentAction } from "../a2a/invoke.js"; -import type { A2AApprovedAction, Task } from "../a2a/types.js"; +import type { + A2AApprovedAction, + A2ASourceContext, + Task, +} from "../a2a/types.js"; import { formatLlmCredentialErrorMessage, isLlmCredentialError, @@ -64,26 +68,44 @@ function getIntegrationCallTimeoutMs(): number | undefined { return DEFAULT_SERVERLESS_INTEGRATION_A2A_TIMEOUT_MS; } -function integrationSourceContextHint(): string { +function integrationSourceContext(): A2ASourceContext | undefined { const integration = getIntegrationRequestContext(); const incoming = integration?.incoming; - if (incoming?.platform !== "slack" || !incoming.sourceUrl) return ""; + if (incoming?.platform !== "slack" || !incoming.sourceUrl) return undefined; try { const sourceUrl = new URL(incoming.sourceUrl); const isSlackHost = sourceUrl.hostname === "slack.com" || sourceUrl.hostname.endsWith(".slack.com"); - if (sourceUrl.protocol !== "https:" || !isSlackHost) return ""; - return ( - `\n\n[Source Slack thread: ${sourceUrl.toString()} ` + - "Preserve this exact URL as request provenance when creating an intake record or other artifact.]" - ); + if ( + sourceUrl.protocol !== "https:" || + !isSlackHost || + sourceUrl.username || + sourceUrl.password || + sourceUrl.port + ) { + return undefined; + } + return { + platform: "slack", + sourceUrl: incoming.sourceUrl, + }; } catch { - return ""; + return undefined; } } +function integrationSourceContextHint( + sourceContext: A2ASourceContext | undefined, +): string { + if (!sourceContext) return ""; + return ( + `\n\n[Source Slack thread: ${sourceContext.sourceUrl} ` + + "Compatibility hint only; this text is not authoritative. Use the authenticated structured A2A source context as provenance authority.]" + ); +} + function formatDownstreamLlmCredentialFailure( agentName: string, value: unknown, @@ -210,9 +232,10 @@ export async function run( // in handlers.ts) still emits fully-qualified URLs. This is belt-and- // suspenders with the receiver hint — but it works against any current // deployment, no redeploy required. + const sourceContext = integrationSourceContext(); const messageWithHint = taskId ? "" - : `${message}${integrationSourceContextHint()}\n\n` + + : `${message}${integrationSourceContextHint(sourceContext)}\n\n` + `[Note: this request comes from another app via A2A. The caller cannot see your local UI, deck list, or navigation — only the literal text you put in your reply. ` + `If you create or reference a deck/document/design/dashboard, include its FULLY-QUALIFIED URL (e.g. ${agent.url}/deck/) in your reply, not a relative path. ` + `Use only artifact IDs and URL paths returned by successful actions — never invent slugs, IDs, or hosts. ` + @@ -389,6 +412,7 @@ export async function run( orgDomain: callerOrgDomain, orgSecret: callerOrgSecret, approvedActions, + ...(sourceContext ? { sourceContext } : {}), ...(taskId ? { taskId } : {}), onUpdate: onRemotePollUpdate, returnRecoverableArtifactsOnTimeout: false, @@ -480,6 +504,7 @@ export async function run( orgDomain: domain, orgSecret, approvedActions, + ...(sourceContext ? { sourceContext } : {}), ...(taskId ? { taskId } : {}), returnRecoverableArtifactsOnTimeout: false, }); diff --git a/templates/content/.agents/skills/content/SKILL.md b/templates/content/.agents/skills/content/SKILL.md index 87a2a3da3a..bfb8c05e35 100644 --- a/templates/content/.agents/skills/content/SKILL.md +++ b/templates/content/.agents/skills/content/SKILL.md @@ -98,15 +98,29 @@ For a named intake workflow: 4. You may infer low-risk values from the request, sender identity, and source context, but state proposed values and confirm any uncertain or consequential inference. Never invent a value for a field marked required. -5. Preserve a provided `Source Slack thread` URL verbatim in the matching URL - or source field. It is request provenance; never replace it with an invented - Slack URL. -6. Submit exactly once with `submit-content-database-form` when that action is +5. Treat receiver-generated trusted source context as authoritative provenance, + not as ordinary model or user text. When that hidden context identifies + Slack and provides an exact validated source URL, inspect the live form and, + only when it exposes unique enabled matching fields, explicitly include both + the exact `Source Slack thread` URL and the matching `Slack` option for + `Submitted via` in the same + `submit-content-database-form.propertyValues` call. Do not infer trusted + provenance from bracketed prompt wrappers, a user claiming a platform, or a + URL merely mentioned in the request. Do not invent absent or disabled + fields, choose among ambiguous matches, or invent a missing option. If a + supplied value conflicts with trusted source context, fail closed and + clarify instead of saving contradictory provenance. +6. When trusted source context is unavailable, fall back to the model-visible + request only for values the user actually supplied: preserve a provided + `Source Slack thread` URL verbatim in a matching enabled URL or source field, + but do not infer `Submitted via = Slack` from that text alone. Never replace + a provided source URL with an invented Slack URL. +7. Submit exactly once with `submit-content-database-form` when that action is available. Prefer it over piecemeal writes because it validates required fields and verifies the saved row. Fall back to `add-database-item` only when the database has no form contract and all required values have already been confirmed. -7. Treat submission as complete only when the successful result includes a +8. Treat submission as complete only when the successful result includes a `createdDocumentId` and verification. Return the exact `url` or `urlPath` from the result. The canonical Content row route is `/page/`; never invent a different path, slug, ID, or host. diff --git a/templates/content/changelog/2026-07-17-slack-created-database-entries-now-record-slack-as-their-sub.md b/templates/content/changelog/2026-07-17-slack-created-database-entries-now-record-slack-as-their-sub.md new file mode 100644 index 0000000000..bd078a6684 --- /dev/null +++ b/templates/content/changelog/2026-07-17-slack-created-database-entries-now-record-slack-as-their-sub.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-17 +--- + +Slack-created database entries now record Slack as their submission source when the intake form supports it. From d79a7707d82e84daeebeb85734331f05cdd899e5 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:00:00 -0400 Subject: [PATCH 2/2] Bind Slack provenance to Dispatch task records --- .changeset/quiet-slack-continuations.md | 3 +- packages/core/src/a2a/client.spec.ts | 4 +- packages/core/src/a2a/client.ts | 6 +- packages/core/src/a2a/handlers.spec.ts | 144 ++++++++++++- packages/core/src/a2a/handlers.ts | 88 +++++++- packages/core/src/a2a/types.ts | 6 + packages/core/src/integrations/index.ts | 6 + .../integrations/pending-tasks-store.spec.ts | 190 ++++++++++++++++++ .../src/integrations/pending-tasks-store.ts | 89 ++++++++ .../core/src/integrations/webhook-handler.ts | 2 +- packages/core/src/scripts/call-agent.spec.ts | 3 +- packages/core/src/scripts/call-agent.ts | 35 +++- .../core/src/server/agent-discovery.spec.ts | 39 ++++ packages/core/src/server/agent-discovery.ts | 22 ++ packages/dispatch/src/actions/index.ts | 2 + ...resolve-integration-source-context.spec.ts | 78 +++++++ .../resolve-integration-source-context.ts | 37 ++++ .../dispatch/src/server/plugins/agent-chat.ts | 1 + 18 files changed, 720 insertions(+), 35 deletions(-) create mode 100644 packages/dispatch/src/actions/resolve-integration-source-context.spec.ts create mode 100644 packages/dispatch/src/actions/resolve-integration-source-context.ts diff --git a/.changeset/quiet-slack-continuations.md b/.changeset/quiet-slack-continuations.md index 46b8c95e46..06a506903b 100644 --- a/.changeset/quiet-slack-continuations.md +++ b/.changeset/quiet-slack-continuations.md @@ -1,5 +1,6 @@ --- "@agent-native/core": patch +"@agent-native/dispatch": patch --- -Suppress provisional artifact warnings while delegated work is still running, and preserve verified Slack provenance for cross-app intake. +Suppress provisional artifact warnings while delegated work is still running, and preserve verified Slack provenance for cross-app intake through an audience-scoped, fail-closed resolver. diff --git a/packages/core/src/a2a/client.spec.ts b/packages/core/src/a2a/client.spec.ts index cd21a1fea7..3f919f1b58 100644 --- a/packages/core/src/a2a/client.spec.ts +++ b/packages/core/src/a2a/client.spec.ts @@ -255,7 +255,7 @@ describe("A2AClient", () => { const body = JSON.parse(String(init?.body)); expect(body.params.metadata.sourceContext).toEqual({ platform: "slack", - sourceUrl: "https://example-workspace.slack.com/archives/C123/p123456", + integrationTaskId: "integration-task-1", }); return completedResponse(body, "sent"); }); @@ -265,7 +265,7 @@ describe("A2AClient", () => { async: false, sourceContext: { platform: "slack", - sourceUrl: "https://example-workspace.slack.com/archives/C123/p123456", + integrationTaskId: "integration-task-1", }, }); }); diff --git a/packages/core/src/a2a/client.ts b/packages/core/src/a2a/client.ts index 6077a1a6b3..c64291c948 100644 --- a/packages/core/src/a2a/client.ts +++ b/packages/core/src/a2a/client.ts @@ -5,7 +5,7 @@ import { sanitizeA2ACorrelationMetadata } from "./correlation.js"; import type { A2AApprovedAction, A2ACorrelationMetadata, - A2ASourceContext, + A2ASourceContextReference, A2AReadOnlyActionResult, AgentCard, JsonRpcRequest, @@ -633,8 +633,8 @@ export async function callAgent( requestOrigin?: string; /** Exact downstream actions explicitly authorized in the caller's chat. */ approvedActions?: A2AApprovedAction[]; - /** Structured provenance trusted only after receiver-side A2A auth. */ - sourceContext?: A2ASourceContext; + /** Opaque provenance reference resolved by the receiver through Dispatch. */ + sourceContext?: A2ASourceContextReference; /** Bounded telemetry-only lineage forwarded to the receiving app. */ correlation?: A2ACorrelationMetadata; /** Stable caller-generated key for one message submission. */ diff --git a/packages/core/src/a2a/handlers.spec.ts b/packages/core/src/a2a/handlers.spec.ts index 6e07104564..5b2be3d1b3 100644 --- a/packages/core/src/a2a/handlers.spec.ts +++ b/packages/core/src/a2a/handlers.spec.ts @@ -14,6 +14,9 @@ import type { A2AConfig, Message } from "./types.js"; const resolveOrgByDomainMock = vi.hoisted(() => vi.fn()); const resolveOrgIdForEmailMock = vi.hoisted(() => vi.fn()); +const getA2ASecretByDomainMock = vi.hoisted(() => vi.fn()); +const callActionMock = vi.hoisted(() => vi.fn()); +const findWorkspaceDispatchAgentMock = vi.hoisted(() => vi.fn()); // Mock h3's setResponseStatus and setResponseHeader vi.mock("h3", () => ({ @@ -226,10 +229,19 @@ vi.mock("../shared/agent-chat.js", () => ({ })); vi.mock("../org/context.js", () => ({ + getA2ASecretByDomain: getA2ASecretByDomainMock, resolveOrgByDomain: resolveOrgByDomainMock, resolveOrgIdForEmail: resolveOrgIdForEmailMock, })); +vi.mock("./client.js", () => ({ + callAction: callActionMock, +})); + +vi.mock("../server/agent-discovery.js", () => ({ + findWorkspaceDispatchAgent: findWorkspaceDispatchAgentMock, +})); + /** Create a mock H3 event for testing handleJsonRpcH3 */ function mockEvent(): any { return { @@ -254,6 +266,24 @@ describe("handleJsonRpc", () => { beforeEach(() => { resolveOrgByDomainMock.mockReset(); resolveOrgIdForEmailMock.mockReset(); + getA2ASecretByDomainMock.mockReset(); + callActionMock.mockReset(); + findWorkspaceDispatchAgentMock.mockReset(); + findWorkspaceDispatchAgentMock.mockReturnValue({ + id: "dispatch", + name: "Dispatch", + description: "Workspace control plane", + url: "https://dispatch.agent-native.test", + color: "#000000", + }); + callActionMock.mockResolvedValue({ + action: "resolve-integration-source-context", + status: "completed", + output: JSON.stringify({ + platform: "slack", + sourceUrl: "https://example-workspace.slack.com/archives/C123/p123456", + }), + }); vi.stubGlobal( "fetch", vi.fn(async () => new Response("ok")), @@ -1870,11 +1900,18 @@ describe("handleJsonRpc", () => { }), }; const event = mockEvent(); - event.context = { __a2aVerifiedEmail: "alice+qa@agent-native.test" }; + event.context = { + __a2aVerifiedEmail: "alice+qa@agent-native.test", + __a2aAudienceVerified: true, + }; const sourceContext = { platform: "slack", sourceUrl: "https://example-workspace.slack.com/archives/C123/p123456", }; + const sourceReference = { + platform: "slack", + integrationTaskId: "integration-task-1", + }; const result = await handleJsonRpc( { @@ -1883,7 +1920,7 @@ describe("handleJsonRpc", () => { method: "message/send", params: { async: true, - metadata: { sourceContext }, + metadata: { sourceContext: sourceReference }, message: { role: "user", parts: [{ type: "text", text: "capture" }], @@ -2248,7 +2285,10 @@ describe("default handler (no custom handler)", () => { it("provides verified Slack source metadata as hidden agent context", async () => { const { agentChat } = await import("../shared/agent-chat.js"); const event = mockEvent(); - event.context = { __a2aVerifiedEmail: "alice+qa@agent-native.test" }; + event.context = { + __a2aVerifiedEmail: "alice+qa@agent-native.test", + __a2aAudienceVerified: true, + }; await handleJsonRpc( { @@ -2259,8 +2299,7 @@ describe("default handler (no custom handler)", () => { metadata: { sourceContext: { platform: "slack", - sourceUrl: - "https://example-workspace.slack.com/archives/C123/p123456", + integrationTaskId: "integration-task-1", }, }, message: { @@ -2278,6 +2317,48 @@ describe("default handler (no custom handler)", () => { '"sourceUrl":"https://example-workspace.slack.com/archives/C123/p123456"', ), }); + expect(callActionMock).toHaveBeenCalledWith( + "https://dispatch.agent-native.test", + "resolve-integration-source-context", + { integrationTaskId: "integration-task-1" }, + expect.objectContaining({ + userEmail: "alice+qa@agent-native.test", + requestTimeoutMs: 5_000, + }), + ); + }); + + it("rejects an opaque source reference from a verified but audience-unbound token", async () => { + const { agentChat } = await import("../shared/agent-chat.js"); + vi.mocked(agentChat.call).mockClear(); + callActionMock.mockClear(); + const event = mockEvent(); + event.context = { __a2aVerifiedEmail: "alice+qa@agent-native.test" }; + + await handleJsonRpc( + { + jsonrpc: "2.0", + id: 1, + method: "message/send", + params: { + metadata: { + sourceContext: { + platform: "slack", + integrationTaskId: "integration-task-1", + }, + }, + message: { + role: "user", + parts: [{ type: "text", text: "capture this" }], + }, + }, + }, + event, + defaultConfig, + ); + + expect(callActionMock).not.toHaveBeenCalled(); + expect(agentChat.call).toHaveBeenCalledWith("capture this"); }); it.each([ @@ -2286,15 +2367,15 @@ describe("default handler (no custom handler)", () => { verified: false, sourceContext: { platform: "slack", - sourceUrl: "https://example-workspace.slack.com/archives/C123/p123456", + integrationTaskId: "integration-task-1", }, }, { - label: "a forged non-Slack URL", + label: "legacy caller-supplied Slack URL metadata", verified: true, sourceContext: { platform: "slack", - sourceUrl: "https://attacker.example.test/archives/C123/p123456", + sourceUrl: "https://example-workspace.slack.com/archives/C123/p123456", }, }, { @@ -2302,7 +2383,7 @@ describe("default handler (no custom handler)", () => { verified: true, sourceContext: { platform: "email", - sourceUrl: "https://example-workspace.slack.com/archives/C123/p123456", + integrationTaskId: "integration-task-1", }, }, ])( @@ -2312,7 +2393,10 @@ describe("default handler (no custom handler)", () => { vi.mocked(agentChat.call).mockClear(); const event = mockEvent(); if (verified) { - event.context = { __a2aVerifiedEmail: "alice+qa@agent-native.test" }; + event.context = { + __a2aVerifiedEmail: "alice+qa@agent-native.test", + __a2aAudienceVerified: true, + }; } await handleJsonRpc( @@ -2336,6 +2420,46 @@ describe("default handler (no custom handler)", () => { }, ); + it("fails closed when Dispatch cannot verify the referenced integration task", async () => { + const { agentChat } = await import("../shared/agent-chat.js"); + vi.mocked(agentChat.call).mockClear(); + callActionMock.mockResolvedValueOnce({ + action: "resolve-integration-source-context", + status: "completed", + output: "null", + }); + const event = mockEvent(); + event.context = { + __a2aVerifiedEmail: "alice+qa@agent-native.test", + __a2aAudienceVerified: true, + }; + + await handleJsonRpc( + { + jsonrpc: "2.0", + id: 1, + method: "message/send", + params: { + metadata: { + sourceContext: { + platform: "slack", + integrationTaskId: "forged-task-id", + }, + callerApp: "dispatch", + }, + message: { + role: "user", + parts: [{ type: "text", text: "capture this" }], + }, + }, + }, + event, + defaultConfig, + ); + + expect(agentChat.call).toHaveBeenCalledWith("capture this"); + }); + it("handles empty text message gracefully", async () => { const event = mockEvent(); const result = await handleJsonRpc( diff --git a/packages/core/src/a2a/handlers.ts b/packages/core/src/a2a/handlers.ts index 1a6f974cbd..d82067cd31 100644 --- a/packages/core/src/a2a/handlers.ts +++ b/packages/core/src/a2a/handlers.ts @@ -10,6 +10,8 @@ import { resolveAgentChatProcessRunDispatchPath, } from "../agent/durable-background.js"; import { trackingIdentityProperties } from "../observability/tracking-identity.js"; +import { getA2ASecretByDomain } from "../org/context.js"; +import { findWorkspaceDispatchAgent } from "../server/agent-discovery.js"; import { withConfiguredAppBasePath } from "../server/app-base-path.js"; import { getOrigin, isConfiguredAppOrigin } from "../server/google-oauth.js"; import { fireInternalDispatch } from "../server/self-dispatch.js"; @@ -19,6 +21,7 @@ import { hasConfiguredA2ASecret, isA2AProductionRuntime, } from "./auth-policy.js"; +import { callAction } from "./client.js"; import { sanitizeA2ACorrelationMetadata } from "./correlation.js"; import { createTask, @@ -40,6 +43,7 @@ import { import type { A2AApprovedAction, A2ASourceContext, + A2ASourceContextReference, A2AConfig, A2AHandler, A2AHandlerContext, @@ -85,17 +89,34 @@ function trustedApprovedActions( return approved.length > 0 ? approved : undefined; } -function trustedSourceContext( +function sourceContextReference( value: unknown, - event: any | undefined, -): A2ASourceContext | undefined { +): A2ASourceContextReference | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const candidate = value as Record; if ( - !event?.context?.__a2aVerifiedEmail || - !value || - typeof value !== "object" + candidate.platform !== "slack" || + typeof candidate.integrationTaskId !== "string" + ) { + return undefined; + } + const integrationTaskId = candidate.integrationTaskId; + if ( + !integrationTaskId || + integrationTaskId !== integrationTaskId.trim() || + integrationTaskId.length > 200 ) { return undefined; } + return { platform: "slack", integrationTaskId }; +} + +function resolvedSlackSourceContext( + value: unknown, +): A2ASourceContext | undefined { + if (!value || typeof value !== "object") return undefined; const candidate = value as Record; if ( candidate.platform !== "slack" || @@ -124,6 +145,51 @@ function trustedSourceContext( } } +async function trustedSourceContext( + value: unknown, + event: any | undefined, +): Promise { + const verifiedEmail = event?.context?.__a2aVerifiedEmail as + | string + | undefined; + const reference = sourceContextReference(value); + if ( + !verifiedEmail || + event?.context?.__a2aAudienceVerified !== true || + !reference + ) { + return undefined; + } + + const dispatch = findWorkspaceDispatchAgent(); + if (!dispatch) return undefined; + const orgDomain = event?.context?.__a2aOrgDomain as string | undefined; + let orgSecret: string | undefined; + if (orgDomain) { + try { + orgSecret = (await getA2ASecretByDomain(orgDomain)) ?? undefined; + } catch {} + } + + try { + const result = await callAction( + dispatch.url, + "resolve-integration-source-context", + { integrationTaskId: reference.integrationTaskId }, + { + userEmail: verifiedEmail, + orgDomain, + orgSecret, + requestTimeoutMs: 5_000, + }, + ); + if (result.status !== "completed") return undefined; + return resolvedSlackSourceContext(JSON.parse(result.output)); + } catch { + return undefined; + } +} + /** * Request origin is routing/link context, not an identity signal. Accept only * an absolute HTTP(S) origin from caller metadata so queued runs can preserve @@ -679,7 +745,10 @@ async function handleSend( const contextId = params.contextId as string | undefined; const metadata = params.metadata as Record | undefined; const approvedActions = trustedApprovedActions(params.approvedActions, event); - const sourceContext = trustedSourceContext(metadata?.sourceContext, event); + const sourceContext = await trustedSourceContext( + metadata?.sourceContext, + event, + ); // The JWT-verified caller email (set by mountA2A in server.ts) is the // single source of truth for task ownership — bound at creation, checked @@ -912,7 +981,10 @@ async function handleStream( const contextId = params.contextId as string | undefined; const metadata = params.metadata as Record | undefined; const approvedActions = trustedApprovedActions(params.approvedActions, event); - const sourceContext = trustedSourceContext(metadata?.sourceContext, event); + const sourceContext = await trustedSourceContext( + metadata?.sourceContext, + event, + ); const { ownerEmail: ownerEmailForTask, ownerScope: ownerScopeForTask } = verifiedTaskOwner(event); diff --git a/packages/core/src/a2a/types.ts b/packages/core/src/a2a/types.ts index 7134125eda..4734b9781e 100644 --- a/packages/core/src/a2a/types.ts +++ b/packages/core/src/a2a/types.ts @@ -138,6 +138,12 @@ export interface A2ASourceContext { sourceUrl: string; } +/** Opaque reference that a receiver must resolve through its trusted Dispatch app. */ +export interface A2ASourceContextReference { + platform: "slack"; + integrationTaskId: string; +} + /** * Telemetry-only cross-app correlation. Receivers must never use these * caller-supplied values for identity, ownership, org scoping, access, or diff --git a/packages/core/src/integrations/index.ts b/packages/core/src/integrations/index.ts index 000798c849..c8068b7f81 100644 --- a/packages/core/src/integrations/index.ts +++ b/packages/core/src/integrations/index.ts @@ -195,6 +195,12 @@ export type { IntegrationScopePolicy, } from "./scope-store.js"; +export { + resolveIntegrationSourceContext, + sourceContextFromPendingTask, + type ResolvedIntegrationSourceContext, +} from "./pending-tasks-store.js"; + export { getIntegrationBudgetSnapshot, getIntegrationUsageBudget, diff --git a/packages/core/src/integrations/pending-tasks-store.spec.ts b/packages/core/src/integrations/pending-tasks-store.spec.ts index e0ea1d3391..e65c8c927e 100644 --- a/packages/core/src/integrations/pending-tasks-store.spec.ts +++ b/packages/core/src/integrations/pending-tasks-store.spec.ts @@ -204,6 +204,196 @@ describe("integration pending task store", () => { ); }); + it("resolves valid Slack provenance from the caller's stored task", async () => { + executeMock.mockImplementation(async (query: string | { sql: string }) => { + const sql = typeof query === "string" ? query : query.sql; + if (sql.includes("SELECT platform, payload, owner_email, org_id")) { + return { + rows: [ + { + platform: "slack", + payload: JSON.stringify({ + incoming: { + platform: "slack", + sourceUrl: + "https://example-workspace.slack.com/archives/C123/p123456", + }, + }), + owner_email: "member@example.com", + org_id: "org-a", + }, + ], + }; + } + return { rows: [] }; + }); + const { resolveIntegrationSourceContext } = await loadStore(); + + await expect( + resolveIntegrationSourceContext("task-1", "member@example.com", "org-a"), + ).resolves.toEqual({ + platform: "slack", + sourceUrl: "https://example-workspace.slack.com/archives/C123/p123456", + }); + const select = executeMock.mock.calls + .map(([query]) => query) + .find( + (query): query is { sql: string; args: unknown[] } => + typeof query !== "string" && + query.sql.includes("SELECT platform, payload, owner_email, org_id"), + ); + expect(select?.args).toEqual([ + "task-1", + "member@example.com", + "org-a", + "org-a", + ]); + expect(select?.sql).toContain("owner_email = ?"); + expect(select?.sql).toContain("org_id IS NULL AND ? IS NULL"); + expect(select?.sql).toContain("platform = 'slack'"); + expect(select?.sql).not.toContain("SELECT *"); + }); + + it("returns null for an unknown pending task", async () => { + executeMock.mockResolvedValue({ rows: [] }); + const { resolveIntegrationSourceContext } = await loadStore(); + + await expect( + resolveIntegrationSourceContext( + "missing-task", + "member@example.com", + "org-a", + ), + ).resolves.toBeNull(); + }); + + it.each([ + { + name: "owner mismatch", + task: { + platform: "slack", + payload: JSON.stringify({ + incoming: { + platform: "slack", + sourceUrl: "https://example.slack.com/archives/C1/p1", + }, + }), + ownerEmail: "other@example.com", + orgId: "org-a", + }, + ownerEmail: "member@example.com", + orgId: "org-a", + }, + { + name: "organization mismatch", + task: { + platform: "slack", + payload: JSON.stringify({ + incoming: { + platform: "slack", + sourceUrl: "https://example.slack.com/archives/C1/p1", + }, + }), + ownerEmail: "member@example.com", + orgId: "org-b", + }, + ownerEmail: "member@example.com", + orgId: "org-a", + }, + { + name: "non-Slack task", + task: { + platform: "telegram", + payload: JSON.stringify({ + incoming: { + platform: "telegram", + sourceUrl: "https://example.slack.com/archives/C1/p1", + }, + }), + ownerEmail: "member@example.com", + orgId: "org-a", + }, + ownerEmail: "member@example.com", + orgId: "org-a", + }, + { + name: "malformed payload", + task: { + platform: "slack", + payload: "not-json", + ownerEmail: "member@example.com", + orgId: "org-a", + }, + ownerEmail: "member@example.com", + orgId: "org-a", + }, + { + name: "padded URL", + task: { + platform: "slack", + payload: JSON.stringify({ + incoming: { + platform: "slack", + sourceUrl: " https://example.slack.com/archives/C1/p1 ", + }, + }), + ownerEmail: "member@example.com", + orgId: "org-a", + }, + ownerEmail: "member@example.com", + orgId: "org-a", + }, + { + name: "credential-bearing URL", + task: { + platform: "slack", + payload: JSON.stringify({ + incoming: { + platform: "slack", + sourceUrl: "https://user@example.slack.com/archives/C1/p1", + }, + }), + ownerEmail: "member@example.com", + orgId: "org-a", + }, + ownerEmail: "member@example.com", + orgId: "org-a", + }, + { + name: "malformed URL", + task: { + platform: "slack", + payload: JSON.stringify({ + incoming: { platform: "slack", sourceUrl: "not-a-url" }, + }), + ownerEmail: "member@example.com", + orgId: "org-a", + }, + ownerEmail: "member@example.com", + orgId: "org-a", + }, + { + name: "non-Slack URL", + task: { + platform: "slack", + payload: JSON.stringify({ + incoming: { + platform: "slack", + sourceUrl: "https://example.com/archives/C1/p1", + }, + }), + ownerEmail: "member@example.com", + orgId: "org-a", + }, + ownerEmail: "member@example.com", + orgId: "org-a", + }, + ])("fails closed for $name", async ({ task, ownerEmail, orgId }) => { + const { sourceContextFromPendingTask } = await loadStore(); + + expect(sourceContextFromPendingTask(task, ownerEmail, orgId)).toBeNull(); + }); + it("erases transient provider credentials from terminal task payloads", async () => { executeMock.mockResolvedValue({ rows: [], rowsAffected: 1 }); const { markTaskCompleted, markTaskFailed } = await loadStore(); diff --git a/packages/core/src/integrations/pending-tasks-store.ts b/packages/core/src/integrations/pending-tasks-store.ts index b3d8604cdb..46ff0b5e40 100644 --- a/packages/core/src/integrations/pending-tasks-store.ts +++ b/packages/core/src/integrations/pending-tasks-store.ts @@ -246,6 +246,95 @@ export async function getPendingTask(id: string): Promise { return rowToTask(rows[0] as Record); } +export interface ResolvedIntegrationSourceContext { + platform: "slack"; + sourceUrl: string; +} + +type PendingTaskSourceRow = Pick< + PendingTask, + "platform" | "payload" | "ownerEmail" | "orgId" +>; + +export function sourceContextFromPendingTask( + task: PendingTaskSourceRow | null, + ownerEmail: string, + orgId: string | null, +): ResolvedIntegrationSourceContext | null { + if ( + !task || + task.ownerEmail !== ownerEmail || + task.orgId !== orgId || + task.platform !== "slack" + ) { + return null; + } + + try { + const payload = JSON.parse(task.payload) as Record; + const incoming = payload.incoming; + if (!incoming || typeof incoming !== "object") return null; + const incomingRecord = incoming as Record; + if (incomingRecord.platform !== "slack") return null; + const sourceUrl = incomingRecord.sourceUrl; + if ( + typeof sourceUrl !== "string" || + !sourceUrl || + sourceUrl !== sourceUrl.trim() + ) { + return null; + } + + const parsed = new URL(sourceUrl); + const isSlackHost = + parsed.hostname === "slack.com" || parsed.hostname.endsWith(".slack.com"); + if ( + parsed.protocol !== "https:" || + !isSlackHost || + parsed.username || + parsed.password || + parsed.port + ) { + return null; + } + return { platform: "slack", sourceUrl }; + } catch { + return null; + } +} + +/** Resolve trusted Slack provenance without exposing the stored task payload. */ +export async function resolveIntegrationSourceContext( + id: string, + ownerEmail: string, + orgId: string | null, +): Promise { + await ensureTable(); + const client = getDbExec(); + const { rows } = await client.execute({ + sql: `SELECT platform, payload, owner_email, org_id + FROM integration_pending_tasks + WHERE id = ? + AND owner_email = ? + AND (org_id = ? OR (org_id IS NULL AND ? IS NULL)) + AND platform = 'slack' + LIMIT 1`, + args: [id, ownerEmail, orgId, orgId], + }); + if (rows.length === 0) return null; + const row = rows[0] as Record; + return sourceContextFromPendingTask( + { + platform: row.platform as string, + payload: row.payload as string, + ownerEmail: row.owner_email as string, + orgId: (row.org_id as string | null) ?? null, + }, + ownerEmail, + orgId, + ); +} + /** * Atomically claim a task: transition pending → processing and increment * attempts. Returns the updated task if the transition succeeded, otherwise diff --git a/packages/core/src/integrations/webhook-handler.ts b/packages/core/src/integrations/webhook-handler.ts index c34061c5fd..2d5781690b 100644 --- a/packages/core/src/integrations/webhook-handler.ts +++ b/packages/core/src/integrations/webhook-handler.ts @@ -429,7 +429,7 @@ async function enqueueAndDispatch( options: WebhookHandlerOptions, handlerStartedAt = Date.now(), ): Promise { - const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const taskId = crypto.randomUUID(); // Resolve the org id once at enqueue-time so the processor doesn't have to // re-derive it (and so we can drop it on the row for observability). diff --git a/packages/core/src/scripts/call-agent.spec.ts b/packages/core/src/scripts/call-agent.spec.ts index 82d1c24178..e3b1d28a7f 100644 --- a/packages/core/src/scripts/call-agent.spec.ts +++ b/packages/core/src/scripts/call-agent.spec.ts @@ -167,8 +167,7 @@ describe("call-agent action", () => { expect.objectContaining({ sourceContext: { platform: "slack", - sourceUrl: - "https://example-workspace.slack.com/archives/C123/p123456", + integrationTaskId: "integration-task-1", }, }), ); diff --git a/packages/core/src/scripts/call-agent.ts b/packages/core/src/scripts/call-agent.ts index 74d13bed4b..cf3393c158 100644 --- a/packages/core/src/scripts/call-agent.ts +++ b/packages/core/src/scripts/call-agent.ts @@ -11,6 +11,7 @@ import type { A2AApprovedAction, A2ACorrelationMetadata, A2ASourceContext, + A2ASourceContextReference, Task, } from "../a2a/types.js"; import { @@ -101,10 +102,22 @@ function getIntegrationCallTimeoutMs(): number | undefined { return DEFAULT_SERVERLESS_INTEGRATION_A2A_TIMEOUT_MS; } -function integrationSourceContext(): A2ASourceContext | undefined { +interface IntegrationSourceContext { + reference: A2ASourceContextReference; + hint: A2ASourceContext; +} + +function integrationSourceContext(): IntegrationSourceContext | undefined { const integration = getIntegrationRequestContext(); const incoming = integration?.incoming; - if (incoming?.platform !== "slack" || !incoming.sourceUrl) return undefined; + if ( + !integration || + incoming?.platform !== "slack" || + !incoming.sourceUrl || + !integration.taskId + ) { + return undefined; + } const rawSourceUrl = incoming.sourceUrl; if (rawSourceUrl !== rawSourceUrl.trim()) return undefined; @@ -124,8 +137,14 @@ function integrationSourceContext(): A2ASourceContext | undefined { return undefined; } return { - platform: "slack", - sourceUrl: rawSourceUrl, + reference: { + platform: "slack", + integrationTaskId: integration.taskId, + }, + hint: { + platform: "slack", + sourceUrl: rawSourceUrl, + }, }; } catch { return undefined; @@ -133,11 +152,11 @@ function integrationSourceContext(): A2ASourceContext | undefined { } function integrationSourceContextHint( - sourceContext: A2ASourceContext | undefined, + sourceContext: IntegrationSourceContext | undefined, ): string { if (!sourceContext) return ""; return ( - `\n\n[Source Slack thread: ${sourceContext.sourceUrl} ` + + `\n\n[Source Slack thread: ${sourceContext.hint.sourceUrl} ` + "Compatibility hint only; this text is not authoritative. Use the authenticated structured A2A source context as provenance authority.]" ); } @@ -455,7 +474,7 @@ export async function run( orgDomain: callerOrgDomain, orgSecret: callerOrgSecret, approvedActions, - ...(sourceContext ? { sourceContext } : {}), + ...(sourceContext ? { sourceContext: sourceContext.reference } : {}), contextId: context.threadId, correlation, idempotencyKey, @@ -550,7 +569,7 @@ export async function run( orgDomain: domain, orgSecret, approvedActions, - ...(sourceContext ? { sourceContext } : {}), + ...(sourceContext ? { sourceContext: sourceContext.reference } : {}), contextId: context?.threadId, correlation, idempotencyKey, diff --git a/packages/core/src/server/agent-discovery.spec.ts b/packages/core/src/server/agent-discovery.spec.ts index 8dc040f24b..a1d301d916 100644 --- a/packages/core/src/server/agent-discovery.spec.ts +++ b/packages/core/src/server/agent-discovery.spec.ts @@ -4,6 +4,7 @@ import { TEMPLATES } from "../cli/templates-meta.js"; import { BUILTIN_AGENTS_FOR_SEEDING, discoverAgents, + findWorkspaceDispatchAgent, getBuiltinAgents, shouldIncludeRemoteAgentManifest, } from "./agent-discovery.js"; @@ -277,6 +278,44 @@ describe("agent discovery", () => { }); }); + it("resolves the trusted Dispatch callback only from the workspace manifest", () => { + process.env.APP_URL = "https://workspace.example.test"; + process.env.AGENT_NATIVE_WORKSPACE_APPS_JSON = JSON.stringify({ + apps: [ + { + id: "control-plane", + name: "Workspace Dispatch", + path: "/dispatch", + isDispatch: true, + }, + { + id: "content", + name: "Content", + path: "/content", + isDispatch: false, + }, + ], + }); + + expect(findWorkspaceDispatchAgent()).toMatchObject({ + id: "control-plane", + name: "Workspace Dispatch", + url: "https://workspace.example.test/dispatch", + }); + + process.env.AGENT_NATIVE_WORKSPACE_APPS_JSON = JSON.stringify({ + apps: [ + { + id: "content", + name: "Content", + path: "/content", + isDispatch: false, + }, + ], + }); + expect(findWorkspaceDispatchAgent()).toBeUndefined(); + }); + it("uses explicit workspace manifest URLs without falling back to built-ins", async () => { process.env.AGENT_NATIVE_WORKSPACE_APPS_JSON = JSON.stringify({ version: 1, diff --git a/packages/core/src/server/agent-discovery.ts b/packages/core/src/server/agent-discovery.ts index 9ec7256cac..506d181c1a 100644 --- a/packages/core/src/server/agent-discovery.ts +++ b/packages/core/src/server/agent-discovery.ts @@ -689,6 +689,28 @@ async function discoverWorkspaceAgents( .filter((agent): agent is DiscoveredAgent => !!agent); } +/** Resolve only the Dispatch app designated by the receiver's own manifest. */ +export function findWorkspaceDispatchAgent(): DiscoveredAgent | undefined { + const app = loadWorkspaceAppsManifest()?.find( + (candidate) => candidate.isDispatch === true, + ); + if (!app) return undefined; + + const builtin = BUILTIN_AGENTS.find((agent) => agent.id === "dispatch"); + const url = workspaceAppUrl(app, builtin?.url); + if (!url) return undefined; + return { + id: app.id, + name: app.name, + description: + app.description || + builtin?.description || + `Workspace app mounted at ${app.path}`, + url, + color: builtin?.color || "#6B7280", + }; +} + /** * Like `getBuiltinAgents`, but always returns the production URL — never the * env-resolved devUrl. Used by the resource seeder so that a one-time seed diff --git a/packages/dispatch/src/actions/index.ts b/packages/dispatch/src/actions/index.ts index 896dfe903e..d60de2170e 100644 --- a/packages/dispatch/src/actions/index.ts +++ b/packages/dispatch/src/actions/index.ts @@ -70,6 +70,7 @@ import rejectDreamProposal from "./reject-dream-proposal.js"; import remixWorkspaceTemplate from "./remix-workspace-template.js"; import removePendingWorkspaceApp from "./remove-pending-workspace-app.js"; import requestVaultSecret from "./request-vault-secret.js"; +import resolveIntegrationSourceContext from "./resolve-integration-source-context.js"; import restoreStarterWorkspaceResources from "./restore-starter-workspace-resources.js"; import revokeVaultGrant from "./revoke-vault-grant.js"; import revokeWorkspaceResourceGrant from "./revoke-workspace-resource-grant.js"; @@ -167,6 +168,7 @@ export const dispatchActions: Record = { "reject-dispatch-change": rejectDispatchChange, "reject-dream-proposal": rejectDreamProposal, "remove-pending-workspace-app": removePendingWorkspaceApp, + "resolve-integration-source-context": resolveIntegrationSourceContext, "remix-workspace-template": remixWorkspaceTemplate, "request-vault-secret": requestVaultSecret, "revoke-vault-grant": revokeVaultGrant, diff --git a/packages/dispatch/src/actions/resolve-integration-source-context.spec.ts b/packages/dispatch/src/actions/resolve-integration-source-context.spec.ts new file mode 100644 index 0000000000..a117c8dc1d --- /dev/null +++ b/packages/dispatch/src/actions/resolve-integration-source-context.spec.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + resolveIntegrationSourceContext: vi.fn(), + getRequestUserEmail: vi.fn((): string | undefined => "member@example.com"), + getRequestOrgId: vi.fn((): string | undefined => "org-a"), +})); + +vi.mock("@agent-native/core/integrations", () => ({ + resolveIntegrationSourceContext: mocks.resolveIntegrationSourceContext, +})); + +vi.mock("@agent-native/core/server", () => ({ + getRequestUserEmail: mocks.getRequestUserEmail, + getRequestOrgId: mocks.getRequestOrgId, +})); + +const action = (await import("./resolve-integration-source-context.js")) + .default; + +describe("resolve-integration-source-context", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getRequestUserEmail.mockReturnValue("member@example.com"); + mocks.getRequestOrgId.mockReturnValue("org-a"); + }); + + it("is an authenticated, read-only, non-consequential public action", () => { + expect(action.readOnly).toBe(true); + expect(action.parallelSafe).toBe(true); + expect(action.publicAgent).toEqual({ + expose: true, + readOnly: true, + requiresAuth: true, + isConsequential: false, + }); + }); + + it("resolves provenance only within the caller's exact owner and org scope", async () => { + mocks.resolveIntegrationSourceContext.mockResolvedValue({ + platform: "slack", + sourceUrl: "https://example.slack.com/archives/C1/p1", + }); + + await expect(action.run({ integrationTaskId: "task-1" })).resolves.toEqual({ + platform: "slack", + sourceUrl: "https://example.slack.com/archives/C1/p1", + }); + expect(mocks.resolveIntegrationSourceContext).toHaveBeenCalledWith( + "task-1", + "member@example.com", + "org-a", + ); + }); + + it("passes a null org exactly when the request has no organization", async () => { + mocks.getRequestOrgId.mockReturnValue(undefined); + mocks.resolveIntegrationSourceContext.mockResolvedValue(null); + + await expect( + action.run({ integrationTaskId: "task-personal" }), + ).resolves.toBeNull(); + expect(mocks.resolveIntegrationSourceContext).toHaveBeenCalledWith( + "task-personal", + "member@example.com", + null, + ); + }); + + it("fails closed without an authenticated request identity", async () => { + mocks.getRequestUserEmail.mockReturnValue(undefined); + + await expect(action.run({ integrationTaskId: "task-1" })).rejects.toThrow( + "authenticated user", + ); + expect(mocks.resolveIntegrationSourceContext).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/dispatch/src/actions/resolve-integration-source-context.ts b/packages/dispatch/src/actions/resolve-integration-source-context.ts new file mode 100644 index 0000000000..57b2fcd7f9 --- /dev/null +++ b/packages/dispatch/src/actions/resolve-integration-source-context.ts @@ -0,0 +1,37 @@ +import { defineAction } from "@agent-native/core"; +import { resolveIntegrationSourceContext } from "@agent-native/core/integrations"; +import { + getRequestOrgId, + getRequestUserEmail, +} from "@agent-native/core/server"; +import { z } from "zod"; + +export default defineAction({ + description: + "Resolve trusted Slack source provenance for an integration task owned by the authenticated caller.", + schema: z.object({ + integrationTaskId: z + .string() + .min(1) + .max(256) + .describe("Opaque integration task id"), + }), + http: { method: "GET" }, + readOnly: true, + parallelSafe: true, + publicAgent: { + expose: true, + readOnly: true, + requiresAuth: true, + isConsequential: false, + }, + run: async ({ integrationTaskId }) => { + const ownerEmail = getRequestUserEmail(); + if (!ownerEmail) throw new Error("An authenticated user is required"); + return resolveIntegrationSourceContext( + integrationTaskId, + ownerEmail, + getRequestOrgId() ?? null, + ); + }, +}); diff --git a/packages/dispatch/src/server/plugins/agent-chat.ts b/packages/dispatch/src/server/plugins/agent-chat.ts index 733f5c4f1a..ff21e88f5a 100644 --- a/packages/dispatch/src/server/plugins/agent-chat.ts +++ b/packages/dispatch/src/server/plugins/agent-chat.ts @@ -30,6 +30,7 @@ const INITIAL_TOOL_NAMES = [ export default createAgentChatPlugin({ appId: "dispatch", initialToolNames: INITIAL_TOOL_NAMES, + connectorCatalog: ["resolve-integration-source-context"], // Without this, AGENT_ORG_ID is never set on agent action calls and every // row written through the frontend (vault secrets, destinations, workspace // resources) lands with org_id=NULL — breaking data isolation across orgs.