Skip to content

Commit e3df943

Browse files
authored
ACP: add optional ingress provenance receipts (#40473)
Merged via squash. Prepared head SHA: b63e46d Co-authored-by: mbelinky <[email protected]> Co-authored-by: mbelinky <[email protected]> Reviewed-by: @mbelinky
1 parent 4d501e4 commit e3df943

File tree

16 files changed

+406
-6
lines changed

16 files changed

+406
-6
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
1414
- 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.
1515
- 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.
1616
- 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.
17+
- 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.
1718

1819
### Breaking
1920

docs/cli/acp.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,52 @@ Each ACP session maps to a single Gateway session key. One agent can have many
9696
sessions; ACP defaults to an isolated `acp:<uuid>` session unless you override
9797
the key or label.
9898

99+
## Use from `acpx` (Codex, Claude, other ACP clients)
100+
101+
If you want a coding agent such as Codex or Claude Code to talk to your
102+
OpenClaw bot over ACP, use `acpx` with its built-in `openclaw` target.
103+
104+
Typical flow:
105+
106+
1. Run the Gateway and make sure the ACP bridge can reach it.
107+
2. Point `acpx openclaw` at `openclaw acp`.
108+
3. Target the OpenClaw session key you want the coding agent to use.
109+
110+
Examples:
111+
112+
```bash
113+
# One-shot request into your default OpenClaw ACP session
114+
acpx openclaw exec "Summarize the active OpenClaw session state."
115+
116+
# Persistent named session for follow-up turns
117+
acpx openclaw sessions ensure --name codex-bridge
118+
acpx openclaw -s codex-bridge --cwd /path/to/repo \
119+
"Ask my OpenClaw work agent for recent context relevant to this repo."
120+
```
121+
122+
If you want `acpx openclaw` to target a specific Gateway and session key every
123+
time, override the `openclaw` agent command in `~/.acpx/config.json`:
124+
125+
```json
126+
{
127+
"agents": {
128+
"openclaw": {
129+
"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"
130+
}
131+
}
132+
}
133+
```
134+
135+
For a repo-local OpenClaw checkout, use the direct CLI entrypoint instead of the
136+
dev runner so the ACP stream stays clean. For example:
137+
138+
```bash
139+
env OPENCLAW_HIDE_BANNER=1 OPENCLAW_SUPPRESS_NOTES=1 node openclaw.mjs acp ...
140+
```
141+
142+
This is the easiest way to let Codex, Claude Code, or another ACP-aware client
143+
pull contextual information from an OpenClaw agent without scraping a terminal.
144+
99145
## Zed editor setup
100146

101147
Add a custom ACP agent in `~/.config/zed/settings.json` (or use Zed’s Settings UI):

src/acp/server.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { isMainModule } from "../infra/is-main.js";
1010
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
1111
import { readSecretFromFile } from "./secret-file.js";
1212
import { AcpGatewayAgent } from "./translator.js";
13-
import type { AcpServerOptions } from "./types.js";
13+
import { normalizeAcpProvenanceMode, type AcpServerOptions } from "./types.js";
1414

1515
export async function serveAcpGateway(opts: AcpServerOptions = {}): Promise<void> {
1616
const cfg = loadConfig();
@@ -186,6 +186,15 @@ function parseArgs(args: string[]): AcpServerOptions {
186186
opts.prefixCwd = false;
187187
continue;
188188
}
189+
if (arg === "--provenance") {
190+
const provenanceMode = normalizeAcpProvenanceMode(args[i + 1]);
191+
if (!provenanceMode) {
192+
throw new Error("Invalid --provenance value. Use off, meta, or meta+receipt.");
193+
}
194+
opts.provenanceMode = provenanceMode;
195+
i += 1;
196+
continue;
197+
}
189198
if (arg === "--verbose" || arg === "-v") {
190199
opts.verbose = true;
191200
continue;
@@ -226,6 +235,7 @@ Options:
226235
--require-existing Fail if the session key/label does not exist
227236
--reset-session Reset the session key before first use
228237
--no-prefix-cwd Do not prefix prompts with the working directory
238+
--provenance <mode> ACP provenance mode: off, meta, or meta+receipt
229239
--verbose, -v Verbose logging to stderr
230240
--help, -h Show this help message
231241
`);

src/acp/translator.prompt-prefix.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,117 @@ describe("acp prompt cwd prefix", () => {
8181
{ expectFinal: true },
8282
);
8383
});
84+
85+
it("injects system provenance metadata when enabled", async () => {
86+
const sessionStore = createInMemorySessionStore();
87+
sessionStore.createSession({
88+
sessionId: "session-1",
89+
sessionKey: "agent:main:main",
90+
cwd: path.join(os.homedir(), "openclaw-test"),
91+
});
92+
93+
const requestSpy = vi.fn(async (method: string) => {
94+
if (method === "chat.send") {
95+
throw new Error("stop-after-send");
96+
}
97+
return {};
98+
});
99+
const agent = new AcpGatewayAgent(
100+
createAcpConnection(),
101+
createAcpGateway(requestSpy as unknown as GatewayClient["request"]),
102+
{
103+
sessionStore,
104+
provenanceMode: "meta",
105+
},
106+
);
107+
108+
await expect(
109+
agent.prompt({
110+
sessionId: "session-1",
111+
prompt: [{ type: "text", text: "hello" }],
112+
_meta: {},
113+
} as unknown as PromptRequest),
114+
).rejects.toThrow("stop-after-send");
115+
116+
expect(requestSpy).toHaveBeenCalledWith(
117+
"chat.send",
118+
expect.objectContaining({
119+
systemInputProvenance: {
120+
kind: "external_user",
121+
originSessionId: "session-1",
122+
sourceChannel: "acp",
123+
sourceTool: "openclaw_acp",
124+
},
125+
systemProvenanceReceipt: undefined,
126+
}),
127+
{ expectFinal: true },
128+
);
129+
});
130+
131+
it("injects a system provenance receipt when requested", async () => {
132+
const sessionStore = createInMemorySessionStore();
133+
sessionStore.createSession({
134+
sessionId: "session-1",
135+
sessionKey: "agent:main:main",
136+
cwd: path.join(os.homedir(), "openclaw-test"),
137+
});
138+
139+
const requestSpy = vi.fn(async (method: string) => {
140+
if (method === "chat.send") {
141+
throw new Error("stop-after-send");
142+
}
143+
return {};
144+
});
145+
const agent = new AcpGatewayAgent(
146+
createAcpConnection(),
147+
createAcpGateway(requestSpy as unknown as GatewayClient["request"]),
148+
{
149+
sessionStore,
150+
provenanceMode: "meta+receipt",
151+
},
152+
);
153+
154+
await expect(
155+
agent.prompt({
156+
sessionId: "session-1",
157+
prompt: [{ type: "text", text: "hello" }],
158+
_meta: {},
159+
} as unknown as PromptRequest),
160+
).rejects.toThrow("stop-after-send");
161+
162+
expect(requestSpy).toHaveBeenCalledWith(
163+
"chat.send",
164+
expect.objectContaining({
165+
systemInputProvenance: {
166+
kind: "external_user",
167+
originSessionId: "session-1",
168+
sourceChannel: "acp",
169+
sourceTool: "openclaw_acp",
170+
},
171+
systemProvenanceReceipt: expect.stringContaining("[Source Receipt]"),
172+
}),
173+
{ expectFinal: true },
174+
);
175+
expect(requestSpy).toHaveBeenCalledWith(
176+
"chat.send",
177+
expect.objectContaining({
178+
systemProvenanceReceipt: expect.stringContaining("bridge=openclaw-acp"),
179+
}),
180+
{ expectFinal: true },
181+
);
182+
expect(requestSpy).toHaveBeenCalledWith(
183+
"chat.send",
184+
expect.objectContaining({
185+
systemProvenanceReceipt: expect.stringContaining("originSessionId=session-1"),
186+
}),
187+
{ expectFinal: true },
188+
);
189+
expect(requestSpy).toHaveBeenCalledWith(
190+
"chat.send",
191+
expect.objectContaining({
192+
systemProvenanceReceipt: expect.stringContaining("targetSession=agent:main:main"),
193+
}),
194+
{ expectFinal: true },
195+
);
196+
});
84197
});

src/acp/translator.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { randomUUID } from "node:crypto";
2+
import os from "node:os";
23
import type {
34
Agent,
45
AgentSideConnection,
@@ -61,6 +62,32 @@ type AcpGatewayAgentOptions = AcpServerOptions & {
6162
const SESSION_CREATE_RATE_LIMIT_DEFAULT_MAX_REQUESTS = 120;
6263
const SESSION_CREATE_RATE_LIMIT_DEFAULT_WINDOW_MS = 10_000;
6364

65+
function buildSystemInputProvenance(originSessionId: string) {
66+
return {
67+
kind: "external_user" as const,
68+
originSessionId,
69+
sourceChannel: "acp",
70+
sourceTool: "openclaw_acp",
71+
};
72+
}
73+
74+
function buildSystemProvenanceReceipt(params: {
75+
cwd: string;
76+
sessionId: string;
77+
sessionKey: string;
78+
}) {
79+
return [
80+
"[Source Receipt]",
81+
"bridge=openclaw-acp",
82+
`originHost=${os.hostname()}`,
83+
`originCwd=${shortenHomePath(params.cwd)}`,
84+
`acpSessionId=${params.sessionId}`,
85+
`originSessionId=${params.sessionId}`,
86+
`targetSession=${params.sessionKey}`,
87+
"[/Source Receipt]",
88+
].join("\n");
89+
}
90+
6491
export class AcpGatewayAgent implements Agent {
6592
private connection: AgentSideConnection;
6693
private gateway: GatewayClient;
@@ -251,6 +278,17 @@ export class AcpGatewayAgent implements Agent {
251278
const prefixCwd = meta.prefixCwd ?? this.opts.prefixCwd ?? true;
252279
const displayCwd = shortenHomePath(session.cwd);
253280
const message = prefixCwd ? `[Working directory: ${displayCwd}]\n\n${userText}` : userText;
281+
const provenanceMode = this.opts.provenanceMode ?? "off";
282+
const systemInputProvenance =
283+
provenanceMode === "off" ? undefined : buildSystemInputProvenance(params.sessionId);
284+
const systemProvenanceReceipt =
285+
provenanceMode === "meta+receipt"
286+
? buildSystemProvenanceReceipt({
287+
cwd: session.cwd,
288+
sessionId: params.sessionId,
289+
sessionKey: session.sessionKey,
290+
})
291+
: undefined;
254292

255293
// Defense-in-depth: also check the final assembled message (includes cwd prefix)
256294
if (Buffer.byteLength(message, "utf-8") > MAX_PROMPT_BYTES) {
@@ -281,6 +319,8 @@ export class AcpGatewayAgent implements Agent {
281319
thinking: readString(params._meta, ["thinking", "thinkingLevel"]),
282320
deliver: readBool(params._meta, ["deliver"]),
283321
timeoutMs: readNumber(params._meta, ["timeoutMs"]),
322+
systemInputProvenance,
323+
systemProvenanceReceipt,
284324
},
285325
{ expectFinal: true },
286326
)

src/acp/types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,22 @@
11
import type { SessionId } from "@agentclientprotocol/sdk";
22
import { VERSION } from "../version.js";
33

4+
export const ACP_PROVENANCE_MODE_VALUES = ["off", "meta", "meta+receipt"] as const;
5+
6+
export type AcpProvenanceMode = (typeof ACP_PROVENANCE_MODE_VALUES)[number];
7+
8+
export function normalizeAcpProvenanceMode(
9+
value: string | undefined,
10+
): AcpProvenanceMode | undefined {
11+
if (!value) {
12+
return undefined;
13+
}
14+
const normalized = value.trim().toLowerCase();
15+
return (ACP_PROVENANCE_MODE_VALUES as readonly string[]).includes(normalized)
16+
? (normalized as AcpProvenanceMode)
17+
: undefined;
18+
}
19+
420
export type AcpSession = {
521
sessionId: SessionId;
622
sessionKey: string;
@@ -20,6 +36,7 @@ export type AcpServerOptions = {
2036
requireExistingSession?: boolean;
2137
resetSession?: boolean;
2238
prefixCwd?: boolean;
39+
provenanceMode?: AcpProvenanceMode;
2340
sessionCreateRateLimit?: {
2441
maxRequests?: number;
2542
windowMs?: number;

src/auto-reply/reply/agent-runner-utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ export function buildEmbeddedRunBaseParams(params: {
175175
config: params.run.config,
176176
skillsSnapshot: params.run.skillsSnapshot,
177177
ownerNumbers: params.run.ownerNumbers,
178+
inputProvenance: params.run.inputProvenance,
178179
senderIsOwner: params.run.senderIsOwner,
179180
enforceFinalTag: resolveEnforceFinalTag(params.run, params.provider),
180181
provider: params.provider,

src/auto-reply/reply/get-reply-run.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,7 @@ export async function runPreparedReply(
521521
timeoutMs,
522522
blockReplyBreak: resolvedBlockStreamingBreak,
523523
ownerNumbers: command.ownerList.length > 0 ? command.ownerList : undefined,
524+
inputProvenance: ctx.InputProvenance ?? sessionCtx.InputProvenance,
524525
extraSystemPrompt: extraSystemPromptParts.join("\n\n") || undefined,
525526
...(isReasoningTagProvider(provider) ? { enforceFinalTag: true } : {}),
526527
},

src/auto-reply/reply/queue/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ExecToolDefaults } from "../../../agents/bash-tools.js";
22
import type { SkillSnapshot } from "../../../agents/skills.js";
33
import type { OpenClawConfig } from "../../../config/config.js";
44
import type { SessionEntry } from "../../../config/sessions.js";
5+
import type { InputProvenance } from "../../../sessions/input-provenance.js";
56
import type { OriginatingChannelType } from "../../templating.js";
67
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../directives.js";
78

@@ -77,6 +78,7 @@ export type FollowupRun = {
7778
timeoutMs: number;
7879
blockReplyBreak: "text_end" | "message_end";
7980
ownerNumbers?: string[];
81+
inputProvenance?: InputProvenance;
8082
extraSystemPrompt?: string;
8183
enforceFinalTag?: boolean;
8284
};

src/auto-reply/templating.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
MediaUnderstandingDecision,
44
MediaUnderstandingOutput,
55
} from "../media-understanding/types.js";
6+
import type { InputProvenance } from "../sessions/input-provenance.js";
67
import type { StickerMetadata } from "../telegram/bot/types.js";
78
import type { InternalMessageChannel } from "../utils/message-channel.js";
89
import type { CommandArgs } from "./commands-registry.types.js";
@@ -117,6 +118,8 @@ export type MsgContext = {
117118
GroupSystemPrompt?: string;
118119
/** Untrusted metadata that must not be treated as system instructions. */
119120
UntrustedContext?: string[];
121+
/** System-attached provenance for the current inbound message. */
122+
InputProvenance?: InputProvenance;
120123
/** Explicit owner allowlist overrides (trusted, configuration-derived). */
121124
OwnerAllowFrom?: Array<string | number>;
122125
SenderName?: string;

0 commit comments

Comments
 (0)