mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 17:54:32 +00:00
fix: harden ACP secret handling and exec preflight boundaries
This commit is contained in:
22
src/acp/secret-file.ts
Normal file
22
src/acp/secret-file.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
56
src/acp/translator.prompt-prefix.test.ts
Normal file
56
src/acp/translator.prompt-prefix.test.ts
Normal 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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user