fix: harden ACP secret handling and exec preflight boundaries

This commit is contained in:
Peter Steinberger
2026-02-19 15:33:25 +01:00
parent 3d7ad1cfca
commit b40821b068
14 changed files with 412 additions and 36 deletions

22
src/acp/secret-file.ts Normal file
View File

@@ -0,0 +1,22 @@
import fs from "node:fs";
import { resolveUserPath } from "../utils.js";
export function readSecretFromFile(filePath: string, label: string): string {
const resolvedPath = resolveUserPath(filePath.trim());
if (!resolvedPath) {
throw new Error(`${label} file path is empty.`);
}
let raw = "";
try {
raw = fs.readFileSync(resolvedPath, "utf8");
} catch (err) {
throw new Error(`Failed to read ${label} file at ${resolvedPath}: ${String(err)}`, {
cause: err,
});
}
const secret = raw.trim();
if (!secret) {
throw new Error(`${label} file at ${resolvedPath} is empty.`);
}
return secret;
}

View File

@@ -1,15 +1,16 @@
#!/usr/bin/env node
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk";
import { Readable, Writable } from "node:stream";
import { fileURLToPath } from "node:url";
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk";
import type { AcpServerOptions } from "./types.js";
import { loadConfig } from "../config/config.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { buildGatewayConnectionDetails } from "../gateway/call.js";
import { GatewayClient } from "../gateway/client.js";
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";
export function serveAcpGateway(opts: AcpServerOptions = {}): Promise<void> {
const cfg = loadConfig();
@@ -95,6 +96,8 @@ export function serveAcpGateway(opts: AcpServerOptions = {}): Promise<void> {
function parseArgs(args: string[]): AcpServerOptions {
const opts: AcpServerOptions = {};
let tokenFile: string | undefined;
let passwordFile: string | undefined;
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--url" || arg === "--gateway-url") {
@@ -107,11 +110,21 @@ function parseArgs(args: string[]): AcpServerOptions {
i += 1;
continue;
}
if (arg === "--token-file" || arg === "--gateway-token-file") {
tokenFile = args[i + 1];
i += 1;
continue;
}
if (arg === "--password" || arg === "--gateway-password") {
opts.gatewayPassword = args[i + 1];
i += 1;
continue;
}
if (arg === "--password-file" || arg === "--gateway-password-file") {
passwordFile = args[i + 1];
i += 1;
continue;
}
if (arg === "--session") {
opts.defaultSessionKey = args[i + 1];
i += 1;
@@ -143,6 +156,18 @@ function parseArgs(args: string[]): AcpServerOptions {
process.exit(0);
}
}
if (opts.gatewayToken?.trim() && tokenFile?.trim()) {
throw new Error("Use either --token or --token-file.");
}
if (opts.gatewayPassword?.trim() && passwordFile?.trim()) {
throw new Error("Use either --password or --password-file.");
}
if (tokenFile?.trim()) {
opts.gatewayToken = readSecretFromFile(tokenFile, "Gateway token");
}
if (passwordFile?.trim()) {
opts.gatewayPassword = readSecretFromFile(passwordFile, "Gateway password");
}
return opts;
}
@@ -154,7 +179,9 @@ Gateway-backed ACP server for IDE integration.
Options:
--url <url> Gateway WebSocket URL
--token <token> Gateway auth token
--token-file <path> Read gateway auth token from file
--password <password> Gateway auth password
--password-file <path> Read gateway auth password from file
--session <key> Default session key (e.g. "agent:main:main")
--session-label <label> Default session label to resolve
--require-existing Fail if the session key/label does not exist
@@ -166,7 +193,18 @@ Options:
}
if (isMainModule({ currentFile: fileURLToPath(import.meta.url) })) {
const opts = parseArgs(process.argv.slice(2));
const argv = process.argv.slice(2);
if (argv.includes("--token") || argv.includes("--gateway-token")) {
console.error(
"Warning: --token can be exposed via process listings. Prefer --token-file or OPENCLAW_GATEWAY_TOKEN.",
);
}
if (argv.includes("--password") || argv.includes("--gateway-password")) {
console.error(
"Warning: --password can be exposed via process listings. Prefer --password-file or OPENCLAW_GATEWAY_PASSWORD.",
);
}
const opts = parseArgs(argv);
serveAcpGateway(opts).catch((err) => {
console.error(String(err));
process.exit(1);

View File

@@ -0,0 +1,56 @@
import type { AgentSideConnection, PromptRequest } from "@agentclientprotocol/sdk";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { GatewayClient } from "../gateway/client.js";
import { createInMemorySessionStore } from "./session.js";
import { AcpGatewayAgent } from "./translator.js";
function createConnection(): AgentSideConnection {
return {
sessionUpdate: vi.fn(async () => {}),
} as unknown as AgentSideConnection;
}
describe("acp prompt cwd prefix", () => {
it("redacts home directory in prompt prefix", async () => {
const sessionStore = createInMemorySessionStore();
const homeCwd = path.join(os.homedir(), "openclaw-test");
sessionStore.createSession({
sessionId: "session-1",
sessionKey: "agent:main:main",
cwd: homeCwd,
});
const requestSpy = vi.fn(async (method: string) => {
if (method === "chat.send") {
throw new Error("stop-after-send");
}
return {};
});
const gateway = {
request: requestSpy,
} as unknown as GatewayClient;
const agent = new AcpGatewayAgent(createConnection(), gateway, {
sessionStore,
prefixCwd: true,
});
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({
message: expect.stringContaining("[Working directory: ~/openclaw-test]"),
}),
{ expectFinal: true },
);
});
});

View File

@@ -1,4 +1,3 @@
import { randomUUID } from "node:crypto";
import type {
Agent,
AgentSideConnection,
@@ -20,6 +19,7 @@ import type {
StopReason,
} from "@agentclientprotocol/sdk";
import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
import { randomUUID } from "node:crypto";
import type { GatewayClient } from "../gateway/client.js";
import type { EventFrame } from "../gateway/protocol/index.js";
import type { SessionsListResult } from "../gateway/session-utils.js";
@@ -27,6 +27,7 @@ import {
createFixedWindowRateLimiter,
type FixedWindowRateLimiter,
} from "../infra/fixed-window-rate-limit.js";
import { shortenHomePath } from "../utils.js";
import { getAvailableCommands } from "./commands.js";
import {
extractAttachmentsFromPrompt,
@@ -263,7 +264,8 @@ export class AcpGatewayAgent implements Agent {
const userText = extractTextFromPrompt(params.prompt);
const attachments = extractAttachmentsFromPrompt(params.prompt);
const prefixCwd = meta.prefixCwd ?? this.opts.prefixCwd ?? true;
const message = prefixCwd ? `[Working directory: ${session.cwd}]\n\n${userText}` : userText;
const displayCwd = shortenHomePath(session.cwd);
const message = prefixCwd ? `[Working directory: ${displayCwd}]\n\n${userText}` : userText;
return new Promise<PromptResponse>((resolve, reject) => {
this.pendingPrompts.set(params.sessionId, {