mirror of https://github.com/openclaw/openclaw.git
ACP: add optional ingress provenance receipts (#40473)
Merged via squash.
Prepared head SHA: b63e46dd94
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
parent
4d501e4ccf
commit
e3df94365b
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:<uuid>` 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):
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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 <mode> ACP provenance mode: off, meta, or meta+receipt
|
||||
--verbose, -v Verbose logging to stderr
|
||||
--help, -h Show this help message
|
||||
`);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<string | number>;
|
||||
SenderName?: string;
|
||||
|
|
|
|||
|
|
@ -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 <mode>", "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) {
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -158,6 +158,8 @@ async function runNonStreamingChatSend(params: {
|
|||
deliver?: boolean;
|
||||
client?: unknown;
|
||||
expectBroadcast?: boolean;
|
||||
requestParams?: Record<string, unknown>;
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue