mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(cli): add openclaw agent exec headless one-shot runner (#113988)
* feat(cli): add agent exec headless runner * test(cli): align agent parent startup paths * fix(cli): scope agent exec environment explicitly
This commit is contained in:
committed by
GitHub
parent
adfb59c19b
commit
69399b1cc3
+62
-1
@@ -1,7 +1,8 @@
|
||||
---
|
||||
summary: "CLI reference for `openclaw agent` (send one agent turn via the Gateway)"
|
||||
summary: "CLI reference for Gateway-backed `openclaw agent` turns and isolated `agent exec` runs"
|
||||
read_when:
|
||||
- You want to run one agent turn from scripts (optionally deliver reply)
|
||||
- You want a strict, ephemeral one-shot agent run for CI
|
||||
title: "Agent"
|
||||
---
|
||||
|
||||
@@ -13,6 +14,66 @@ Pass at least one session selector: `--to`, `--session-key`, `--session-id`, or
|
||||
|
||||
Related: [Agent send tool](/tools/agent-send)
|
||||
|
||||
## `agent exec`
|
||||
|
||||
`openclaw agent exec` runs one embedded agent turn without connecting to a Gateway. It is the recommended headless entry point for CI and coding automation because it owns setup, cleanup, output projection, and process status.
|
||||
|
||||
```bash
|
||||
openclaw agent exec "Run the focused tests and fix failures"
|
||||
openclaw agent exec --message-file task.md --cwd ./repo
|
||||
cat task.md | openclaw agent exec --message-file - --json
|
||||
```
|
||||
|
||||
By default, the command creates and later removes a temporary state directory. Its implicit config skips workspace bootstrap files, disables the agent sandbox, selects the `coding` tool profile, restricts filesystem tools to `--cwd`, and enables full Gateway-host execution policy for the embedded local tool runtime. `--cwd` defaults to the process working directory and is passed as both the agent workspace and tool working directory.
|
||||
|
||||
Use `--state-dir <dir>` to retain sessions and other run state. The directory must already exist and is never created or deleted by the command. The command still uses its isolated implicit policy config; it does not read the ordinary OpenClaw config from that directory.
|
||||
|
||||
`--auth-env-only` is enabled by default. In this mode, the run can use provider keys already present in the process environment, but it does not load OpenClaw auth profiles or external Codex, Claude, or other CLI credential stores. Provider auth variables remain available to model authentication but are omitted from agent-launched host commands. Use `--no-auth-env-only` only when the run intentionally relies on those stored credentials.
|
||||
|
||||
Select a primary and ordered fallback chain with repeatable flags:
|
||||
|
||||
```bash
|
||||
openclaw agent exec "Implement the change" \
|
||||
--model openai/gpt-5.6-sol \
|
||||
--fallback anthropic/claude-sonnet-4-6 \
|
||||
--fallback google/gemini-3.1-pro-preview
|
||||
```
|
||||
|
||||
For this command only, explicit `--fallback` values remain active with explicit `--model`. Other agent entry points keep their existing rule that a user-selected model disables configured fallbacks.
|
||||
|
||||
The timeout defaults to 600 seconds for `agent exec`; this does not change the existing embedded `agent --local` default. A successful run exits `0`, any model or result error exits `1`, and a timeout exits `2`. Failure includes `meta.error`, aborted runs, exhausted model fallbacks, an error stop reason, and any error payload.
|
||||
|
||||
Plain output writes only the final assistant text to stdout. Diagnostics use stderr. `--json` reserves stdout for this stable envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"status": "ok",
|
||||
"final": "The focused tests pass.",
|
||||
"payloads": [{ "text": "The focused tests pass." }],
|
||||
"usage": { "input": 120, "output": 8, "total": 128 },
|
||||
"model": "gpt-5.6-sol",
|
||||
"provider": "openai",
|
||||
"sessionId": "019..."
|
||||
}
|
||||
```
|
||||
|
||||
`status` is `ok`, `error`, or `timeout`. `usage` is omitted when unavailable. Failed envelopes add `error: { message, kind }`; `model` and `provider` are `null` when failure happens before model selection.
|
||||
|
||||
### `agent exec` options
|
||||
|
||||
- `[message]`: positional prompt text
|
||||
- `--message-file <path>`: read a UTF-8 prompt from a file; `-` reads stdin
|
||||
- `--cwd <dir>`: set both the agent workspace and tool working directory
|
||||
- `--state-dir <dir>`: use an existing state directory without deleting it
|
||||
- `--model <provider/model>`: explicit primary model
|
||||
- `--thinking <level>`: one-run thinking level
|
||||
- `--fallback <provider/model>`: ordered fallback model; repeatable and requires `--model`
|
||||
- `--auth-env-only`: ignore stored and external CLI credentials (default)
|
||||
- `--no-auth-env-only`: allow stored and external CLI credentials
|
||||
- `--timeout <seconds>`: deadline in seconds (default `600`; `0` disables it)
|
||||
- `--json`: emit the stable JSON envelope
|
||||
|
||||
## Options
|
||||
|
||||
- `-m, --message <text>`: message body
|
||||
|
||||
@@ -1258,6 +1258,8 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- Route: /cli/agent
|
||||
- Headings:
|
||||
- H1: openclaw agent
|
||||
- H2: agent exec
|
||||
- H3: agent exec options
|
||||
- H2: Options
|
||||
- H2: Examples
|
||||
- H2: Notes
|
||||
|
||||
@@ -1163,6 +1163,23 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses an explicit per-run fallback chain with an explicit model", async () => {
|
||||
setupSingleAttemptFallback();
|
||||
const fallbacks = ["openai/gpt-5.6-terra", "anthropic/claude-sonnet-4-6"];
|
||||
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("anthropic", "claude"));
|
||||
|
||||
await agentCommand({
|
||||
message: "hello",
|
||||
to: "+1234567890",
|
||||
model: "anthropic/claude",
|
||||
modelFallbacksOverride: fallbacks,
|
||||
});
|
||||
|
||||
const fallbackParams = mockCallArg(state.runWithModelFallbackMock) as FallbackRunnerParams;
|
||||
expect(fallbackParams.fallbacksOverride).toEqual(fallbacks);
|
||||
expect(state.resolveEffectiveModelFallbacksMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips legacy override repair when continuing an ordinary locked harness session", async () => {
|
||||
setupSingleAttemptFallback();
|
||||
state.resolvedSessionKeyMock = "agent:main:plugin-owned";
|
||||
@@ -4015,9 +4032,15 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => {
|
||||
],
|
||||
}));
|
||||
|
||||
await runBasicAgentCommand();
|
||||
const onModelFallbackExhausted = vi.fn();
|
||||
await agentCommand({
|
||||
message: "hello",
|
||||
to: "+1234567890",
|
||||
onModelFallbackExhausted,
|
||||
});
|
||||
|
||||
expect(state.deliverAgentCommandResultMock).toHaveBeenCalledTimes(1);
|
||||
expect(onModelFallbackExhausted).toHaveBeenCalledTimes(1);
|
||||
const lifecycleEvents = state.emitAgentEventMock.mock.calls
|
||||
.map((call) => call[0] as { stream?: string; data?: Record<string, unknown> })
|
||||
.filter((event) => event.stream === "lifecycle");
|
||||
@@ -4074,9 +4097,15 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => {
|
||||
attempts: [],
|
||||
}));
|
||||
|
||||
await runBasicAgentCommand();
|
||||
const onResultErrorPayload = vi.fn();
|
||||
await agentCommand({
|
||||
message: "hello",
|
||||
to: "+1234567890",
|
||||
onResultErrorPayload,
|
||||
});
|
||||
|
||||
expect(state.deliverAgentCommandResultMock).toHaveBeenCalledTimes(1);
|
||||
expect(onResultErrorPayload).toHaveBeenCalledWith("Command may have changed state");
|
||||
const lifecycleEvents = state.emitAgentEventMock.mock.calls
|
||||
.map((call) => call[0] as { stream?: string; data?: Record<string, unknown> })
|
||||
.filter((event) => event.stream === "lifecycle");
|
||||
|
||||
@@ -426,6 +426,9 @@ async function agentCommandInternal(
|
||||
embeddedSessionState,
|
||||
trackInternalModelRunTarget,
|
||||
});
|
||||
if (embeddedAttempt.fallbackExhausted) {
|
||||
opts.onModelFallbackExhausted?.();
|
||||
}
|
||||
sessionEntry = embeddedAttempt.sessionEntry;
|
||||
lifecycleGeneration = embeddedAttempt.lifecycleGeneration;
|
||||
const finalized = await finalizeEmbeddedAgentCommand({
|
||||
|
||||
@@ -71,6 +71,8 @@ export {
|
||||
saveAuthProfileStore,
|
||||
findPersistedAuthProfileCredential,
|
||||
resolvePersistedAuthProfileOwnerAgentDir,
|
||||
withEnvOnlyAuthProfileStore,
|
||||
withAuthProfileStoreAgentDir,
|
||||
} from "./auth-profiles/store.js";
|
||||
export type {
|
||||
ApiKeyCredential,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Merges persisted stores, runtime snapshots, inherited main-agent OAuth
|
||||
* profiles, and external CLI overlays while keeping save paths local.
|
||||
*/
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { isSecretRef } from "../../config/types.secrets.js";
|
||||
@@ -74,6 +75,42 @@ type SaveAuthProfileStoreOptions = {
|
||||
};
|
||||
|
||||
const INLINE_OAUTH_TOKEN_FIELDS = ["access", "refresh", "idToken"] as const;
|
||||
type AuthProfileRuntimeMode = { kind: "env-only" } | { kind: "agent-dir"; agentDir: string };
|
||||
|
||||
const authProfileRuntimeMode = new AsyncLocalStorage<AuthProfileRuntimeMode>();
|
||||
|
||||
function createEmptyAuthProfileStore(): AuthProfileStore {
|
||||
return { version: AUTH_STORE_VERSION, profiles: {} };
|
||||
}
|
||||
|
||||
/** Run a bounded operation without persisted or external CLI auth profiles. */
|
||||
export function withEnvOnlyAuthProfileStore<T>(run: () => T): T {
|
||||
return authProfileRuntimeMode.run({ kind: "env-only" }, run);
|
||||
}
|
||||
|
||||
/** Run a bounded operation against one existing persisted auth store. */
|
||||
export function withAuthProfileStoreAgentDir<T>(agentDir: string, run: () => T): T {
|
||||
return authProfileRuntimeMode.run({ kind: "agent-dir", agentDir }, run);
|
||||
}
|
||||
|
||||
function isEnvOnlyAuthProfileRuntime(): boolean {
|
||||
return authProfileRuntimeMode.getStore()?.kind === "env-only";
|
||||
}
|
||||
|
||||
function resolveRuntimeAuthProfileAgentDir(agentDir?: string): string | undefined {
|
||||
const mode = authProfileRuntimeMode.getStore();
|
||||
return mode?.kind === "agent-dir" ? mode.agentDir : agentDir;
|
||||
}
|
||||
|
||||
function resolveRuntimeAuthProfileLoadOptions(
|
||||
options?: LoadAuthProfileStoreOptions,
|
||||
): LoadAuthProfileStoreOptions | undefined {
|
||||
const mode = authProfileRuntimeMode.getStore();
|
||||
if (mode?.kind !== "agent-dir") {
|
||||
return options;
|
||||
}
|
||||
return { ...options, inheritedAuthDir: mode.agentDir };
|
||||
}
|
||||
|
||||
function hasInlineOAuthTokenMaterial(credential: Record<string, unknown>): boolean {
|
||||
return INLINE_OAUTH_TOKEN_FIELDS.some((field) => credential[field] !== undefined);
|
||||
@@ -870,13 +907,14 @@ export async function updateAuthProfileStoreWithLock(params: {
|
||||
saveOptions?: SaveAuthProfileStoreOptions;
|
||||
updater: (store: AuthProfileStore) => boolean;
|
||||
}): Promise<AuthProfileStore | null> {
|
||||
const agentDir = resolveRuntimeAuthProfileAgentDir(params.agentDir);
|
||||
let publishRuntimeSnapshots: (() => void) | undefined;
|
||||
let store: AuthProfileStore;
|
||||
try {
|
||||
store = runAuthProfileWriteTransaction(
|
||||
params.agentDir,
|
||||
agentDir,
|
||||
(database) => {
|
||||
const loadedStore = loadAuthProfileStoreForAgent(params.agentDir, {
|
||||
const loadedStore = loadAuthProfileStoreForAgent(agentDir, {
|
||||
database,
|
||||
readOnly: true,
|
||||
syncExternalCli: false,
|
||||
@@ -885,7 +923,7 @@ export async function updateAuthProfileStoreWithLock(params: {
|
||||
if (shouldSave) {
|
||||
publishRuntimeSnapshots = saveAuthProfileStoreInTransaction(
|
||||
loadedStore,
|
||||
params.agentDir,
|
||||
agentDir,
|
||||
params.saveOptions,
|
||||
database,
|
||||
);
|
||||
@@ -897,7 +935,7 @@ export async function updateAuthProfileStoreWithLock(params: {
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.warn(`auth profile store update failed: ${message}`, {
|
||||
agentDir: params.agentDir,
|
||||
agentDir,
|
||||
error: message,
|
||||
});
|
||||
return null;
|
||||
@@ -908,26 +946,38 @@ export async function updateAuthProfileStoreWithLock(params: {
|
||||
|
||||
/** Load the main auth profile store with runtime external profiles overlaid. */
|
||||
export function loadAuthProfileStore(): AuthProfileStore {
|
||||
const asStore = loadPersistedAuthProfileStore();
|
||||
if (isEnvOnlyAuthProfileRuntime()) {
|
||||
return createEmptyAuthProfileStore();
|
||||
}
|
||||
const agentDir = resolveRuntimeAuthProfileAgentDir();
|
||||
const asStore = loadPersistedAuthProfileStore(agentDir);
|
||||
if (asStore) {
|
||||
return overlayExternalAuthProfiles(markRuntimePersistedProfiles(asStore));
|
||||
return overlayExternalAuthProfiles(markRuntimePersistedProfiles(asStore), { agentDir });
|
||||
}
|
||||
|
||||
const store: AuthProfileStore = { version: AUTH_STORE_VERSION, profiles: {} };
|
||||
return overlayExternalAuthProfiles(markRuntimePersistedProfiles(store));
|
||||
return overlayExternalAuthProfiles(markRuntimePersistedProfiles(store), { agentDir });
|
||||
}
|
||||
|
||||
function loadAuthProfileStoreForAgent(
|
||||
agentDir?: string,
|
||||
options?: LoadAuthProfileStoreOptions,
|
||||
): AuthProfileStore {
|
||||
const readOnly = options?.readOnly === true;
|
||||
const asStore = loadPersistedAuthProfileStore(agentDir, resolvePersistedLoadOptions(options));
|
||||
if (isEnvOnlyAuthProfileRuntime()) {
|
||||
return createEmptyAuthProfileStore();
|
||||
}
|
||||
const effectiveAgentDir = resolveRuntimeAuthProfileAgentDir(agentDir);
|
||||
const effectiveOptions = resolveRuntimeAuthProfileLoadOptions(options);
|
||||
const readOnly = effectiveOptions?.readOnly === true;
|
||||
const asStore = loadPersistedAuthProfileStore(
|
||||
effectiveAgentDir,
|
||||
resolvePersistedLoadOptions(effectiveOptions),
|
||||
);
|
||||
if (asStore) {
|
||||
const synced = maybeSyncPersistedExternalCliAuthProfiles({
|
||||
store: asStore,
|
||||
agentDir,
|
||||
options,
|
||||
agentDir: effectiveAgentDir,
|
||||
options: effectiveOptions,
|
||||
});
|
||||
return markRuntimePersistedProfiles(synced.store);
|
||||
}
|
||||
@@ -941,13 +991,13 @@ function loadAuthProfileStoreForAgent(
|
||||
const forceReadOnly = process.env.OPENCLAW_AUTH_STORE_READONLY === "1";
|
||||
const shouldWrite = !readOnly && !forceReadOnly && mergedOAuth;
|
||||
if (shouldWrite) {
|
||||
saveAuthProfileStore(store, agentDir);
|
||||
saveAuthProfileStore(store, effectiveAgentDir);
|
||||
}
|
||||
|
||||
const synced = maybeSyncPersistedExternalCliAuthProfiles({
|
||||
store,
|
||||
agentDir,
|
||||
options,
|
||||
agentDir: effectiveAgentDir,
|
||||
options: effectiveOptions,
|
||||
});
|
||||
return markRuntimePersistedProfiles(synced.store);
|
||||
}
|
||||
@@ -957,27 +1007,35 @@ export function loadAuthProfileStoreForRuntime(
|
||||
agentDir?: string,
|
||||
options?: LoadAuthProfileStoreOptions,
|
||||
): AuthProfileStore {
|
||||
const store = loadAuthProfileStoreForAgent(agentDir, options);
|
||||
const authPath = resolveAuthStorePath(agentDir);
|
||||
const mainAuthPath = resolveAuthStorePath(options?.inheritedAuthDir);
|
||||
const externalCli = resolveExternalCliOverlayOptions(options);
|
||||
if (!agentDir || authPath === mainAuthPath) {
|
||||
if (isEnvOnlyAuthProfileRuntime()) {
|
||||
return createEmptyAuthProfileStore();
|
||||
}
|
||||
const effectiveAgentDir = resolveRuntimeAuthProfileAgentDir(agentDir);
|
||||
const effectiveOptions = resolveRuntimeAuthProfileLoadOptions(options);
|
||||
const store = loadAuthProfileStoreForAgent(effectiveAgentDir, effectiveOptions);
|
||||
const authPath = resolveAuthStorePath(effectiveAgentDir);
|
||||
const mainAuthPath = resolveAuthStorePath(effectiveOptions?.inheritedAuthDir);
|
||||
const externalCli = resolveExternalCliOverlayOptions(effectiveOptions);
|
||||
if (!effectiveAgentDir || authPath === mainAuthPath) {
|
||||
return setRuntimeLocalProfileMetadata(
|
||||
overlayExternalAuthProfiles(store, {
|
||||
agentDir,
|
||||
agentDir: effectiveAgentDir,
|
||||
...externalCli,
|
||||
}),
|
||||
listRuntimeLocalProfileIds(store),
|
||||
);
|
||||
}
|
||||
|
||||
const mainStore = loadAuthProfileStoreForAgent(options?.inheritedAuthDir, options);
|
||||
const mainStore = loadAuthProfileStoreForAgent(
|
||||
effectiveOptions?.inheritedAuthDir,
|
||||
effectiveOptions,
|
||||
);
|
||||
const mergedStore = mergeAuthProfileStores(mainStore, store, {
|
||||
preserveBaseRuntimeExternalProfiles: true,
|
||||
});
|
||||
return setRuntimeLocalProfileMetadata(
|
||||
overlayExternalAuthProfiles(mergedStore, {
|
||||
agentDir,
|
||||
agentDir: effectiveAgentDir,
|
||||
...externalCli,
|
||||
}),
|
||||
listRuntimeLocalProfileIds(store, mainStore),
|
||||
@@ -1009,15 +1067,19 @@ export function loadAuthProfileStoreWithoutExternalProfiles(
|
||||
agentDir?: string,
|
||||
loadOptions?: Pick<LoadAuthProfileStoreOptions, "allowKeychainPrompt" | "inheritedAuthDir">,
|
||||
): AuthProfileStore {
|
||||
const effectiveAgentDir = resolveRuntimeAuthProfileAgentDir(agentDir);
|
||||
const effectiveLoadOptions = resolveRuntimeAuthProfileLoadOptions(loadOptions);
|
||||
const options: LoadAuthProfileStoreOptions = {
|
||||
readOnly: true,
|
||||
allowKeychainPrompt: loadOptions?.allowKeychainPrompt ?? false,
|
||||
...(loadOptions?.inheritedAuthDir ? { inheritedAuthDir: loadOptions.inheritedAuthDir } : {}),
|
||||
allowKeychainPrompt: effectiveLoadOptions?.allowKeychainPrompt ?? false,
|
||||
...(effectiveLoadOptions?.inheritedAuthDir
|
||||
? { inheritedAuthDir: effectiveLoadOptions.inheritedAuthDir }
|
||||
: {}),
|
||||
};
|
||||
const store = loadAuthProfileStoreForAgent(agentDir, options);
|
||||
const authPath = resolveAuthStorePath(agentDir);
|
||||
const store = loadAuthProfileStoreForAgent(effectiveAgentDir, options);
|
||||
const authPath = resolveAuthStorePath(effectiveAgentDir);
|
||||
const mainAuthPath = resolveAuthStorePath(options.inheritedAuthDir);
|
||||
if (!agentDir || authPath === mainAuthPath) {
|
||||
if (!effectiveAgentDir || authPath === mainAuthPath) {
|
||||
return setRuntimeLocalProfileMetadata(
|
||||
stripRuntimeExternalProfileMetadata(store),
|
||||
listRuntimeLocalProfileIds(store),
|
||||
@@ -1049,12 +1111,17 @@ export function ensureAuthProfileStore(
|
||||
syncExternalCli?: boolean;
|
||||
},
|
||||
): AuthProfileStore {
|
||||
const externalCli = resolveExternalCliOverlayOptions(options);
|
||||
const runtimeStore = resolveRuntimeAuthProfileStore(agentDir, options);
|
||||
if (isEnvOnlyAuthProfileRuntime()) {
|
||||
return createEmptyAuthProfileStore();
|
||||
}
|
||||
const effectiveAgentDir = resolveRuntimeAuthProfileAgentDir(agentDir);
|
||||
const effectiveOptions = resolveRuntimeAuthProfileLoadOptions(options);
|
||||
const externalCli = resolveExternalCliOverlayOptions(effectiveOptions);
|
||||
const runtimeStore = resolveRuntimeAuthProfileStore(effectiveAgentDir, effectiveOptions);
|
||||
const store = overlayExternalAuthProfiles(
|
||||
ensureAuthProfileStoreWithoutExternalProfiles(agentDir, options),
|
||||
ensureAuthProfileStoreWithoutExternalProfiles(effectiveAgentDir, effectiveOptions),
|
||||
{
|
||||
agentDir,
|
||||
agentDir: effectiveAgentDir,
|
||||
...externalCli,
|
||||
},
|
||||
);
|
||||
@@ -1077,21 +1144,25 @@ export function ensureAuthProfileStoreWithoutExternalProfiles(
|
||||
syncExternalCli?: boolean;
|
||||
},
|
||||
): AuthProfileStore {
|
||||
const effectiveOptions: LoadAuthProfileStoreOptions = {
|
||||
...options,
|
||||
};
|
||||
const runtimeStore = resolveRuntimeAuthProfileStore(agentDir, effectiveOptions);
|
||||
if (isEnvOnlyAuthProfileRuntime()) {
|
||||
return createEmptyAuthProfileStore();
|
||||
}
|
||||
const effectiveAgentDir = resolveRuntimeAuthProfileAgentDir(agentDir);
|
||||
const effectiveOptions: LoadAuthProfileStoreOptions = resolveRuntimeAuthProfileLoadOptions(
|
||||
options,
|
||||
) ?? { ...options };
|
||||
const runtimeStore = resolveRuntimeAuthProfileStore(effectiveAgentDir, effectiveOptions);
|
||||
if (runtimeStore) {
|
||||
return buildAuthProfileStoreWithoutExternalProfiles({
|
||||
store: runtimeStore,
|
||||
agentDir,
|
||||
agentDir: effectiveAgentDir,
|
||||
options: effectiveOptions,
|
||||
});
|
||||
}
|
||||
const store = loadAuthProfileStoreForAgent(agentDir, effectiveOptions);
|
||||
const authPath = resolveAuthStorePath(agentDir);
|
||||
const store = loadAuthProfileStoreForAgent(effectiveAgentDir, effectiveOptions);
|
||||
const authPath = resolveAuthStorePath(effectiveAgentDir);
|
||||
const mainAuthPath = resolveAuthStorePath(effectiveOptions.inheritedAuthDir);
|
||||
if (!agentDir || authPath === mainAuthPath) {
|
||||
if (!effectiveAgentDir || authPath === mainAuthPath) {
|
||||
return stripRuntimeExternalProfileMetadata(store);
|
||||
}
|
||||
|
||||
@@ -1111,19 +1182,25 @@ export function findPersistedAuthProfileCredential(params: {
|
||||
agentDir?: string;
|
||||
profileId: string;
|
||||
}): AuthProfileStore["profiles"][string] | undefined {
|
||||
const requestedStore = loadPersistedAuthProfileStore(params.agentDir);
|
||||
if (isEnvOnlyAuthProfileRuntime()) {
|
||||
return undefined;
|
||||
}
|
||||
const agentDir = resolveRuntimeAuthProfileAgentDir(params.agentDir);
|
||||
const requestedStore = loadPersistedAuthProfileStore(agentDir);
|
||||
const requestedProfile = requestedStore?.profiles[params.profileId];
|
||||
if (requestedProfile || !params.agentDir) {
|
||||
if (requestedProfile || !agentDir) {
|
||||
return requestedProfile;
|
||||
}
|
||||
|
||||
const requestedPath = resolveAuthStorePath(params.agentDir);
|
||||
const mainPath = resolveAuthStorePath();
|
||||
const requestedPath = resolveAuthStorePath(agentDir);
|
||||
const mainPath = resolveAuthStorePath(resolveRuntimeAuthProfileAgentDir());
|
||||
if (requestedPath === mainPath) {
|
||||
return requestedProfile;
|
||||
}
|
||||
|
||||
return loadPersistedAuthProfileStore()?.profiles[params.profileId];
|
||||
return loadPersistedAuthProfileStore(resolveRuntimeAuthProfileAgentDir())?.profiles[
|
||||
params.profileId
|
||||
];
|
||||
}
|
||||
|
||||
/** Resolve which agent dir owns a persisted profile, accounting for inherited OAuth. */
|
||||
@@ -1131,17 +1208,22 @@ export function resolvePersistedAuthProfileOwnerAgentDir(params: {
|
||||
agentDir?: string;
|
||||
profileId: string;
|
||||
}): string | undefined {
|
||||
if (!params.agentDir) {
|
||||
if (isEnvOnlyAuthProfileRuntime()) {
|
||||
return undefined;
|
||||
}
|
||||
const requestedStore = loadPersistedAuthProfileStore(params.agentDir);
|
||||
const requestedPath = resolveAuthStorePath(params.agentDir);
|
||||
const mainPath = resolveAuthStorePath();
|
||||
const agentDir = resolveRuntimeAuthProfileAgentDir(params.agentDir);
|
||||
if (!agentDir) {
|
||||
return undefined;
|
||||
}
|
||||
const requestedStore = loadPersistedAuthProfileStore(agentDir);
|
||||
const requestedPath = resolveAuthStorePath(agentDir);
|
||||
const mainAgentDir = resolveRuntimeAuthProfileAgentDir();
|
||||
const mainPath = resolveAuthStorePath(mainAgentDir);
|
||||
if (requestedPath === mainPath) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const mainStore = loadPersistedAuthProfileStore();
|
||||
const mainStore = loadPersistedAuthProfileStore(mainAgentDir);
|
||||
const requestedProfile = requestedStore?.profiles[params.profileId];
|
||||
if (requestedProfile) {
|
||||
return shouldUseMainOwnerForLocalOAuthCredential({
|
||||
@@ -1149,19 +1231,23 @@ export function resolvePersistedAuthProfileOwnerAgentDir(params: {
|
||||
main: mainStore?.profiles[params.profileId],
|
||||
})
|
||||
? undefined
|
||||
: params.agentDir;
|
||||
: agentDir;
|
||||
}
|
||||
|
||||
return mainStore?.profiles[params.profileId] ? undefined : params.agentDir;
|
||||
return mainStore?.profiles[params.profileId] ? undefined : agentDir;
|
||||
}
|
||||
|
||||
/** Load the store shape used when applying local-only auth updates. */
|
||||
export function ensureAuthProfileStoreForLocalUpdate(agentDir?: string): AuthProfileStore {
|
||||
if (isEnvOnlyAuthProfileRuntime()) {
|
||||
return createEmptyAuthProfileStore();
|
||||
}
|
||||
const effectiveAgentDir = resolveRuntimeAuthProfileAgentDir(agentDir);
|
||||
const options: LoadAuthProfileStoreOptions = { syncExternalCli: false };
|
||||
const store = loadAuthProfileStoreForAgent(agentDir, options);
|
||||
const authPath = resolveAuthStorePath(agentDir);
|
||||
const mainAuthPath = resolveAuthStorePath();
|
||||
if (!agentDir || authPath === mainAuthPath) {
|
||||
const store = loadAuthProfileStoreForAgent(effectiveAgentDir, options);
|
||||
const authPath = resolveAuthStorePath(effectiveAgentDir);
|
||||
const mainAuthPath = resolveAuthStorePath(resolveRuntimeAuthProfileAgentDir());
|
||||
if (!effectiveAgentDir || authPath === mainAuthPath) {
|
||||
return store;
|
||||
}
|
||||
|
||||
@@ -1311,10 +1397,11 @@ export function saveAuthProfileStore(
|
||||
options?: SaveAuthProfileStoreOptions,
|
||||
database?: OpenClawAgentDatabase,
|
||||
): void {
|
||||
const effectiveAgentDir = resolveRuntimeAuthProfileAgentDir(agentDir);
|
||||
if (database) {
|
||||
const publishRuntimeSnapshots = saveAuthProfileStoreInTransaction(
|
||||
store,
|
||||
agentDir,
|
||||
effectiveAgentDir,
|
||||
options,
|
||||
database,
|
||||
true,
|
||||
@@ -1329,10 +1416,10 @@ export function saveAuthProfileStore(
|
||||
return;
|
||||
}
|
||||
let publishRuntimeSnapshots: (() => void) | undefined;
|
||||
runAuthProfileWriteTransaction(agentDir, (transactionDatabase) => {
|
||||
runAuthProfileWriteTransaction(effectiveAgentDir, (transactionDatabase) => {
|
||||
publishRuntimeSnapshots = saveAuthProfileStoreInTransaction(
|
||||
store,
|
||||
agentDir,
|
||||
effectiveAgentDir,
|
||||
options,
|
||||
transactionDatabase,
|
||||
);
|
||||
@@ -1473,11 +1560,12 @@ function rebuildRuntimeAuthProfileStoreSnapshot(
|
||||
export function captureAuthProfileStorePersistenceSnapshot(
|
||||
agentDir?: string,
|
||||
): AuthProfileStorePersistenceSnapshot {
|
||||
return runAuthProfileWriteTransaction(agentDir, (database) => {
|
||||
const effectiveAgentDir = resolveRuntimeAuthProfileAgentDir(agentDir);
|
||||
return runAuthProfileWriteTransaction(effectiveAgentDir, (database) => {
|
||||
return {
|
||||
credentialsRaw: readPersistedAuthProfileStoreRaw(agentDir, database),
|
||||
stateRaw: readPersistedAuthProfileStateRaw(agentDir, database),
|
||||
...captureRuntimeAuthProfileStorePersistenceSnapshot(agentDir),
|
||||
credentialsRaw: readPersistedAuthProfileStoreRaw(effectiveAgentDir, database),
|
||||
stateRaw: readPersistedAuthProfileStateRaw(effectiveAgentDir, database),
|
||||
...captureRuntimeAuthProfileStorePersistenceSnapshot(effectiveAgentDir),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1492,22 +1580,23 @@ export function saveAuthProfileStoreIfPersistenceSnapshotMatches(params: {
|
||||
agentDir?: string;
|
||||
options?: SaveAuthProfileStoreOptions;
|
||||
}): CommittedAuthProfileStoreSave {
|
||||
const agentDir = resolveRuntimeAuthProfileAgentDir(params.agentDir);
|
||||
let publishRuntimeSnapshots: (() => void) | undefined;
|
||||
const owned: AuthProfileStorePersistenceSnapshot = {
|
||||
credentialsRaw: null,
|
||||
stateRaw: null,
|
||||
runtimeCaptured: false,
|
||||
};
|
||||
runAuthProfileWriteTransaction(params.agentDir, (database) => {
|
||||
const currentCredentials = readPersistedAuthProfileStoreRaw(params.agentDir, database);
|
||||
const currentState = readPersistedAuthProfileStateRaw(params.agentDir, database);
|
||||
runAuthProfileWriteTransaction(agentDir, (database) => {
|
||||
const currentCredentials = readPersistedAuthProfileStoreRaw(agentDir, database);
|
||||
const currentState = readPersistedAuthProfileStateRaw(agentDir, database);
|
||||
if (
|
||||
!isDeepStrictEqual(currentCredentials, params.snapshot.credentialsRaw) ||
|
||||
!isDeepStrictEqual(currentState, params.snapshot.stateRaw)
|
||||
) {
|
||||
throw new Error("auth profile store changed after secrets apply captured it");
|
||||
}
|
||||
const runtimeAtSaveEdge = captureRuntimeAuthProfileStorePersistenceSnapshot(params.agentDir);
|
||||
const runtimeAtSaveEdge = captureRuntimeAuthProfileStorePersistenceSnapshot(agentDir);
|
||||
owned.runtimeRevisionAtSaveEdge = runtimeAtSaveEdge.runtimeRevision;
|
||||
owned.derivedRuntimeRevisionsAtSaveEdge = runtimeAtSaveEdge.derivedRuntimeStores?.flatMap(
|
||||
(entry) =>
|
||||
@@ -1517,12 +1606,12 @@ export function saveAuthProfileStoreIfPersistenceSnapshotMatches(params: {
|
||||
);
|
||||
publishRuntimeSnapshots = saveAuthProfileStoreInTransaction(
|
||||
params.store,
|
||||
params.agentDir,
|
||||
agentDir,
|
||||
params.options,
|
||||
database,
|
||||
);
|
||||
owned.credentialsRaw = readPersistedAuthProfileStoreRaw(params.agentDir, database);
|
||||
owned.stateRaw = readPersistedAuthProfileStateRaw(params.agentDir, database);
|
||||
owned.credentialsRaw = readPersistedAuthProfileStoreRaw(agentDir, database);
|
||||
owned.stateRaw = readPersistedAuthProfileStateRaw(agentDir, database);
|
||||
});
|
||||
return {
|
||||
owned,
|
||||
@@ -1530,7 +1619,7 @@ export function saveAuthProfileStoreIfPersistenceSnapshotMatches(params: {
|
||||
publishRuntimeSnapshotsAfterCommit(() => {
|
||||
recordRuntimeAuthProfileStorePublicationEdge(
|
||||
owned,
|
||||
captureRuntimeAuthProfileStorePersistenceSnapshot(params.agentDir),
|
||||
captureRuntimeAuthProfileStorePersistenceSnapshot(agentDir),
|
||||
);
|
||||
publishRuntimeSnapshots?.();
|
||||
recordRuntimeAuthProfileStoreOwnership(
|
||||
|
||||
@@ -107,6 +107,14 @@ export async function finalizeEmbeddedAgentCommand(params: {
|
||||
),
|
||||
};
|
||||
}
|
||||
const resultErrorPayload = result.payloads?.find((payload) => payload.isError === true);
|
||||
if (resultErrorPayload) {
|
||||
const message =
|
||||
typeof resultErrorPayload.text === "string" && resultErrorPayload.text.trim()
|
||||
? resultErrorPayload.text
|
||||
: undefined;
|
||||
params.opts.onResultErrorPayload?.(message);
|
||||
}
|
||||
params.onTerminalDeliveryEvidenceChanged(buildRestartRecoveryTerminalDeliveryEvidence(result));
|
||||
|
||||
const rotatedSessionFile = result.meta.agentMeta?.sessionFile;
|
||||
|
||||
@@ -211,7 +211,8 @@ export async function runEmbeddedAgentAttempt(params: {
|
||||
const spawnedBy = normalizedSpawned.spawnedBy ?? sessionEntry?.spawnedBy;
|
||||
const effectiveFallbacksOverride = isModelSelectionLocked(sessionEntry)
|
||||
? []
|
||||
: resolveEffectiveModelFallbacks({
|
||||
: (params.opts.modelFallbacksOverride ??
|
||||
resolveEffectiveModelFallbacks({
|
||||
cfg,
|
||||
agentId: sessionAgentId,
|
||||
sessionKey,
|
||||
@@ -221,7 +222,7 @@ export async function runEmbeddedAgentAttempt(params: {
|
||||
hasAutoFallbackProvenance: hasExplicitRunOverride
|
||||
? false
|
||||
: hasStoredAutoFallbackProvenance,
|
||||
});
|
||||
}));
|
||||
|
||||
const fallbackRuntimeState: { originRuntime?: "cli" | "embedded" } = {};
|
||||
attemptLifecycleState.currentTurnUserMessagePersisted = false;
|
||||
|
||||
@@ -73,6 +73,8 @@ export type AgentCommandOpts = {
|
||||
provider?: string;
|
||||
/** Per-run model override. */
|
||||
model?: string;
|
||||
/** Explicit ordered fallback chain for this run. Undefined uses normal selection policy. */
|
||||
modelFallbacksOverride?: string[];
|
||||
to?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
@@ -183,6 +185,10 @@ export type AgentCommandOpts = {
|
||||
mainRestartRecoveryAdmitted?: boolean;
|
||||
/** Called when the actual run model is selected, including fallback retries. */
|
||||
onActiveModelSelected?: (ctx: { provider: string; model: string }) => void | Promise<void>;
|
||||
/** Called when every candidate in the run's model fallback chain failed. */
|
||||
onModelFallbackExhausted?: () => void;
|
||||
/** Called before delivery projection when the raw run contains an error payload. */
|
||||
onResultErrorPayload?: (message?: string) => void;
|
||||
/** Called when compaction rotates the active run onto a successor session. */
|
||||
onSessionIdChanged?: (sessionId: string) => void;
|
||||
/** Internal one-shot model probe mode: no tools, no workspace/chat prompt policy. */
|
||||
|
||||
@@ -24,4 +24,39 @@ describe("argv-invocation", () => {
|
||||
isRootHelpInvocation: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("consumes agent parent option values before the exec subcommand", () => {
|
||||
expect(
|
||||
resolveCliArgvInvocation([
|
||||
"node",
|
||||
"openclaw",
|
||||
"agent",
|
||||
"--model",
|
||||
"openai/gpt-5.6-sol",
|
||||
"exec",
|
||||
"fix it",
|
||||
]).commandPath,
|
||||
).toEqual(["agent", "exec"]);
|
||||
});
|
||||
|
||||
it("does not treat an exec-valued parent option as the subcommand", () => {
|
||||
expect(
|
||||
resolveCliArgvInvocation(["node", "openclaw", "agent", "--message", "exec"]).commandPath,
|
||||
).toEqual(["agent"]);
|
||||
});
|
||||
|
||||
it("consumes root options between the agent parent and exec", () => {
|
||||
expect(
|
||||
resolveCliArgvInvocation([
|
||||
"node",
|
||||
"openclaw",
|
||||
"agent",
|
||||
"--no-color",
|
||||
"--model",
|
||||
"openai/gpt-5.6-sol",
|
||||
"exec",
|
||||
"fix it",
|
||||
]).commandPath,
|
||||
).toEqual(["agent", "exec"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// Normalized argv invocation summary used before Commander command dispatch.
|
||||
import {
|
||||
consumeRootOptionToken,
|
||||
FLAG_TERMINATOR,
|
||||
isValueToken,
|
||||
} from "../infra/cli-root-options.js";
|
||||
import {
|
||||
getCommandPathWithRootOptions,
|
||||
getPrimaryCommand,
|
||||
@@ -6,6 +11,91 @@ import {
|
||||
isRootHelpInvocation,
|
||||
} from "./argv.js";
|
||||
|
||||
const AGENT_PARENT_BOOLEAN_FLAGS = new Set(["--local", "--deliver", "--json"]);
|
||||
const AGENT_PARENT_VALUE_FLAGS = new Set([
|
||||
"-m",
|
||||
"--message",
|
||||
"--message-file",
|
||||
"-t",
|
||||
"--to",
|
||||
"--session-key",
|
||||
"--session-id",
|
||||
"--agent",
|
||||
"--model",
|
||||
"--thinking",
|
||||
"--verbose",
|
||||
"--channel",
|
||||
"--reply-to",
|
||||
"--reply-channel",
|
||||
"--reply-account",
|
||||
"--timeout",
|
||||
]);
|
||||
|
||||
function consumeAgentParentOption(args: readonly string[], index: number): number {
|
||||
const arg = args[index];
|
||||
if (!arg?.startsWith("-") || arg === FLAG_TERMINATOR) {
|
||||
return 0;
|
||||
}
|
||||
const equalsIndex = arg.indexOf("=");
|
||||
const flag = equalsIndex === -1 ? arg : arg.slice(0, equalsIndex);
|
||||
if (AGENT_PARENT_BOOLEAN_FLAGS.has(flag)) {
|
||||
return equalsIndex === -1 ? 1 : 0;
|
||||
}
|
||||
if (!AGENT_PARENT_VALUE_FLAGS.has(flag)) {
|
||||
return 0;
|
||||
}
|
||||
if (equalsIndex !== -1) {
|
||||
return arg.slice(equalsIndex + 1).trim() ? 1 : 0;
|
||||
}
|
||||
return isValueToken(args[index + 1]) ? 2 : 0;
|
||||
}
|
||||
|
||||
function resolveAgentCommandPath(argv: string[]): string[] | null {
|
||||
const args = argv.slice(2);
|
||||
let sawAgent = false;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (!arg || arg === FLAG_TERMINATOR) {
|
||||
break;
|
||||
}
|
||||
if (!sawAgent) {
|
||||
const rootConsumed = consumeRootOptionToken(args, index);
|
||||
if (rootConsumed > 0) {
|
||||
index += rootConsumed - 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("-")) {
|
||||
continue;
|
||||
}
|
||||
if (arg !== "agent") {
|
||||
return null;
|
||||
}
|
||||
sawAgent = true;
|
||||
continue;
|
||||
}
|
||||
const rootConsumed = consumeRootOptionToken(args, index);
|
||||
if (rootConsumed > 0) {
|
||||
index += rootConsumed - 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("-")) {
|
||||
const consumed = consumeAgentParentOption(args, index);
|
||||
if (consumed === 0) {
|
||||
return ["agent"];
|
||||
}
|
||||
index += consumed - 1;
|
||||
continue;
|
||||
}
|
||||
return ["agent", arg];
|
||||
}
|
||||
return sawAgent ? ["agent"] : null;
|
||||
}
|
||||
|
||||
/** Resolves startup policy paths while consuming known parent-command option values. */
|
||||
export function resolveCliStartupCommandPath(argv: string[]): string[] {
|
||||
return resolveAgentCommandPath(argv) ?? getCommandPathWithRootOptions(argv, 2);
|
||||
}
|
||||
|
||||
type CliArgvInvocation = {
|
||||
argv: string[];
|
||||
commandPath: string[];
|
||||
@@ -18,7 +108,7 @@ type CliArgvInvocation = {
|
||||
export function resolveCliArgvInvocation(argv: string[]): CliArgvInvocation {
|
||||
return {
|
||||
argv,
|
||||
commandPath: getCommandPathWithRootOptions(argv, 2),
|
||||
commandPath: resolveCliStartupCommandPath(argv),
|
||||
primary: getPrimaryCommand(argv),
|
||||
hasHelpOrVersion: isHelpOrVersionInvocation(argv),
|
||||
isRootHelpInvocation: isRootHelpInvocation(argv),
|
||||
|
||||
@@ -88,6 +88,16 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
|
||||
networkProxy: ({ argv }) => (hasFlag(argv, "--local") ? "default" : "bypass"),
|
||||
},
|
||||
},
|
||||
{
|
||||
commandPath: ["agent", "exec"],
|
||||
policy: {
|
||||
bypassConfigGuard: true,
|
||||
loadPlugins: "never",
|
||||
ownsProtocolStdout: true,
|
||||
hideBanner: true,
|
||||
networkProxy: "default",
|
||||
},
|
||||
},
|
||||
{ commandPath: ["message"], policy: { loadPlugins: "never" } },
|
||||
{ commandPath: ["docs"], policy: { bypassConfigGuard: true } },
|
||||
{
|
||||
|
||||
@@ -169,6 +169,15 @@ describe("command-path-policy", () => {
|
||||
}),
|
||||
).toBe("default");
|
||||
|
||||
expectResolvedPolicy(["agent", "exec"], {
|
||||
bypassConfigGuard: true,
|
||||
loadPlugins: "never",
|
||||
pluginRegistry: { scope: "all" },
|
||||
ownsProtocolStdout: true,
|
||||
hideBanner: true,
|
||||
networkProxy: "default",
|
||||
});
|
||||
|
||||
for (const commandPath of [
|
||||
["agents"],
|
||||
["agents", "list"],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
// Resolves CLI command path policy from the declarative command catalog.
|
||||
import { isGatewayConfigBypassCommandPath } from "../gateway/explicit-connection-policy.js";
|
||||
import { resolveCliStartupCommandPath } from "./argv-invocation.js";
|
||||
import { getCommandPathWithRootOptions } from "./argv.js";
|
||||
import {
|
||||
cliCommandCatalog,
|
||||
@@ -45,8 +46,10 @@ function isCommandPathPrefix(commandPath: string[], pattern: readonly string[]):
|
||||
|
||||
function resolveCliCatalogCommandPath(argv: string[]): string[] {
|
||||
// Gateway `run openclaw ...` argv needs catalog routing against the embedded command path.
|
||||
const startupPath = resolveCliStartupCommandPath(argv);
|
||||
const tokens =
|
||||
resolveGatewayCatalogCommandPath(argv) ?? getCommandPathWithRootOptions(argv, argv.length);
|
||||
resolveGatewayCatalogCommandPath(argv) ??
|
||||
(startupPath[0] === "agent" ? startupPath : getCommandPathWithRootOptions(argv, argv.length));
|
||||
if (tokens.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ describe("command-startup-policy", () => {
|
||||
expect(shouldBypassConfigGuardForCommandPath(["config", "validate"])).toBe(true);
|
||||
expect(shouldBypassConfigGuardForCommandPath(["config", "schema"])).toBe(true);
|
||||
expect(shouldBypassConfigGuardForCommandPath(["docs"])).toBe(true);
|
||||
expect(shouldBypassConfigGuardForCommandPath(["agent", "exec"])).toBe(true);
|
||||
expect(shouldBypassConfigGuardForCommandPath(["config", "set"])).toBe(false);
|
||||
expect(shouldBypassConfigGuardForCommandPath(["status"])).toBe(false);
|
||||
});
|
||||
@@ -113,6 +114,12 @@ describe("command-startup-policy", () => {
|
||||
jsonOutputMode: true,
|
||||
}).loadPlugins,
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolvePolicy({
|
||||
argv: ["node", "openclaw", "agent", "exec", "fix it"],
|
||||
commandPath: ["agent", "exec"],
|
||||
}).loadPlugins,
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolvePolicy({
|
||||
argv: ["node", "openclaw", "agent"],
|
||||
|
||||
@@ -109,7 +109,7 @@ describe("command-registry", () => {
|
||||
expect(names).toContain("sessions");
|
||||
expect(names).toContain("commitments");
|
||||
expect(names).toContain("tasks");
|
||||
expect(names).not.toContain("agent");
|
||||
expect(names).toContain("agent");
|
||||
expect(names).not.toContain("setup");
|
||||
expect(names).not.toContain("status");
|
||||
expect(names).not.toContain("doctor");
|
||||
|
||||
@@ -89,7 +89,7 @@ const coreCliCommandCatalog = defineCommandDescriptorCatalog([
|
||||
{
|
||||
name: "agent",
|
||||
description: "Run an agent turn via the Gateway (use --local for embedded)",
|
||||
hasSubcommands: false,
|
||||
hasSubcommands: true,
|
||||
},
|
||||
{
|
||||
name: "agents",
|
||||
|
||||
@@ -143,12 +143,17 @@ describe("registerPreActionHooks", () => {
|
||||
|
||||
function buildProgram() {
|
||||
const programLocal = new Command().name("openclaw");
|
||||
programLocal
|
||||
const agent = programLocal
|
||||
.command("agent")
|
||||
.requiredOption("-m, --message <text>")
|
||||
.option("--local")
|
||||
.option("--json")
|
||||
.action(() => {});
|
||||
agent
|
||||
.command("exec")
|
||||
.argument("[message]")
|
||||
.option("--json")
|
||||
.action(() => {});
|
||||
programLocal
|
||||
.command("status")
|
||||
.option("--json")
|
||||
@@ -401,7 +406,7 @@ describe("registerPreActionHooks", () => {
|
||||
|
||||
expect(ensureConfigReadyMock).toHaveBeenCalledWith({
|
||||
runtime: runtimeMock,
|
||||
commandPath: ["agent", "hi"],
|
||||
commandPath: ["agent"],
|
||||
});
|
||||
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({
|
||||
scope: "all",
|
||||
@@ -416,7 +421,7 @@ describe("registerPreActionHooks", () => {
|
||||
|
||||
expect(ensureConfigReadyMock).toHaveBeenCalledWith({
|
||||
runtime: runtimeMock,
|
||||
commandPath: ["agent", "hi"],
|
||||
commandPath: ["agent"],
|
||||
suppressDoctorStdout: true,
|
||||
});
|
||||
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({
|
||||
@@ -424,6 +429,18 @@ describe("registerPreActionHooks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("bypasses operator config and plugin startup for agent exec", async () => {
|
||||
await runPreAction({
|
||||
parseArgv: ["agent", "exec", "fix it"],
|
||||
processArgv: ["node", "openclaw", "agent", "exec", "fix it"],
|
||||
});
|
||||
|
||||
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
|
||||
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
|
||||
expect(routeLogsToStderrMock).toHaveBeenCalled();
|
||||
expect(emitCliBannerMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps setup alias and channels add manifest-first", async () => {
|
||||
await runPreAction({
|
||||
parseArgv: ["onboard"],
|
||||
|
||||
@@ -6,6 +6,7 @@ import { theme } from "../../../packages/terminal-core/src/theme.js";
|
||||
import { formatHelpExamples } from "../help-format.js";
|
||||
|
||||
type AgentViaGatewayModule = typeof import("../../commands/agent-via-gateway.js");
|
||||
type AgentExecModule = typeof import("../../commands/agent-exec.js");
|
||||
type CliUtilsModule = typeof import("../cli-utils.js");
|
||||
type GlobalStateModule = typeof import("../../global-state.js");
|
||||
type RuntimeModule = typeof import("../../runtime.js");
|
||||
@@ -14,6 +15,14 @@ async function loadAgentCliCommand(): Promise<AgentViaGatewayModule["agentCliCom
|
||||
return (await import("../../commands/agent-via-gateway.js")).agentCliCommand;
|
||||
}
|
||||
|
||||
async function loadAgentExecCommand(): Promise<AgentExecModule["agentExecCommand"]> {
|
||||
return (await import("../../commands/agent-exec.js")).agentExecCommand;
|
||||
}
|
||||
|
||||
function collectFallback(value: string, previous: string[]): string[] {
|
||||
return [...previous, value];
|
||||
}
|
||||
|
||||
async function loadDefaultRuntime(): Promise<RuntimeModule["defaultRuntime"]> {
|
||||
return (await import("../../runtime.js")).defaultRuntime;
|
||||
}
|
||||
@@ -31,7 +40,7 @@ export function registerAgentTurnCommand(
|
||||
program: Command,
|
||||
args: { agentChannelOptions: string },
|
||||
): void {
|
||||
program
|
||||
const agent = program
|
||||
.command("agent")
|
||||
.description("Run an agent turn via the Gateway (use --local for embedded)")
|
||||
.option("-m, --message <text>", "Message body for the agent")
|
||||
@@ -109,4 +118,71 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.openclaw.ai/cli/age
|
||||
await agentCliCommand(opts, defaultRuntime);
|
||||
});
|
||||
});
|
||||
|
||||
agent
|
||||
.command("exec [message]")
|
||||
.description("Run one isolated headless embedded agent turn")
|
||||
.option("--message-file <path>", "Read the UTF-8 prompt from a file; use - for stdin")
|
||||
.option("--cwd <dir>", "Set both the agent workspace and tool working directory")
|
||||
.option("--state-dir <dir>", "Use an existing state directory without deleting it")
|
||||
.option("--model <provider/model>", "Use an explicit primary model for this run")
|
||||
.option(
|
||||
"--thinking <level>",
|
||||
"Thinking level: off | minimal | low | medium | high | xhigh | adaptive | max where supported",
|
||||
)
|
||||
.option(
|
||||
"--fallback <provider/model>",
|
||||
"Add an ordered fallback model (repeatable; requires --model)",
|
||||
collectFallback,
|
||||
[],
|
||||
)
|
||||
.option("--auth-env-only", "Use provider credentials from environment variables only", true)
|
||||
.option("--no-auth-env-only", "Allow stored and external CLI credential discovery")
|
||||
.option("--timeout <seconds>", "Agent deadline in seconds", "600")
|
||||
.option("--json", "Emit the stable agent-exec JSON envelope", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
|
||||
['openclaw agent exec "Fix the failing test"', "Run in the current directory."],
|
||||
[
|
||||
"openclaw agent exec --message-file task.md --cwd ./repo",
|
||||
"Read a prompt file and set the workspace.",
|
||||
],
|
||||
[
|
||||
'openclaw agent exec "Summarize this repo" --model openai/gpt-5.6-sol --fallback anthropic/claude-sonnet-4-6 --json',
|
||||
"Use an explicit fallback chain and JSON output.",
|
||||
],
|
||||
])}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/agent#agent-exec", "docs.openclaw.ai/cli/agent#agent-exec")}`,
|
||||
)
|
||||
.action(async (message: string | undefined, opts, command): Promise<void> => {
|
||||
const parentOpts = command.parent?.opts() as
|
||||
| {
|
||||
messageFile?: string;
|
||||
model?: string;
|
||||
thinking?: string;
|
||||
timeout?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
const execOpts = {
|
||||
...opts,
|
||||
messageFile: opts.messageFile ?? parentOpts?.messageFile,
|
||||
model: opts.model ?? parentOpts?.model,
|
||||
thinking: opts.thinking ?? parentOpts?.thinking,
|
||||
timeout: parentOpts?.timeout ?? opts.timeout,
|
||||
json: opts.json === true || parentOpts?.json === true,
|
||||
};
|
||||
const [defaultRuntime, runCommandWithRuntime, agentExecCommand] = await Promise.all([
|
||||
loadDefaultRuntime(),
|
||||
loadRunCommandWithRuntime(),
|
||||
loadAgentExecCommand(),
|
||||
]);
|
||||
await runCommandWithRuntime(defaultRuntime, async () => {
|
||||
const result = await agentExecCommand(message, execOpts, defaultRuntime);
|
||||
if (result.exitCode !== 0) {
|
||||
defaultRuntime.exit(result.exitCode, { resetStream: process.stderr });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { registerAgentsCommands } from "./register.agent.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
agentCliCommandMock: vi.fn(),
|
||||
agentExecCommandMock: vi.fn(),
|
||||
agentsAddCommandMock: vi.fn(),
|
||||
agentsBindingsCommandMock: vi.fn(),
|
||||
agentsBindCommandMock: vi.fn(),
|
||||
@@ -22,6 +23,7 @@ const mocks = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
const agentCliCommandMock = mocks.agentCliCommandMock;
|
||||
const agentExecCommandMock = mocks.agentExecCommandMock;
|
||||
const agentsAddCommandMock = mocks.agentsAddCommandMock;
|
||||
const agentsBindingsCommandMock = mocks.agentsBindingsCommandMock;
|
||||
const agentsBindCommandMock = mocks.agentsBindCommandMock;
|
||||
@@ -36,6 +38,10 @@ vi.mock("../../commands/agent-via-gateway.js", () => ({
|
||||
agentCliCommand: mocks.agentCliCommandMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../commands/agent-exec.js", () => ({
|
||||
agentExecCommand: mocks.agentExecCommandMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../commands/agents.commands.add.js", () => ({
|
||||
agentsAddCommand: mocks.agentsAddCommandMock,
|
||||
}));
|
||||
@@ -78,6 +84,7 @@ describe("agent command registration", () => {
|
||||
vi.clearAllMocks();
|
||||
runtime.exit.mockImplementation(() => {});
|
||||
agentCliCommandMock.mockResolvedValue(undefined);
|
||||
agentExecCommandMock.mockResolvedValue({ exitCode: 0 });
|
||||
agentsAddCommandMock.mockResolvedValue(undefined);
|
||||
agentsBindingsCommandMock.mockResolvedValue(undefined);
|
||||
agentsBindCommandMock.mockResolvedValue(undefined);
|
||||
@@ -150,6 +157,62 @@ describe("agent command registration", () => {
|
||||
expect(deps).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps bare agent on the existing parent action", async () => {
|
||||
await runCli(["agent", "--message", "hi", "--agent", "ops"]);
|
||||
|
||||
expect(agentCliCommandMock).toHaveBeenCalledTimes(1);
|
||||
expect(agentExecCommandMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps an exec-valued parent message on the existing parent action", async () => {
|
||||
await runCli(["agent", "--message", "exec", "--agent", "ops"]);
|
||||
|
||||
expect(agentCliCommandMock).toHaveBeenCalledTimes(1);
|
||||
expect(agentExecCommandMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs the nested headless exec command with repeatable fallbacks", async () => {
|
||||
await runCli([
|
||||
"agent",
|
||||
"exec",
|
||||
"fix it",
|
||||
"--cwd",
|
||||
"/tmp/project",
|
||||
"--model",
|
||||
"openai/gpt-5.6-sol",
|
||||
"--fallback",
|
||||
"anthropic/claude-sonnet-4-6",
|
||||
"--fallback",
|
||||
"google/gemini-3.1-pro-preview",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
expect(agentCliCommandMock).not.toHaveBeenCalled();
|
||||
expect(agentExecCommandMock).toHaveBeenCalledWith(
|
||||
"fix it",
|
||||
expect.objectContaining({
|
||||
cwd: "/tmp/project",
|
||||
model: "openai/gpt-5.6-sol",
|
||||
fallback: ["anthropic/claude-sonnet-4-6", "google/gemini-3.1-pro-preview"],
|
||||
authEnvOnly: true,
|
||||
timeout: "600",
|
||||
json: true,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts parent options before the nested exec command", async () => {
|
||||
await runCli(["agent", "--model", "openai/gpt-5.6-sol", "exec", "fix it", "--json"]);
|
||||
|
||||
expect(agentCliCommandMock).not.toHaveBeenCalled();
|
||||
expect(agentExecCommandMock).toHaveBeenCalledWith(
|
||||
"fix it",
|
||||
expect.objectContaining({ model: "openai/gpt-5.6-sol", json: true }),
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
|
||||
it("runs agents add and computes hasFlags based on explicit options", async () => {
|
||||
await runCli(["agents", "add", "alpha"]);
|
||||
const [alphaOptions, alphaRuntime, alphaFlags] = commandCall(agentsAddCommandMock, 0);
|
||||
|
||||
+9
-1
@@ -677,6 +677,10 @@ function shouldLoadCliDotEnv(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return existsSync(path.join(resolveStateDir(env), ".env"));
|
||||
}
|
||||
|
||||
function isAgentExecInvocation(commandPath: string[]): boolean {
|
||||
return commandPath[0] === "agent" && commandPath[1] === "exec";
|
||||
}
|
||||
|
||||
function isCommanderParseExit(error: unknown): error is { exitCode: number } {
|
||||
if (!error || typeof error !== "object") {
|
||||
return false;
|
||||
@@ -999,7 +1003,11 @@ export async function runCli(argv: string[] = process.argv) {
|
||||
// Enforce the minimum supported runtime before gateway selection can read or recover config.
|
||||
assertSupportedRuntime();
|
||||
|
||||
if (!isHelpOrVersionInvocation && (isGatewayRunInvocation || shouldLoadCliDotEnv())) {
|
||||
if (
|
||||
!isHelpOrVersionInvocation &&
|
||||
!isAgentExecInvocation(normalizedInvocation.commandPath) &&
|
||||
(isGatewayRunInvocation || shouldLoadCliDotEnv())
|
||||
) {
|
||||
await startupTrace.measure("dotenv", async () => {
|
||||
if (isRemoteAgentDispatchInvocation(normalizedArgv, normalizedInvocation.primary)) {
|
||||
const { loadGatewayDispatchCliDotEnv } = await import("./gateway-dispatch-dotenv.js");
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
findPersistedAuthProfileCredential,
|
||||
loadAuthProfileStoreForRuntime,
|
||||
resolvePersistedAuthProfileOwnerAgentDir,
|
||||
} from "../agents/auth-profiles.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { agentExecCommand, classifyAgentExecResult, resolveAgentExecPrompt } from "./agent-exec.js";
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
async function makeTempRoot(prefix: string): Promise<string> {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
tempRoots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function createRuntime() {
|
||||
const log = vi.fn();
|
||||
const error = vi.fn();
|
||||
const runtime: RuntimeEnv = {
|
||||
log,
|
||||
error,
|
||||
exit: vi.fn(),
|
||||
};
|
||||
return { runtime, log, error };
|
||||
}
|
||||
|
||||
function successResult(text = "done") {
|
||||
return {
|
||||
payloads: [{ text }],
|
||||
meta: {
|
||||
durationMs: 25,
|
||||
finalAssistantVisibleText: text,
|
||||
agentMeta: {
|
||||
sessionId: "session-result",
|
||||
provider: "openai",
|
||||
model: "gpt-5.6-sol",
|
||||
usage: { input: 10, output: 2, total: 12 },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("agent exec prompt sources", () => {
|
||||
it("accepts a positional prompt", async () => {
|
||||
await expect(resolveAgentExecPrompt("fix it", undefined)).resolves.toBe("fix it");
|
||||
});
|
||||
|
||||
it("reads a UTF-8 prompt file", async () => {
|
||||
const root = await makeTempRoot("openclaw-agent-exec-prompt-");
|
||||
const promptPath = path.join(root, "prompt.md");
|
||||
await fs.writeFile(promptPath, "\uFEFFline one\nline two", "utf8");
|
||||
|
||||
await expect(resolveAgentExecPrompt(undefined, promptPath)).resolves.toBe("line one\nline two");
|
||||
});
|
||||
|
||||
it("reads --message-file - from stdin", async () => {
|
||||
const stdin = Readable.from([Buffer.from("from stdin", "utf8")]);
|
||||
await expect(resolveAgentExecPrompt(undefined, "-", stdin)).resolves.toBe("from stdin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent exec strict result classification", () => {
|
||||
it("classifies a successful embedded result", () => {
|
||||
expect(classifyAgentExecResult(successResult())).toMatchObject({
|
||||
ok: true,
|
||||
status: "ok",
|
||||
final: "done",
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies model error payloads as failure", () => {
|
||||
const envelope = classifyAgentExecResult({
|
||||
payloads: [{ text: "provider rejected request", isError: true }],
|
||||
meta: { durationMs: 10 },
|
||||
});
|
||||
expect(envelope).toMatchObject({
|
||||
ok: false,
|
||||
status: "error",
|
||||
error: { kind: "error_payload", message: "provider rejected request" },
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies textless error payloads as failure", () => {
|
||||
const envelope = classifyAgentExecResult({
|
||||
payloads: [{ isError: true }],
|
||||
meta: { durationMs: 10 },
|
||||
});
|
||||
expect(envelope).toMatchObject({
|
||||
ok: false,
|
||||
status: "error",
|
||||
error: { kind: "error_payload", message: "Agent run failed" },
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies terminal timeouts separately", () => {
|
||||
const envelope = classifyAgentExecResult({
|
||||
payloads: [{ text: "timed out", isError: true }],
|
||||
meta: { durationMs: 600_000, aborted: true, stopReason: "timeout" },
|
||||
});
|
||||
expect(envelope).toMatchObject({
|
||||
ok: false,
|
||||
status: "timeout",
|
||||
error: { kind: "timeout" },
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies exhausted explicit fallbacks as failure", () => {
|
||||
const envelope = classifyAgentExecResult(successResult("last candidate output"), true);
|
||||
expect(envelope).toMatchObject({
|
||||
ok: false,
|
||||
status: "error",
|
||||
error: { kind: "fallback_exhausted" },
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies projected production error payloads as failure", () => {
|
||||
const envelope = classifyAgentExecResult(
|
||||
successResult("projected error text"),
|
||||
false,
|
||||
"projected error text",
|
||||
);
|
||||
expect(envelope).toMatchObject({
|
||||
ok: false,
|
||||
status: "error",
|
||||
final: "",
|
||||
payloads: [{ text: "projected error text", isError: true }],
|
||||
error: { kind: "error_payload", message: "projected error text" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not restore metadata text for a projected textless error", () => {
|
||||
const result = successResult("metadata error text");
|
||||
result.payloads = [];
|
||||
const envelope = classifyAgentExecResult(result, false, true);
|
||||
expect(envelope).toMatchObject({ ok: false, status: "error", final: "", payloads: [] });
|
||||
});
|
||||
|
||||
it("projects payloads onto the stable documented fields", () => {
|
||||
const envelope = classifyAgentExecResult({
|
||||
payloads: [
|
||||
{
|
||||
text: "done",
|
||||
mediaUrl: null,
|
||||
audioAsVoice: true,
|
||||
presentation: { blocks: [] },
|
||||
channelData: { private: true },
|
||||
},
|
||||
],
|
||||
meta: { durationMs: 10 },
|
||||
});
|
||||
expect(envelope.payloads).toEqual([{ text: "done", mediaUrl: null }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent exec command composition", () => {
|
||||
it("treats invalid timeout syntax as an ordinary usage error", async () => {
|
||||
const { runtime } = createRuntime();
|
||||
|
||||
const result = await agentExecCommand("inspect", { timeout: "nope", json: true }, runtime, {
|
||||
runAgent: vi.fn(async () => successResult()),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
exitCode: 1,
|
||||
envelope: { status: "error", error: { kind: "exception" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("maps structured thrown timeouts to exit code 2", async () => {
|
||||
const { runtime } = createRuntime();
|
||||
const timeout = Object.assign(new Error("deadline elapsed"), { name: "TimeoutError" });
|
||||
|
||||
const result = await agentExecCommand("inspect", { json: true }, runtime, {
|
||||
runAgent: vi.fn(async () => {
|
||||
throw timeout;
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
exitCode: 2,
|
||||
envelope: { status: "timeout", error: { kind: "timeout" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("creates and removes ephemeral state around the embedded run", async () => {
|
||||
const { runtime } = createRuntime();
|
||||
let observedStateDir = "";
|
||||
let observedConfig: unknown;
|
||||
const result = await agentExecCommand("inspect", {}, runtime, {
|
||||
runAgent: vi.fn(async () => {
|
||||
observedStateDir = process.env.OPENCLAW_STATE_DIR ?? "";
|
||||
observedConfig = JSON.parse(
|
||||
await fs.readFile(process.env.OPENCLAW_CONFIG_PATH ?? "", "utf8"),
|
||||
);
|
||||
await expect(fs.stat(observedStateDir)).resolves.toBeDefined();
|
||||
return successResult();
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(observedConfig).toMatchObject({
|
||||
agents: { defaults: { skipBootstrap: true, sandbox: { mode: "off" } } },
|
||||
tools: {
|
||||
profile: "coding",
|
||||
fs: { workspaceOnly: true },
|
||||
exec: { host: "gateway", mode: "full" },
|
||||
},
|
||||
});
|
||||
await expect(fs.stat(observedStateDir)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("classifies cleanup failures before emitting the JSON envelope", async () => {
|
||||
const { runtime, log } = createRuntime();
|
||||
let observedStateDir = "";
|
||||
vi.spyOn(fs, "rm").mockRejectedValueOnce(new Error("cleanup denied"));
|
||||
|
||||
const result = await agentExecCommand("inspect", { json: true }, runtime, {
|
||||
runAgent: vi.fn(async () => {
|
||||
observedStateDir = process.env.OPENCLAW_STATE_DIR ?? "";
|
||||
return successResult();
|
||||
}),
|
||||
});
|
||||
tempRoots.push(observedStateDir);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
exitCode: 1,
|
||||
envelope: {
|
||||
ok: false,
|
||||
status: "error",
|
||||
error: { kind: "exception", message: "Agent exec cleanup failed: cleanup denied" },
|
||||
},
|
||||
});
|
||||
expect(log).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({
|
||||
ok: false,
|
||||
status: "error",
|
||||
error: { message: "Agent exec cleanup failed: cleanup denied" },
|
||||
});
|
||||
});
|
||||
|
||||
it("threads --cwd to both workspace and tool cwd", async () => {
|
||||
const root = await makeTempRoot("openclaw-agent-exec-cwd-");
|
||||
const { runtime } = createRuntime();
|
||||
const runAgent = vi.fn(async () => successResult());
|
||||
|
||||
await agentExecCommand("inspect", { cwd: root }, runtime, { runAgent });
|
||||
|
||||
expect(runAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ workspaceDir: root, cwd: root }),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits the small stable JSON envelope", async () => {
|
||||
const { runtime, log } = createRuntime();
|
||||
|
||||
const result = await agentExecCommand("inspect", { json: true }, runtime, {
|
||||
runAgent: vi.fn(async () => successResult("final answer")),
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(log).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({
|
||||
ok: true,
|
||||
status: "ok",
|
||||
final: "final answer",
|
||||
payloads: [{ text: "final answer" }],
|
||||
usage: { input: 10, output: 2, total: 12 },
|
||||
model: "gpt-5.6-sol",
|
||||
provider: "openai",
|
||||
sessionId: "session-result",
|
||||
});
|
||||
});
|
||||
|
||||
it("honors ordered fallbacks with an explicit primary model", async () => {
|
||||
const { runtime } = createRuntime();
|
||||
const runAgent = vi.fn(async () => successResult());
|
||||
|
||||
await agentExecCommand(
|
||||
"inspect",
|
||||
{
|
||||
model: "openai/gpt-5.6-sol",
|
||||
fallback: ["anthropic/claude-sonnet-4-6", "google/gemini-3.1-pro-preview"],
|
||||
},
|
||||
runtime,
|
||||
{ runAgent },
|
||||
);
|
||||
|
||||
expect(runAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: "openai/gpt-5.6-sol",
|
||||
modelFallbacksOverride: ["anthropic/claude-sonnet-4-6", "google/gemini-3.1-pro-preview"],
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an explicit state directory and deletes only its temporary config", async () => {
|
||||
const stateDir = await makeTempRoot("openclaw-agent-exec-state-");
|
||||
const marker = path.join(stateDir, "keep.txt");
|
||||
await fs.writeFile(marker, "keep", "utf8");
|
||||
const { runtime } = createRuntime();
|
||||
let configPath = "";
|
||||
|
||||
await agentExecCommand("inspect", { stateDir }, runtime, {
|
||||
runAgent: vi.fn(async () => {
|
||||
configPath = process.env.OPENCLAW_CONFIG_PATH ?? "";
|
||||
expect(process.env.OPENCLAW_STATE_DIR).toBe(stateDir);
|
||||
return successResult();
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(fs.readFile(marker, "utf8")).resolves.toBe("keep");
|
||||
await expect(fs.stat(configPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("skips external Codex CLI credentials in default auth-env-only mode", async () => {
|
||||
const codexHome = await makeTempRoot("openclaw-agent-exec-codex-home-");
|
||||
await fs.writeFile(
|
||||
path.join(codexHome, "auth.json"),
|
||||
JSON.stringify({
|
||||
auth_mode: "chatgpt",
|
||||
tokens: { access_token: "test-access", refresh_token: "test-refresh" },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const previousCodexHome = process.env.CODEX_HOME;
|
||||
const previousOpenAiApiKey = process.env.OPENAI_API_KEY;
|
||||
const previousDatabaseUrl = process.env.DATABASE_URL;
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
process.env.OPENAI_API_KEY = "test-openai-key";
|
||||
process.env.DATABASE_URL = "postgres://test.invalid/database";
|
||||
const { runtime } = createRuntime();
|
||||
let profileIds: string[] = [];
|
||||
let runtimeProfileIds: string[] = [];
|
||||
let hostExecApiKey: string | undefined;
|
||||
let hostExecDatabaseUrl: string | undefined;
|
||||
try {
|
||||
const { withHostExecInheritedEnvOmitted } = await import("../infra/host-env-security.js");
|
||||
await withHostExecInheritedEnvOmitted(["DATABASE_URL"], () =>
|
||||
agentExecCommand("inspect", {}, runtime, {
|
||||
runAgent: vi.fn(async () => {
|
||||
profileIds = Object.keys(
|
||||
ensureAuthProfileStore(undefined, {
|
||||
allowKeychainPrompt: false,
|
||||
externalCliProviderIds: ["openai"],
|
||||
}).profiles,
|
||||
);
|
||||
runtimeProfileIds = Object.keys(
|
||||
loadAuthProfileStoreForRuntime(undefined, {
|
||||
allowKeychainPrompt: false,
|
||||
externalCliProviderIds: ["openai"],
|
||||
}).profiles,
|
||||
);
|
||||
const { sanitizeHostExecEnv } = await import("../infra/host-env-security.js");
|
||||
const hostExecEnv = sanitizeHostExecEnv({ baseEnv: process.env });
|
||||
hostExecApiKey = hostExecEnv.OPENAI_API_KEY;
|
||||
hostExecDatabaseUrl = hostExecEnv.DATABASE_URL;
|
||||
return successResult();
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
if (previousCodexHome === undefined) {
|
||||
delete process.env.CODEX_HOME;
|
||||
} else {
|
||||
process.env.CODEX_HOME = previousCodexHome;
|
||||
}
|
||||
if (previousOpenAiApiKey === undefined) {
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
} else {
|
||||
process.env.OPENAI_API_KEY = previousOpenAiApiKey;
|
||||
}
|
||||
if (previousDatabaseUrl === undefined) {
|
||||
delete process.env.DATABASE_URL;
|
||||
} else {
|
||||
process.env.DATABASE_URL = previousDatabaseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
expect(profileIds).toEqual([]);
|
||||
expect(runtimeProfileIds).toEqual([]);
|
||||
expect(hostExecApiKey).toBeUndefined();
|
||||
expect(hostExecDatabaseUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it("blocks direct persisted credential reads in default auth-env-only mode", async () => {
|
||||
const normalStateDir = await makeTempRoot("openclaw-agent-exec-hidden-auth-");
|
||||
const normalAgentDir = path.join(normalStateDir, "agents", "main", "agent");
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
process.env.OPENCLAW_STATE_DIR = normalStateDir;
|
||||
const { saveAuthProfileStore } = await import("../agents/auth-profiles.js");
|
||||
saveAuthProfileStore(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai:stored": { type: "api_key", provider: "openai", key: "test-key" },
|
||||
},
|
||||
},
|
||||
normalAgentDir,
|
||||
);
|
||||
const { runtime } = createRuntime();
|
||||
let persistedCredential: unknown;
|
||||
let ownerAgentDir: string | undefined;
|
||||
try {
|
||||
await agentExecCommand("inspect", {}, runtime, {
|
||||
runAgent: vi.fn(async () => {
|
||||
persistedCredential = findPersistedAuthProfileCredential({
|
||||
agentDir: normalAgentDir,
|
||||
profileId: "openai:stored",
|
||||
});
|
||||
ownerAgentDir = resolvePersistedAuthProfileOwnerAgentDir({
|
||||
agentDir: normalAgentDir,
|
||||
profileId: "openai:stored",
|
||||
});
|
||||
return successResult();
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
}
|
||||
|
||||
expect(persistedCredential).toBeUndefined();
|
||||
expect(ownerAgentDir).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the normal stored auth profile when auth-env-only is disabled", async () => {
|
||||
const normalStateDir = await makeTempRoot("openclaw-agent-exec-normal-state-");
|
||||
const normalAgentDir = path.join(normalStateDir, "agents", "main", "agent");
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
process.env.OPENCLAW_STATE_DIR = normalStateDir;
|
||||
const { saveAuthProfileStore } = await import("../agents/auth-profiles.js");
|
||||
saveAuthProfileStore(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai:stored": { type: "api_key", provider: "openai", key: "test-key" },
|
||||
},
|
||||
},
|
||||
normalAgentDir,
|
||||
);
|
||||
const { runtime } = createRuntime();
|
||||
let profileIds: string[] = [];
|
||||
try {
|
||||
await agentExecCommand("inspect", { authEnvOnly: false }, runtime, {
|
||||
runAgent: vi.fn(async () => {
|
||||
expect(process.env.OPENCLAW_STATE_DIR).not.toBe(normalStateDir);
|
||||
profileIds = Object.keys(
|
||||
ensureAuthProfileStore(undefined, {
|
||||
allowKeychainPrompt: false,
|
||||
syncExternalCli: false,
|
||||
}).profiles,
|
||||
);
|
||||
return successResult();
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
}
|
||||
|
||||
expect(profileIds).toContain("openai:stored");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,512 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { TextDecoder } from "node:util";
|
||||
import { readByteStreamWithLimit } from "@openclaw/media-core/read-byte-stream-with-limit";
|
||||
import type { EmbeddedAgentRunMeta } from "../agents/embedded-agent.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { parseStrictNonNegativeInteger } from "../infra/parse-finite-number.js";
|
||||
import { writeRuntimeJson, type RuntimeEnv } from "../runtime.js";
|
||||
|
||||
const AGENT_EXEC_MESSAGE_MAX_BYTES = 4 * 1024 * 1024;
|
||||
const AGENT_EXEC_DEFAULT_TIMEOUT_SECONDS = 600;
|
||||
const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
|
||||
|
||||
export type AgentExecCliOptions = {
|
||||
messageFile?: string;
|
||||
cwd?: string;
|
||||
stateDir?: string;
|
||||
model?: string;
|
||||
thinking?: string;
|
||||
fallback?: string[];
|
||||
authEnvOnly?: boolean;
|
||||
timeout?: string;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type AgentExecPayload = {
|
||||
text?: string;
|
||||
mediaUrl?: string | null;
|
||||
mediaUrls?: string[];
|
||||
isError?: boolean;
|
||||
isReasoning?: boolean;
|
||||
isCommentary?: boolean;
|
||||
};
|
||||
|
||||
type AgentExecRawPayload = AgentExecPayload & Record<string, unknown>;
|
||||
|
||||
type AgentExecRunResult = {
|
||||
payloads?: AgentExecRawPayload[];
|
||||
meta: EmbeddedAgentRunMeta;
|
||||
};
|
||||
|
||||
type AgentExecStatus = "ok" | "error" | "timeout";
|
||||
|
||||
export type AgentExecEnvelope = {
|
||||
ok: boolean;
|
||||
status: AgentExecStatus;
|
||||
final: string;
|
||||
payloads: AgentExecPayload[];
|
||||
usage?: NonNullable<NonNullable<EmbeddedAgentRunMeta["agentMeta"]>["usage"]>;
|
||||
model: string | null;
|
||||
provider: string | null;
|
||||
sessionId: string;
|
||||
error?: {
|
||||
message: string;
|
||||
kind: string;
|
||||
};
|
||||
};
|
||||
|
||||
type AgentExecCommandResult = {
|
||||
envelope: AgentExecEnvelope;
|
||||
exitCode: 0 | 1 | 2;
|
||||
};
|
||||
|
||||
type AgentExecCommandDeps = {
|
||||
stdin?: AsyncIterable<unknown>;
|
||||
runAgent?: (
|
||||
opts: Record<string, unknown>,
|
||||
runtime: RuntimeEnv,
|
||||
) => Promise<AgentExecRunResult | undefined>;
|
||||
};
|
||||
|
||||
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 as NodeJS.ErrnoException).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;
|
||||
}
|
||||
|
||||
function projectAgentExecPayload(payload: AgentExecRawPayload): AgentExecPayload {
|
||||
return {
|
||||
...(typeof payload.text === "string" ? { text: payload.text } : {}),
|
||||
...(payload.mediaUrl !== undefined ? { mediaUrl: payload.mediaUrl } : {}),
|
||||
...(Array.isArray(payload.mediaUrls) ? { mediaUrls: [...payload.mediaUrls] } : {}),
|
||||
...(payload.isError === true ? { isError: true } : {}),
|
||||
...(payload.isReasoning === true ? { isReasoning: true } : {}),
|
||||
...(payload.isCommentary === true ? { isCommentary: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function finalTextFromResult(
|
||||
result: AgentExecRunResult,
|
||||
payloads: AgentExecPayload[],
|
||||
allowMetadataFallback: boolean,
|
||||
): string {
|
||||
const payloadText = payloads
|
||||
.filter(
|
||||
(payload) =>
|
||||
payload.isError !== true &&
|
||||
payload.isReasoning !== true &&
|
||||
payload.isCommentary !== true &&
|
||||
typeof payload.text === "string" &&
|
||||
payload.text.trim().length > 0,
|
||||
)
|
||||
.map((payload) => payload.text!.trimEnd())
|
||||
.join("\n");
|
||||
return (
|
||||
payloadText ||
|
||||
(allowMetadataFallback ? result.meta.finalAssistantVisibleText?.trimEnd() : "") ||
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
function firstErrorPayload(result: AgentExecRunResult): AgentExecPayload | undefined {
|
||||
return result.payloads?.find((payload) => payload.isError === true);
|
||||
}
|
||||
|
||||
/** Classify an embedded result into the strict `agent exec` process contract. */
|
||||
export function classifyAgentExecResult(
|
||||
result: AgentExecRunResult,
|
||||
fallbackExhausted = false,
|
||||
projectedErrorPayload?: string | true,
|
||||
): AgentExecEnvelope {
|
||||
const meta = result.meta;
|
||||
const errorPayload = firstErrorPayload(result);
|
||||
const errorPayloadMessage =
|
||||
typeof projectedErrorPayload === "string"
|
||||
? projectedErrorPayload
|
||||
: typeof errorPayload?.text === "string" && errorPayload.text.trim()
|
||||
? errorPayload.text
|
||||
: undefined;
|
||||
const hasErrorPayload = projectedErrorPayload !== undefined || errorPayload !== undefined;
|
||||
const payloads = (result.payloads ?? []).map(projectAgentExecPayload);
|
||||
if (typeof projectedErrorPayload === "string") {
|
||||
const projectedErrorIndex = payloads.findIndex(
|
||||
(payload) => payload.isError !== true && payload.text === projectedErrorPayload,
|
||||
);
|
||||
if (projectedErrorIndex >= 0) {
|
||||
payloads[projectedErrorIndex] = {
|
||||
...payloads[projectedErrorIndex],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
const timeout = meta.stopReason === "timeout" || meta.timeoutPhase !== undefined;
|
||||
const failed =
|
||||
fallbackExhausted ||
|
||||
meta.aborted === true ||
|
||||
meta.error !== undefined ||
|
||||
meta.stopReason === "error" ||
|
||||
hasErrorPayload;
|
||||
const status: AgentExecStatus = timeout ? "timeout" : failed ? "error" : "ok";
|
||||
const errorMessage = timeout
|
||||
? (meta.error?.message ?? errorPayloadMessage ?? "Agent run timed out")
|
||||
: fallbackExhausted
|
||||
? (meta.error?.message ?? errorPayloadMessage ?? "All model fallback candidates failed")
|
||||
: (meta.error?.message ?? errorPayloadMessage ?? (failed ? "Agent run failed" : undefined));
|
||||
const errorKind = timeout
|
||||
? "timeout"
|
||||
: fallbackExhausted
|
||||
? "fallback_exhausted"
|
||||
: meta.error?.kind
|
||||
? meta.error.kind
|
||||
: meta.aborted
|
||||
? "aborted"
|
||||
: hasErrorPayload
|
||||
? "error_payload"
|
||||
: failed
|
||||
? "agent_error"
|
||||
: undefined;
|
||||
const agentMeta = meta.agentMeta;
|
||||
return {
|
||||
ok: status === "ok",
|
||||
status,
|
||||
final: finalTextFromResult(result, payloads, !hasErrorPayload),
|
||||
payloads,
|
||||
...(agentMeta?.usage ? { usage: agentMeta.usage } : {}),
|
||||
model: agentMeta?.model ?? null,
|
||||
provider: agentMeta?.provider ?? null,
|
||||
sessionId: agentMeta?.sessionId ?? "",
|
||||
...(errorMessage && errorKind ? { error: { message: errorMessage, kind: errorKind } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function exitCodeForEnvelope(envelope: AgentExecEnvelope): 0 | 1 | 2 {
|
||||
return envelope.status === "ok" ? 0 : envelope.status === "timeout" ? 2 : 1;
|
||||
}
|
||||
|
||||
function buildImplicitConfig(cwd: string): OpenClawConfig {
|
||||
return {
|
||||
env: { shellEnv: { enabled: false } },
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: cwd,
|
||||
skipBootstrap: true,
|
||||
sandbox: { mode: "off" },
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
profile: "coding",
|
||||
fs: { workspaceOnly: true },
|
||||
exec: { host: "gateway", mode: "full" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTimeoutSeconds(value: string | undefined): string {
|
||||
const raw = value ?? String(AGENT_EXEC_DEFAULT_TIMEOUT_SECONDS);
|
||||
if (parseStrictNonNegativeInteger(raw) === undefined) {
|
||||
throw new Error("--timeout must be a non-negative integer in seconds.");
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function normalizeFallbacks(model: string | undefined, values: string[] | undefined): string[] {
|
||||
const fallbacks = (values ?? []).map((value) => value.trim()).filter(Boolean);
|
||||
if (fallbacks.length > 0 && !model?.trim()) {
|
||||
throw new Error("--fallback requires --model so the primary model is explicit.");
|
||||
}
|
||||
return fallbacks;
|
||||
}
|
||||
|
||||
async function requireDirectory(value: string, label: string): Promise<string> {
|
||||
const resolved = path.resolve(value);
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.stat(resolved);
|
||||
} catch (error) {
|
||||
throw new Error(`${label} does not exist: ${resolved}`, { cause: error });
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`${label} is not a directory: ${resolved}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function setAgentExecEnvironment(params: {
|
||||
stateDir: string;
|
||||
configPath: string;
|
||||
cwd: string;
|
||||
}): () => void {
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
const previousConfigPath = process.env.OPENCLAW_CONFIG_PATH;
|
||||
const previousWorkspaceDir = process.env.OPENCLAW_WORKSPACE_DIR;
|
||||
process.env.OPENCLAW_STATE_DIR = params.stateDir;
|
||||
process.env.OPENCLAW_CONFIG_PATH = params.configPath;
|
||||
process.env.OPENCLAW_WORKSPACE_DIR = params.cwd;
|
||||
return () => {
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
if (previousConfigPath === undefined) {
|
||||
delete process.env.OPENCLAW_CONFIG_PATH;
|
||||
} else {
|
||||
process.env.OPENCLAW_CONFIG_PATH = previousConfigPath;
|
||||
}
|
||||
if (previousWorkspaceDir === undefined) {
|
||||
delete process.env.OPENCLAW_WORKSPACE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_WORKSPACE_DIR = previousWorkspaceDir;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function isStructuredTimeoutError(error: unknown): boolean {
|
||||
let candidate = error;
|
||||
for (let depth = 0; depth < 4; depth += 1) {
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
return false;
|
||||
}
|
||||
const record = candidate as {
|
||||
cause?: unknown;
|
||||
code?: unknown;
|
||||
name?: unknown;
|
||||
reason?: unknown;
|
||||
};
|
||||
if (
|
||||
record.name === "TimeoutError" ||
|
||||
record.code === "ETIMEDOUT" ||
|
||||
record.reason === "timeout"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
candidate = record.cause;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function errorEnvelope(error: unknown, sessionId: string): AgentExecEnvelope {
|
||||
const status = isStructuredTimeoutError(error) ? "timeout" : "error";
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
final: "",
|
||||
payloads: [],
|
||||
model: null,
|
||||
provider: null,
|
||||
sessionId,
|
||||
error: {
|
||||
message: formatErrorMessage(error),
|
||||
kind: status === "timeout" ? "timeout" : "exception",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function writeAgentExecOutput(
|
||||
runtime: RuntimeEnv,
|
||||
envelope: AgentExecEnvelope,
|
||||
json: boolean,
|
||||
): void {
|
||||
if (json) {
|
||||
writeRuntimeJson(runtime, envelope);
|
||||
} else if (envelope.final) {
|
||||
runtime.log(envelope.final);
|
||||
}
|
||||
if (!envelope.ok && envelope.error) {
|
||||
runtime.error(envelope.error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Run one isolated embedded agent turn and project its stable CLI result. */
|
||||
export async function agentExecCommand(
|
||||
positionalMessage: string | undefined,
|
||||
opts: AgentExecCliOptions,
|
||||
runtime: RuntimeEnv,
|
||||
deps: AgentExecCommandDeps = {},
|
||||
): Promise<AgentExecCommandResult> {
|
||||
const sessionId = randomUUID();
|
||||
let commandResult: AgentExecCommandResult;
|
||||
let cleanupRoot: string | undefined;
|
||||
let restoreEnvironment: (() => void) | undefined;
|
||||
let runtimePaths: typeof import("../config/paths.js") | undefined;
|
||||
let configIo: typeof import("../config/io.js") | undefined;
|
||||
try {
|
||||
const prompt = await resolveAgentExecPrompt(
|
||||
positionalMessage,
|
||||
opts.messageFile,
|
||||
deps.stdin ?? process.stdin,
|
||||
);
|
||||
const cwd = await requireDirectory(opts.cwd ?? process.cwd(), "Working directory");
|
||||
const stateDir = opts.stateDir
|
||||
? await requireDirectory(opts.stateDir, "State directory")
|
||||
: await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-exec-"));
|
||||
cleanupRoot = opts.stateDir
|
||||
? await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-exec-config-"))
|
||||
: stateDir;
|
||||
const configPath = path.join(cleanupRoot, "openclaw.json");
|
||||
await fs.writeFile(configPath, `${JSON.stringify(buildImplicitConfig(cwd), null, 2)}\n`, {
|
||||
encoding: "utf8",
|
||||
flag: "wx",
|
||||
mode: 0o600,
|
||||
});
|
||||
const timeout = normalizeTimeoutSeconds(opts.timeout);
|
||||
const fallbacks = normalizeFallbacks(opts.model, opts.fallback);
|
||||
const { resolveDefaultAgentDir } = await import("../agents/agent-scope-config.js");
|
||||
const storedAuthAgentDir = resolveDefaultAgentDir({});
|
||||
restoreEnvironment = setAgentExecEnvironment({ stateDir, configPath, cwd });
|
||||
[runtimePaths, configIo] = await Promise.all([
|
||||
import("../config/paths.js"),
|
||||
import("../config/io.js"),
|
||||
]);
|
||||
configIo.clearConfigCache();
|
||||
configIo.clearRuntimeConfigSnapshot();
|
||||
runtimePaths.pinRuntimePaths();
|
||||
const [
|
||||
{ withAuthProfileStoreAgentDir, withEnvOnlyAuthProfileStore },
|
||||
{ withHostExecInheritedEnvOmitted },
|
||||
{ listKnownProviderAuthEnvVarNames },
|
||||
runAgent,
|
||||
] = await Promise.all([
|
||||
import("../agents/auth-profiles.js"),
|
||||
import("../infra/host-env-security.js"),
|
||||
import("../secrets/provider-env-vars.js"),
|
||||
deps.runAgent
|
||||
? Promise.resolve(deps.runAgent)
|
||||
: import("./agent.js").then((module) => module.agentCommand),
|
||||
]);
|
||||
let fallbackExhausted = false;
|
||||
let resultErrorPayload: string | true | undefined;
|
||||
const silentRuntime: RuntimeEnv = {
|
||||
log: () => {},
|
||||
error: (...args) => runtime.error(...args),
|
||||
exit: (code, exitOpts) => runtime.exit(code, exitOpts),
|
||||
};
|
||||
const invoke = async () =>
|
||||
await runAgent(
|
||||
{
|
||||
message: prompt,
|
||||
sessionId,
|
||||
workspaceDir: cwd,
|
||||
cwd,
|
||||
model: opts.model,
|
||||
thinking: opts.thinking,
|
||||
timeout,
|
||||
modelFallbacksOverride: fallbacks.length > 0 ? fallbacks : undefined,
|
||||
cleanupBundleMcpOnRunEnd: true,
|
||||
cleanupCliLiveSessionOnRunEnd: true,
|
||||
oneShotCliRun: true,
|
||||
onModelFallbackExhausted: () => {
|
||||
fallbackExhausted = true;
|
||||
},
|
||||
onResultErrorPayload: (message?: string) => {
|
||||
resultErrorPayload = message ?? true;
|
||||
},
|
||||
},
|
||||
silentRuntime,
|
||||
);
|
||||
const runWithAuthScope = () =>
|
||||
opts.authEnvOnly === false
|
||||
? withAuthProfileStoreAgentDir(storedAuthAgentDir, invoke)
|
||||
: withEnvOnlyAuthProfileStore(invoke);
|
||||
const result = await withHostExecInheritedEnvOmitted(
|
||||
listKnownProviderAuthEnvVarNames({ env: process.env }),
|
||||
runWithAuthScope,
|
||||
);
|
||||
if (!result) {
|
||||
throw new Error("Agent run returned no result");
|
||||
}
|
||||
const envelope = classifyAgentExecResult(result, fallbackExhausted, resultErrorPayload);
|
||||
if (!envelope.sessionId) {
|
||||
envelope.sessionId = sessionId;
|
||||
}
|
||||
commandResult = { envelope, exitCode: exitCodeForEnvelope(envelope) };
|
||||
} catch (error) {
|
||||
const envelope = errorEnvelope(error, sessionId);
|
||||
commandResult = { envelope, exitCode: exitCodeForEnvelope(envelope) };
|
||||
}
|
||||
|
||||
let cleanupError: unknown;
|
||||
const runCleanupStep = (step: () => void) => {
|
||||
try {
|
||||
step();
|
||||
} catch (error) {
|
||||
cleanupError ??= error;
|
||||
}
|
||||
};
|
||||
runCleanupStep(() => restoreEnvironment?.());
|
||||
runCleanupStep(() => configIo?.clearConfigCache());
|
||||
runCleanupStep(() => configIo?.clearRuntimeConfigSnapshot());
|
||||
runCleanupStep(() => runtimePaths?.pinRuntimePaths());
|
||||
if (cleanupRoot) {
|
||||
try {
|
||||
await fs.rm(cleanupRoot, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
cleanupError ??= error;
|
||||
}
|
||||
}
|
||||
if (cleanupError) {
|
||||
const cleanupFailure = new Error(
|
||||
`Agent exec cleanup failed: ${formatErrorMessage(cleanupError)}`,
|
||||
);
|
||||
const envelope = errorEnvelope(cleanupFailure, sessionId);
|
||||
commandResult = { envelope, exitCode: exitCodeForEnvelope(envelope) };
|
||||
}
|
||||
|
||||
writeAgentExecOutput(runtime, commandResult.envelope, opts.json === true);
|
||||
return commandResult;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
// Filters host environment variables before passing them to runtimes.
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { sortUniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
||||
import { HOST_ENV_SECURITY_POLICY } from "./host-env-security-policy.js";
|
||||
import { markOpenClawExecEnv } from "./openclaw-exec-env.js";
|
||||
@@ -48,6 +49,24 @@ const GIT_ALLOW_PROTOCOL_ENV_KEY = "GIT_ALLOW_PROTOCOL";
|
||||
const GIT_PROTOCOL_FROM_USER_ENV_KEY = "GIT_PROTOCOL_FROM_USER";
|
||||
const GIT_PROTOCOL_FROM_USER_DISABLED_VALUE = "0";
|
||||
const GIT_DEFAULT_ALWAYS_ALLOWED_PROTOCOLS = new Set(["git", "http", "https", "ssh"]);
|
||||
const scopedBlockedInheritedEnvKeys = new AsyncLocalStorage<ReadonlySet<string>>();
|
||||
|
||||
/** Run a bounded operation without forwarding selected inherited env vars to host exec. */
|
||||
export function withHostExecInheritedEnvOmitted<T>(keys: Iterable<string>, run: () => T): T {
|
||||
const normalized = new Set(scopedBlockedInheritedEnvKeys.getStore() ?? []);
|
||||
for (const key of keys) {
|
||||
const trimmed = key.trim();
|
||||
if (trimmed) {
|
||||
normalized.add(trimmed.toUpperCase());
|
||||
}
|
||||
}
|
||||
return scopedBlockedInheritedEnvKeys.run(normalized, run);
|
||||
}
|
||||
|
||||
function isScopedBlockedHostExecEnvVarName(rawKey: string): boolean {
|
||||
const key = normalizeEnvVarKey(rawKey);
|
||||
return key ? (scopedBlockedInheritedEnvKeys.getStore()?.has(key.toUpperCase()) ?? false) : false;
|
||||
}
|
||||
|
||||
function isShellWrapperAllowedOverrideEnvVarName(rawKey: string): boolean {
|
||||
const key = normalizeEnvVarKey(rawKey, { portable: true });
|
||||
@@ -235,6 +254,10 @@ function sanitizeHostEnvOverridesWithDiagnostics(params?: {
|
||||
continue;
|
||||
}
|
||||
const upper = normalized.toUpperCase();
|
||||
if (isScopedBlockedHostExecEnvVarName(upper)) {
|
||||
rejectedBlocked.push(upper);
|
||||
continue;
|
||||
}
|
||||
// PATH is part of the security boundary (command resolution + safe-bin checks). Never allow
|
||||
// request-scoped PATH overrides from agents/gateways.
|
||||
if (blockPathOverrides && upper === "PATH") {
|
||||
@@ -264,6 +287,9 @@ export function sanitizeHostExecEnvWithDiagnostics(params?: {
|
||||
|
||||
const merged: Record<string, string> = {};
|
||||
for (const [key, value] of listNormalizedEnvEntries(baseEnv)) {
|
||||
if (isScopedBlockedHostExecEnvVarName(key)) {
|
||||
continue;
|
||||
}
|
||||
const sanitizedEntry = sanitizeHostInheritedEnvEntry(key, value);
|
||||
if (!sanitizedEntry) {
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user