File size: 9,376 Bytes
d197cf3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 | import { createReadStream, existsSync } from "node:fs";
import path from "node:path";
import { TextDecoder } from "node:util";
import { readByteStreamWithLimit } from "@openclaw/media-core/read-byte-stream-with-limit";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { mergeDeep } from "../infra/deep-merge.js";
const AGENT_EXEC_MESSAGE_MAX_BYTES = 4 * 1024 * 1024;
const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
export type AgentExecCliOptions = {
messageFile?: string;
cwd?: string;
stateDir?: string;
config?: string;
isolated?: boolean;
model?: string;
thinking?: string;
fallback?: string[];
codeMode?: "direct" | "auto" | "code";
localModelLean?: boolean;
authEnvOnly?: boolean;
timeout?: string;
json?: boolean;
};
function decodePrompt(bytes: Buffer, source: string): string {
let value: string;
try {
value = UTF8_DECODER.decode(bytes).replace(/^\uFEFF/, "");
} catch {
throw new Error(`${source} must be valid UTF-8`);
}
if (!value.trim()) {
throw new Error(`${source} is empty`);
}
return value;
}
async function readPromptStream(stream: AsyncIterable<unknown>, source: string): Promise<string> {
const bytes = await readByteStreamWithLimit(stream, {
maxBytes: AGENT_EXEC_MESSAGE_MAX_BYTES,
onOverflow: () => new Error(`${source} exceeds ${String(AGENT_EXEC_MESSAGE_MAX_BYTES)} bytes`),
});
return decodePrompt(bytes, source);
}
/** Resolve the one allowed prompt source for `agent exec`. */
export async function resolveAgentExecPrompt(
positionalMessage: string | undefined,
messageFile: string | undefined,
stdin: AsyncIterable<unknown> = process.stdin,
): Promise<string> {
const file = messageFile?.trim();
const hasPositional = positionalMessage !== undefined;
if (hasPositional && file) {
throw new Error("Use either the prompt argument or --message-file, not both.");
}
if (messageFile !== undefined && !file) {
throw new Error("--message-file must not be empty.");
}
if (file) {
const stream = file === "-" ? stdin : createReadStream(file);
try {
return await readPromptStream(stream, file === "-" ? "stdin" : `Message file ${file}`);
} catch (error) {
if (file === "-" || !(error instanceof Error) || !("code" in error)) {
throw error;
}
const code = error.code;
if (code === "ENOENT") {
throw new Error(`Message file not found: ${file}`, { cause: error });
}
throw error;
}
}
if (!positionalMessage?.trim()) {
throw new Error("Missing prompt. Pass text or use --message-file <path>.");
}
return positionalMessage;
}
/**
* Facts owned by this invocation rather than by any config, so they win over
* both the ambient config and `--config`: exec is always scoped to the folder
* it was pointed at, a one-shot turn never bootstraps, and explicit flags
* outrank whatever the resolved config says.
*/
/**
* Drops inherited state and workspace location overrides, which outrank the
* facts this invocation owns. `session.store` and `agentDir` can redirect state
* outside the invocation root, where its lock or temporary cleanup cannot own
* it; a native harness `runtime.acp.cwd` can make the turn edit the wrong repo.
* `agents.bindings[].acp.cwd` needs no equivalent because exec runs no channel,
* so no binding matches.
*/
function stripInheritedAgentLocations(base: OpenClawConfig): OpenClawConfig {
const { session, ...root } = base;
const { store: _store, ...sessionWithoutStore } = session ?? {};
const withoutSessionStore = session ? { ...root, session: sessionWithoutStore } : base;
const entries = withoutSessionStore.agents?.entries;
if (!entries) {
return withoutSessionStore;
}
return {
...withoutSessionStore,
agents: {
...withoutSessionStore.agents,
entries: Object.fromEntries(
Object.entries(entries).map(([id, entry]) => {
const { agentDir: _agentDir, runtime, ...rest } = entry;
if (runtime?.type !== "acp" || runtime.acp?.cwd === undefined) {
return [id, { ...rest, ...(runtime ? { runtime } : {}) }];
}
const { cwd: _cwd, ...acp } = runtime.acp;
return [id, { ...rest, runtime: { ...runtime, acp } }];
}),
),
},
};
}
function buildExecRunOverlay(params: {
base: OpenClawConfig;
cwd: string;
opts: Pick<AgentExecCliOptions, "localModelLean">;
}): OpenClawConfig {
// A per-agent `workspace` outranks `agents.defaults`, so pinning only the
// defaults would let an inherited entry silently run the turn against a
// different repository. Override every configured entry as well.
const entries = Object.keys(params.base.agents?.entries ?? {});
return {
agents: {
defaults: {
workspace: params.cwd,
skipBootstrap: true,
...(params.opts.localModelLean ? { experimental: { localModelLean: true } } : {}),
},
...(entries.length > 0
? { entries: Object.fromEntries(entries.map((id) => [id, { workspace: params.cwd }])) }
: {}),
},
// This process exits after one turn, so live skill invalidation cannot be
// observed and would leave Chokidar retaining the otherwise-finished CLI.
skills: { load: { watch: false } },
};
}
/**
* Coding one-shot defaults. These merge *under* the resolved config so an
* operator who configured a tool profile, shell env, or sandbox keeps it;
* notably exec must never downgrade a configured sandbox to `off`.
*/
function buildExecConfigDefaults(): OpenClawConfig {
return {
env: { shellEnv: { enabled: false } },
agents: { defaults: { sandbox: { mode: "off" } } },
tools: {
profile: "coding",
fs: { workspaceOnly: true },
// No `exec.host`: the default `auto` already resolves to the gateway when
// no sandbox is configured, and pinning `gateway` here would route
// commands back onto the host for an inherited config that enables one.
// `mode: "full"` stays because a headless one-shot has no approval channel.
exec: { mode: "full" },
},
};
}
/**
* Resolves the config exec runs against. Default is the ambient config, so a
* one-shot turn behaves like other folder-scoped coding CLIs and can reach
* configured providers, credentials, and `agentRuntime` harness choices.
*
* `--auth-env-only` opts out of that inheritance entirely rather than trying to
* launder the resolved config. A config is a credential store by design -- API
* keys, secret headers, request auth, an inline `env` block, and login-shell
* import all feed provider auth -- so the only closed way to promise
* environment-only credentials is to not read it.
*/
export async function resolveExecBaseConfig(
opts: Pick<AgentExecCliOptions, "authEnvOnly" | "config" | "isolated">,
): Promise<OpenClawConfig> {
// `--isolated` and `--auth-env-only` both mean "read no config", so pairing
// either with `--config` is a contradiction. Failing beats silently ignoring
// the pinned file, which would run a CI invocation on bare exec defaults.
if (opts.config && (opts.isolated || opts.authEnvOnly === true)) {
const conflicting = opts.isolated ? "--isolated" : "--auth-env-only";
throw new Error(`--config cannot be combined with ${conflicting}.`);
}
if (opts.isolated || opts.authEnvOnly === true) {
// A missing config is normally passed through the persisted-config
// migrations, which materialize the legacy main agent. Configless exec
// modes must preserve that runtime contract even though they skip all
// authored config and its credential surfaces.
const { migratePersistedImplicitMainRoster } = await import("../config/legacy.roster.js");
const { coerceConfig } = await import("../config/io.read-helpers.js");
return coerceConfig(migratePersistedImplicitMainRoster({}).config);
}
const { createConfigIO, getRuntimeConfig } = await import("../config/io.js");
if (!opts.config) {
// Ambient means "whatever this process considers effective", so this honors a
// runtime snapshot an in-process caller already published and otherwise loads
// the ordinary config file exactly as any other command does.
return getRuntimeConfig();
}
// `--config` pins an exact file. The factory loader reads that file directly --
// unlike the module-level loader it never resolves from a published runtime
// snapshot, so a pinned run cannot be shadowed by one. It throws on a config
// that exists but is invalid, so the run cannot silently degrade to exec
// defaults, and it finalizes the load (config `env` block, shell-env fallback).
const io = createConfigIO({ configPath: path.resolve(opts.config) });
if (!existsSync(io.configPath)) {
throw new Error(`--config file not found: ${io.configPath}`);
}
return io.loadConfig();
}
export function buildExecRunConfig(params: {
base: OpenClawConfig;
cwd: string;
opts?: Pick<AgentExecCliOptions, "localModelLean">;
}): OpenClawConfig {
const opts = params.opts ?? {};
const base = stripInheritedAgentLocations(params.base);
return mergeDeep(
mergeDeep(buildExecConfigDefaults(), base),
buildExecRunOverlay({ base, cwd: params.cwd, opts }),
) as OpenClawConfig; // SAFETY: Merging three typed configs preserves the OpenClawConfig shape.
}
|