diff --git a/CHANGELOG.md b/CHANGELOG.md index dbed37c7643..437757f61a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - macOS/onboarding: add a remote gateway token field for remote mode, preserve existing non-plaintext `gateway.remote.token` config values until explicitly replaced, and warn when the loaded token shape cannot be used directly from the macOS app. (#40187, supersedes #34614) Thanks @cgdusek. - CLI/backup: add `openclaw backup create` and `openclaw backup verify` for local state archives, including `--only-config`, `--no-include-workspace`, manifest/payload validation, and backup guidance in destructive flows. (#40163) thanks @shichangs. - CLI/backup: improve archive naming for date sorting, add config-only backup mode, and harden backup planning, publication, and verification edge cases. (#40163) Thanks @gumadeiras. +- ACP/Provenance: add optional ACP ingress provenance metadata and visible receipt injection (`openclaw acp --provenance off|meta|meta+receipt`) so OpenClaw agents can retain and report ACP-origin context with session trace IDs. (#40473) thanks @mbelinky. ### Breaking diff --git a/docs/cli/acp.md b/docs/cli/acp.md index e1fdcf6a398..7650390ed55 100644 --- a/docs/cli/acp.md +++ b/docs/cli/acp.md @@ -96,6 +96,52 @@ Each ACP session maps to a single Gateway session key. One agent can have many sessions; ACP defaults to an isolated `acp:` session unless you override the key or label. +## Use from `acpx` (Codex, Claude, other ACP clients) + +If you want a coding agent such as Codex or Claude Code to talk to your +OpenClaw bot over ACP, use `acpx` with its built-in `openclaw` target. + +Typical flow: + +1. Run the Gateway and make sure the ACP bridge can reach it. +2. Point `acpx openclaw` at `openclaw acp`. +3. Target the OpenClaw session key you want the coding agent to use. + +Examples: + +```bash +# One-shot request into your default OpenClaw ACP session +acpx openclaw exec "Summarize the active OpenClaw session state." + +# Persistent named session for follow-up turns +acpx openclaw sessions ensure --name codex-bridge +acpx openclaw -s codex-bridge --cwd /path/to/repo \ + "Ask my OpenClaw work agent for recent context relevant to this repo." +``` + +If you want `acpx openclaw` to target a specific Gateway and session key every +time, override the `openclaw` agent command in `~/.acpx/config.json`: + +```json +{ + "agents": { + "openclaw": { + "command": "env OPENCLAW_HIDE_BANNER=1 OPENCLAW_SUPPRESS_NOTES=1 openclaw acp --url ws://127.0.0.1:18789 --token-file ~/.openclaw/gateway.token --session agent:main:main" + } + } +} +``` + +For a repo-local OpenClaw checkout, use the direct CLI entrypoint instead of the +dev runner so the ACP stream stays clean. For example: + +```bash +env OPENCLAW_HIDE_BANNER=1 OPENCLAW_SUPPRESS_NOTES=1 node openclaw.mjs acp ... +``` + +This is the easiest way to let Codex, Claude Code, or another ACP-aware client +pull contextual information from an OpenClaw agent without scraping a terminal. + ## Zed editor setup Add a custom ACP agent in `~/.config/zed/settings.json` (or use Zed’s Settings UI): diff --git a/src/acp/server.ts b/src/acp/server.ts index c65dbad202a..c19f48b3631 100644 --- a/src/acp/server.ts +++ b/src/acp/server.ts @@ -10,7 +10,7 @@ import { isMainModule } from "../infra/is-main.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { readSecretFromFile } from "./secret-file.js"; import { AcpGatewayAgent } from "./translator.js"; -import type { AcpServerOptions } from "./types.js"; +import { normalizeAcpProvenanceMode, type AcpServerOptions } from "./types.js"; export async function serveAcpGateway(opts: AcpServerOptions = {}): Promise { const cfg = loadConfig(); @@ -186,6 +186,15 @@ function parseArgs(args: string[]): AcpServerOptions { opts.prefixCwd = false; continue; } + if (arg === "--provenance") { + const provenanceMode = normalizeAcpProvenanceMode(args[i + 1]); + if (!provenanceMode) { + throw new Error("Invalid --provenance value. Use off, meta, or meta+receipt."); + } + opts.provenanceMode = provenanceMode; + i += 1; + continue; + } if (arg === "--verbose" || arg === "-v") { opts.verbose = true; continue; @@ -226,6 +235,7 @@ Options: --require-existing Fail if the session key/label does not exist --reset-session Reset the session key before first use --no-prefix-cwd Do not prefix prompts with the working directory + --provenance ACP provenance mode: off, meta, or meta+receipt --verbose, -v Verbose logging to stderr --help, -h Show this help message `); diff --git a/src/acp/translator.prompt-prefix.test.ts b/src/acp/translator.prompt-prefix.test.ts index f6d2b93d263..38c186519c0 100644 --- a/src/acp/translator.prompt-prefix.test.ts +++ b/src/acp/translator.prompt-prefix.test.ts @@ -81,4 +81,117 @@ describe("acp prompt cwd prefix", () => { { expectFinal: true }, ); }); + + it("injects system provenance metadata when enabled", async () => { + const sessionStore = createInMemorySessionStore(); + sessionStore.createSession({ + sessionId: "session-1", + sessionKey: "agent:main:main", + cwd: path.join(os.homedir(), "openclaw-test"), + }); + + const requestSpy = vi.fn(async (method: string) => { + if (method === "chat.send") { + throw new Error("stop-after-send"); + } + return {}; + }); + const agent = new AcpGatewayAgent( + createAcpConnection(), + createAcpGateway(requestSpy as unknown as GatewayClient["request"]), + { + sessionStore, + provenanceMode: "meta", + }, + ); + + await expect( + agent.prompt({ + sessionId: "session-1", + prompt: [{ type: "text", text: "hello" }], + _meta: {}, + } as unknown as PromptRequest), + ).rejects.toThrow("stop-after-send"); + + expect(requestSpy).toHaveBeenCalledWith( + "chat.send", + expect.objectContaining({ + systemInputProvenance: { + kind: "external_user", + originSessionId: "session-1", + sourceChannel: "acp", + sourceTool: "openclaw_acp", + }, + systemProvenanceReceipt: undefined, + }), + { expectFinal: true }, + ); + }); + + it("injects a system provenance receipt when requested", async () => { + const sessionStore = createInMemorySessionStore(); + sessionStore.createSession({ + sessionId: "session-1", + sessionKey: "agent:main:main", + cwd: path.join(os.homedir(), "openclaw-test"), + }); + + const requestSpy = vi.fn(async (method: string) => { + if (method === "chat.send") { + throw new Error("stop-after-send"); + } + return {}; + }); + const agent = new AcpGatewayAgent( + createAcpConnection(), + createAcpGateway(requestSpy as unknown as GatewayClient["request"]), + { + sessionStore, + provenanceMode: "meta+receipt", + }, + ); + + await expect( + agent.prompt({ + sessionId: "session-1", + prompt: [{ type: "text", text: "hello" }], + _meta: {}, + } as unknown as PromptRequest), + ).rejects.toThrow("stop-after-send"); + + expect(requestSpy).toHaveBeenCalledWith( + "chat.send", + expect.objectContaining({ + systemInputProvenance: { + kind: "external_user", + originSessionId: "session-1", + sourceChannel: "acp", + sourceTool: "openclaw_acp", + }, + systemProvenanceReceipt: expect.stringContaining("[Source Receipt]"), + }), + { expectFinal: true }, + ); + expect(requestSpy).toHaveBeenCalledWith( + "chat.send", + expect.objectContaining({ + systemProvenanceReceipt: expect.stringContaining("bridge=openclaw-acp"), + }), + { expectFinal: true }, + ); + expect(requestSpy).toHaveBeenCalledWith( + "chat.send", + expect.objectContaining({ + systemProvenanceReceipt: expect.stringContaining("originSessionId=session-1"), + }), + { expectFinal: true }, + ); + expect(requestSpy).toHaveBeenCalledWith( + "chat.send", + expect.objectContaining({ + systemProvenanceReceipt: expect.stringContaining("targetSession=agent:main:main"), + }), + { expectFinal: true }, + ); + }); }); diff --git a/src/acp/translator.ts b/src/acp/translator.ts index c7cf3739a9a..0dbf3099c3d 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import os from "node:os"; import type { Agent, AgentSideConnection, @@ -61,6 +62,32 @@ type AcpGatewayAgentOptions = AcpServerOptions & { const SESSION_CREATE_RATE_LIMIT_DEFAULT_MAX_REQUESTS = 120; const SESSION_CREATE_RATE_LIMIT_DEFAULT_WINDOW_MS = 10_000; +function buildSystemInputProvenance(originSessionId: string) { + return { + kind: "external_user" as const, + originSessionId, + sourceChannel: "acp", + sourceTool: "openclaw_acp", + }; +} + +function buildSystemProvenanceReceipt(params: { + cwd: string; + sessionId: string; + sessionKey: string; +}) { + return [ + "[Source Receipt]", + "bridge=openclaw-acp", + `originHost=${os.hostname()}`, + `originCwd=${shortenHomePath(params.cwd)}`, + `acpSessionId=${params.sessionId}`, + `originSessionId=${params.sessionId}`, + `targetSession=${params.sessionKey}`, + "[/Source Receipt]", + ].join("\n"); +} + export class AcpGatewayAgent implements Agent { private connection: AgentSideConnection; private gateway: GatewayClient; @@ -251,6 +278,17 @@ export class AcpGatewayAgent implements Agent { const prefixCwd = meta.prefixCwd ?? this.opts.prefixCwd ?? true; const displayCwd = shortenHomePath(session.cwd); const message = prefixCwd ? `[Working directory: ${displayCwd}]\n\n${userText}` : userText; + const provenanceMode = this.opts.provenanceMode ?? "off"; + const systemInputProvenance = + provenanceMode === "off" ? undefined : buildSystemInputProvenance(params.sessionId); + const systemProvenanceReceipt = + provenanceMode === "meta+receipt" + ? buildSystemProvenanceReceipt({ + cwd: session.cwd, + sessionId: params.sessionId, + sessionKey: session.sessionKey, + }) + : undefined; // Defense-in-depth: also check the final assembled message (includes cwd prefix) if (Buffer.byteLength(message, "utf-8") > MAX_PROMPT_BYTES) { @@ -281,6 +319,8 @@ export class AcpGatewayAgent implements Agent { thinking: readString(params._meta, ["thinking", "thinkingLevel"]), deliver: readBool(params._meta, ["deliver"]), timeoutMs: readNumber(params._meta, ["timeoutMs"]), + systemInputProvenance, + systemProvenanceReceipt, }, { expectFinal: true }, ) diff --git a/src/acp/types.ts b/src/acp/types.ts index b266f6a5eef..101cbe9c4a3 100644 --- a/src/acp/types.ts +++ b/src/acp/types.ts @@ -1,6 +1,22 @@ import type { SessionId } from "@agentclientprotocol/sdk"; import { VERSION } from "../version.js"; +export const ACP_PROVENANCE_MODE_VALUES = ["off", "meta", "meta+receipt"] as const; + +export type AcpProvenanceMode = (typeof ACP_PROVENANCE_MODE_VALUES)[number]; + +export function normalizeAcpProvenanceMode( + value: string | undefined, +): AcpProvenanceMode | undefined { + if (!value) { + return undefined; + } + const normalized = value.trim().toLowerCase(); + return (ACP_PROVENANCE_MODE_VALUES as readonly string[]).includes(normalized) + ? (normalized as AcpProvenanceMode) + : undefined; +} + export type AcpSession = { sessionId: SessionId; sessionKey: string; @@ -20,6 +36,7 @@ export type AcpServerOptions = { requireExistingSession?: boolean; resetSession?: boolean; prefixCwd?: boolean; + provenanceMode?: AcpProvenanceMode; sessionCreateRateLimit?: { maxRequests?: number; windowMs?: number; diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index b7ec4858e51..36e45bd9bf1 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -175,6 +175,7 @@ export function buildEmbeddedRunBaseParams(params: { config: params.run.config, skillsSnapshot: params.run.skillsSnapshot, ownerNumbers: params.run.ownerNumbers, + inputProvenance: params.run.inputProvenance, senderIsOwner: params.run.senderIsOwner, enforceFinalTag: resolveEnforceFinalTag(params.run, params.provider), provider: params.provider, diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 704688ddf6d..dceac522eca 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -521,6 +521,7 @@ export async function runPreparedReply( timeoutMs, blockReplyBreak: resolvedBlockStreamingBreak, ownerNumbers: command.ownerList.length > 0 ? command.ownerList : undefined, + inputProvenance: ctx.InputProvenance ?? sessionCtx.InputProvenance, extraSystemPrompt: extraSystemPromptParts.join("\n\n") || undefined, ...(isReasoningTagProvider(provider) ? { enforceFinalTag: true } : {}), }, diff --git a/src/auto-reply/reply/queue/types.ts b/src/auto-reply/reply/queue/types.ts index 929f02e0726..507f77d499d 100644 --- a/src/auto-reply/reply/queue/types.ts +++ b/src/auto-reply/reply/queue/types.ts @@ -2,6 +2,7 @@ import type { ExecToolDefaults } from "../../../agents/bash-tools.js"; import type { SkillSnapshot } from "../../../agents/skills.js"; import type { OpenClawConfig } from "../../../config/config.js"; import type { SessionEntry } from "../../../config/sessions.js"; +import type { InputProvenance } from "../../../sessions/input-provenance.js"; import type { OriginatingChannelType } from "../../templating.js"; import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../directives.js"; @@ -77,6 +78,7 @@ export type FollowupRun = { timeoutMs: number; blockReplyBreak: "text_end" | "message_end"; ownerNumbers?: string[]; + inputProvenance?: InputProvenance; extraSystemPrompt?: string; enforceFinalTag?: boolean; }; diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index ae6a7917ff8..cc4fc49e93f 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -3,6 +3,7 @@ import type { MediaUnderstandingDecision, MediaUnderstandingOutput, } from "../media-understanding/types.js"; +import type { InputProvenance } from "../sessions/input-provenance.js"; import type { StickerMetadata } from "../telegram/bot/types.js"; import type { InternalMessageChannel } from "../utils/message-channel.js"; import type { CommandArgs } from "./commands-registry.types.js"; @@ -117,6 +118,8 @@ export type MsgContext = { GroupSystemPrompt?: string; /** Untrusted metadata that must not be treated as system instructions. */ UntrustedContext?: string[]; + /** System-attached provenance for the current inbound message. */ + InputProvenance?: InputProvenance; /** Explicit owner allowlist overrides (trusted, configuration-derived). */ OwnerAllowFrom?: Array; SenderName?: string; diff --git a/src/cli/acp-cli.ts b/src/cli/acp-cli.ts index c4c7b09aeaf..a769e234592 100644 --- a/src/cli/acp-cli.ts +++ b/src/cli/acp-cli.ts @@ -2,6 +2,7 @@ import type { Command } from "commander"; import { runAcpClientInteractive } from "../acp/client.js"; import { readSecretFromFile } from "../acp/secret-file.js"; import { serveAcpGateway } from "../acp/server.js"; +import { normalizeAcpProvenanceMode } from "../acp/types.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; @@ -45,6 +46,7 @@ export function registerAcpCli(program: Command) { .option("--require-existing", "Fail if the session key/label does not exist", false) .option("--reset-session", "Reset the session key before first use", false) .option("--no-prefix-cwd", "Do not prefix prompts with the working directory", false) + .option("--provenance ", "ACP provenance mode: off, meta, or meta+receipt") .option("-v, --verbose", "Verbose logging to stderr", false) .addHelpText( "after", @@ -72,6 +74,10 @@ export function registerAcpCli(program: Command) { if (opts.password) { warnSecretCliFlag("--password"); } + const provenanceMode = normalizeAcpProvenanceMode(opts.provenance as string | undefined); + if (opts.provenance && !provenanceMode) { + throw new Error("Invalid --provenance value. Use off, meta, or meta+receipt."); + } await serveAcpGateway({ gatewayUrl: opts.url as string | undefined, gatewayToken, @@ -81,6 +87,7 @@ export function registerAcpCli(program: Command) { requireExistingSession: Boolean(opts.requireExisting), resetSession: Boolean(opts.resetSession), prefixCwd: !opts.noPrefixCwd, + provenanceMode, verbose: Boolean(opts.verbose), }); } catch (err) { diff --git a/src/gateway/protocol/schema/agent.ts b/src/gateway/protocol/schema/agent.ts index 68b3fb0b83c..75d560ba92b 100644 --- a/src/gateway/protocol/schema/agent.ts +++ b/src/gateway/protocol/schema/agent.ts @@ -100,6 +100,7 @@ export const AgentParamsSchema = Type.Object( Type.Object( { kind: Type.String({ enum: [...INPUT_PROVENANCE_KIND_VALUES] }), + originSessionId: Type.Optional(Type.String()), sourceSessionKey: Type.Optional(Type.String()), sourceChannel: Type.Optional(Type.String()), sourceTool: Type.Optional(Type.String()), diff --git a/src/gateway/protocol/schema/logs-chat.ts b/src/gateway/protocol/schema/logs-chat.ts index ffa01945c01..5545bd443f1 100644 --- a/src/gateway/protocol/schema/logs-chat.ts +++ b/src/gateway/protocol/schema/logs-chat.ts @@ -1,4 +1,5 @@ import { Type } from "@sinclair/typebox"; +import { INPUT_PROVENANCE_KIND_VALUES } from "../../../sessions/input-provenance.js"; import { ChatSendSessionKeyString, NonEmptyString } from "./primitives.js"; export const LogsTailParamsSchema = Type.Object( @@ -39,6 +40,19 @@ export const ChatSendParamsSchema = Type.Object( deliver: Type.Optional(Type.Boolean()), attachments: Type.Optional(Type.Array(Type.Unknown())), timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })), + systemInputProvenance: Type.Optional( + Type.Object( + { + kind: Type.String({ enum: [...INPUT_PROVENANCE_KIND_VALUES] }), + originSessionId: Type.Optional(Type.String()), + sourceSessionKey: Type.Optional(Type.String()), + sourceChannel: Type.Optional(Type.String()), + sourceTool: Type.Optional(Type.String()), + }, + { additionalProperties: false }, + ), + ), + systemProvenanceReceipt: Type.Optional(Type.String()), idempotencyKey: NonEmptyString, }, { additionalProperties: false }, diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 37f5a0cfb6f..1415ef6d6f7 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -158,6 +158,8 @@ async function runNonStreamingChatSend(params: { deliver?: boolean; client?: unknown; expectBroadcast?: boolean; + requestParams?: Record; + waitForCompletion?: boolean; }) { const sendParams: { sessionKey: string; @@ -173,7 +175,10 @@ async function runNonStreamingChatSend(params: { sendParams.deliver = params.deliver; } await chatHandlers["chat.send"]({ - params: sendParams, + params: { + ...sendParams, + ...params.requestParams, + }, respond: params.respond as unknown as Parameters< (typeof chatHandlers)["chat.send"] >[0]["respond"], @@ -185,6 +190,9 @@ async function runNonStreamingChatSend(params: { const shouldExpectBroadcast = params.expectBroadcast ?? true; if (!shouldExpectBroadcast) { + if (params.waitForCompletion === false) { + return undefined; + } await vi.waitFor(() => { expect(params.context.dedupe.has(`chat:${params.idempotencyKey}`)).toBe(true); }, FAST_WAIT_OPTS); @@ -885,4 +893,77 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }), ); }); + + it("rejects reserved system provenance fields for non-ACP clients", async () => { + createTranscriptFixture("openclaw-chat-send-system-provenance-reject-"); + mockState.finalText = "ok"; + const respond = vi.fn(); + const context = createChatContext(); + + await runNonStreamingChatSend({ + context, + respond, + idempotencyKey: "idem-system-provenance-reject", + requestParams: { + systemInputProvenance: { kind: "external_user", sourceChannel: "acp" }, + systemProvenanceReceipt: "[Source Receipt]\nbridge=openclaw-acp\n[/Source Receipt]", + }, + expectBroadcast: false, + waitForCompletion: false, + }); + + const [ok, _payload, error] = respond.mock.calls.at(-1) ?? []; + expect(ok).toBe(false); + expect(error).toMatchObject({ + message: "system provenance fields are reserved for the ACP bridge", + }); + expect(mockState.lastDispatchCtx).toBeUndefined(); + }); + + it("injects ACP system provenance into the agent-visible body", async () => { + createTranscriptFixture("openclaw-chat-send-system-provenance-acp-"); + mockState.finalText = "ok"; + const respond = vi.fn(); + const context = createChatContext(); + + await runNonStreamingChatSend({ + context, + respond, + idempotencyKey: "idem-system-provenance-acp", + message: "bench update", + client: { + connect: { + client: { + id: "cli", + mode: "cli", + displayName: "ACP", + version: "acp", + }, + }, + }, + requestParams: { + systemInputProvenance: { + kind: "external_user", + originSessionId: "acp-session-1", + sourceChannel: "acp", + sourceTool: "openclaw_acp", + }, + systemProvenanceReceipt: + "[Source Receipt]\nbridge=openclaw-acp\noriginSessionId=acp-session-1\n[/Source Receipt]", + }, + expectBroadcast: false, + }); + + expect(mockState.lastDispatchCtx?.InputProvenance).toEqual({ + kind: "external_user", + originSessionId: "acp-session-1", + sourceChannel: "acp", + sourceTool: "openclaw_acp", + }); + expect(mockState.lastDispatchCtx?.Body).toBe( + "[Source Receipt]\nbridge=openclaw-acp\noriginSessionId=acp-session-1\n[/Source Receipt]\n\nbench update", + ); + expect(mockState.lastDispatchCtx?.RawBody).toBe("bench update"); + expect(mockState.lastDispatchCtx?.CommandBody).toBe("bench update"); + }); }); diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 7b4adb5cd78..71669080382 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -11,6 +11,7 @@ import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.j import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; import { resolveSessionFilePath } from "../../config/sessions.js"; import { jsonUtf8Bytes } from "../../infra/json-utf8-bytes.js"; +import { normalizeInputProvenance, type InputProvenance } from "../../sessions/input-provenance.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; import { @@ -32,7 +33,12 @@ import { } from "../chat-abort.js"; import { type ChatImageContent, parseMessageWithAttachments } from "../chat-attachments.js"; import { stripEnvelopeFromMessage, stripEnvelopeFromMessages } from "../chat-sanitize.js"; -import { GATEWAY_CLIENT_CAPS, hasGatewayClientCap } from "../protocol/client-info.js"; +import { + GATEWAY_CLIENT_CAPS, + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, + hasGatewayClientCap, +} from "../protocol/client-info.js"; import { ErrorCodes, errorShape, @@ -55,7 +61,11 @@ import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js"; import { setGatewayDedupeEntry } from "./agent-wait-dedupe.js"; import { normalizeRpcAttachmentsToChatAttachments } from "./attachment-normalize.js"; import { appendInjectedAssistantMessageToTranscript } from "./chat-transcript-inject.js"; -import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js"; +import type { + GatewayRequestContext, + GatewayRequestHandlerOptions, + GatewayRequestHandlers, +} from "./types.js"; type TranscriptAppendResult = { ok: boolean; @@ -225,6 +235,33 @@ export function sanitizeChatSendMessageInput( return { ok: true, message: stripDisallowedChatControlChars(normalized) }; } +function normalizeOptionalChatSystemReceipt( + value: unknown, +): { ok: true; receipt?: string } | { ok: false; error: string } { + if (value == null) { + return { ok: true }; + } + if (typeof value !== "string") { + return { ok: false, error: "systemProvenanceReceipt must be a string" }; + } + const sanitized = sanitizeChatSendMessageInput(value); + if (!sanitized.ok) { + return sanitized; + } + const receipt = sanitized.message.trim(); + return { ok: true, receipt: receipt || undefined }; +} + +function isAcpBridgeClient(client: GatewayRequestHandlerOptions["client"]): boolean { + const info = client?.connect?.client; + return ( + info?.id === GATEWAY_CLIENT_NAMES.CLI && + info?.mode === GATEWAY_CLIENT_MODES.CLI && + info?.displayName === "ACP" && + info?.version === "acp" + ); +} + function truncateChatHistoryText(text: string): { text: string; truncated: boolean } { if (text.length <= CHAT_HISTORY_TEXT_MAX_CHARS) { return { text, truncated: false }; @@ -860,8 +897,21 @@ export const chatHandlers: GatewayRequestHandlers = { content?: unknown; }>; timeoutMs?: number; + systemInputProvenance?: InputProvenance; + systemProvenanceReceipt?: string; idempotencyKey: string; }; + if ((p.systemInputProvenance || p.systemProvenanceReceipt) && !isAcpBridgeClient(client)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "system provenance fields are reserved for the ACP bridge", + ), + ); + return; + } const sanitizedMessageResult = sanitizeChatSendMessageInput(p.message); if (!sanitizedMessageResult.ok) { respond( @@ -871,7 +921,14 @@ export const chatHandlers: GatewayRequestHandlers = { ); return; } + const systemReceiptResult = normalizeOptionalChatSystemReceipt(p.systemProvenanceReceipt); + if (!systemReceiptResult.ok) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, systemReceiptResult.error)); + return; + } const inboundMessage = sanitizedMessageResult.message; + const systemInputProvenance = normalizeInputProvenance(p.systemInputProvenance); + const systemProvenanceReceipt = systemReceiptResult.receipt; const stopCommand = isChatStopCommandText(inboundMessage); const normalizedAttachments = normalizeRpcAttachmentsToChatAttachments(p.attachments); const rawMessage = inboundMessage.trim(); @@ -972,6 +1029,9 @@ export const chatHandlers: GatewayRequestHandlers = { p.thinking && trimmedMessage && !trimmedMessage.startsWith("/"), ); const commandBody = injectThinking ? `/think ${p.thinking} ${parsedMessage}` : parsedMessage; + const messageForAgent = systemProvenanceReceipt + ? [systemProvenanceReceipt, parsedMessage].filter(Boolean).join("\n\n") + : parsedMessage; const clientInfo = client?.connect?.client; const { originatingChannel, @@ -990,14 +1050,15 @@ export const chatHandlers: GatewayRequestHandlers = { // Inject timestamp so agents know the current date/time. // Only BodyForAgent gets the timestamp — Body stays raw for UI display. // See: https://github.com/moltbot/moltbot/issues/3658 - const stampedMessage = injectTimestamp(parsedMessage, timestampOptsFromConfig(cfg)); + const stampedMessage = injectTimestamp(messageForAgent, timestampOptsFromConfig(cfg)); const ctx: MsgContext = { - Body: parsedMessage, + Body: messageForAgent, BodyForAgent: stampedMessage, BodyForCommands: commandBody, RawBody: parsedMessage, CommandBody: commandBody, + InputProvenance: systemInputProvenance, SessionKey: sessionKey, Provider: INTERNAL_MESSAGE_CHANNEL, Surface: INTERNAL_MESSAGE_CHANNEL, diff --git a/src/sessions/input-provenance.ts b/src/sessions/input-provenance.ts index 4540e680612..7dc228eb320 100644 --- a/src/sessions/input-provenance.ts +++ b/src/sessions/input-provenance.ts @@ -10,6 +10,7 @@ export type InputProvenanceKind = (typeof INPUT_PROVENANCE_KIND_VALUES)[number]; export type InputProvenance = { kind: InputProvenanceKind; + originSessionId?: string; sourceSessionKey?: string; sourceChannel?: string; sourceTool?: string; @@ -39,6 +40,7 @@ export function normalizeInputProvenance(value: unknown): InputProvenance | unde } return { kind: record.kind, + originSessionId: normalizeOptionalString(record.originSessionId), sourceSessionKey: normalizeOptionalString(record.sourceSessionKey), sourceChannel: normalizeOptionalString(record.sourceChannel), sourceTool: normalizeOptionalString(record.sourceTool),