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.
}