mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-18 08:31:49 -06:00
1ca60fbc3a
* refactor(agents): make roster ownership explicit * feat(config): materialize legacy agent roles * fix(cron): migrate legacy owners at startup * feat(gateway): expose agent selection contracts * fix(gateway): enforce agent-scoped authorization * docs(config): document explicit agent ownership * fix(config): pin retained owner workspace * fix(gateway): target hook wakes at effective agent * fix(sessions): preserve fixed-store ownership * fix: preserve retained agent ownership * fix: preserve legacy agent ownership across runtime surfaces * fix: fail closed on ambiguous session ownership * fix: preserve compatibility owners across dispatch and writes * fix: preserve retained agent projections * fix: preserve agent ownership compatibility * fix: preserve per-agent heartbeat guidance * fix: preserve compatibility owners in generic paths * fix: enforce configured ownership in session paths * fix: defer remote roster selection * fix: preserve ownership across session and config writes * fix: fail closed on ambiguous restored ownership * fix: preserve explicit ACP and legacy ownership * fix: honor durable fixed-store ownership * fix: enforce fixed-store owner authority * fix: preserve ownership evidence boundaries * fix: honor resolved session ownership * fix: align compatibility ownership paths * fix: persist legacy main store ownership * fix: close ownership fallback gaps * fix(agents): close retained owner compatibility gaps * fix(agents): enforce session owner resolution * fix(agents): complete session owner resolution sweep * fix(agents): preserve durable session ownership * fix: complete persisted session owner routing * fix: thread prepared session owners * fix: preserve stable session ownership * fix: enforce session ownership boundaries * fix: close session ownership delta gaps * fix: reconcile session ownership after rebase * fix: reconcile ownership with current main * fix: align session store path imports * fix: align session store config path import * fix: reconcile explicit ownership CI * fix: reconcile ownership rebase checks * fix: align ownership ci contracts * fix: align ownership rebase checks * fix: preserve compatibility owner during setup * fix(doctor): migrate ownerless heartbeat monitors * fix(gateway): preserve explicit session ownership * test: align ownership fixtures after rebase * test: complete plugin manifest fixture * test: align runtime context mocks * fix(gateway): preserve alias routing for existing sessions * style: format agent routing update * fix(gateway): preserve selected owner during alias routing * style: normalize rebased ownership files * fix(gateway): preserve owner through global alias routing * fix(gateway): preserve explicit ownership at HTTP boundaries * fix(gateway): validate compatibility model ownership * fix(agents): reconcile strict session ownership * fix(agents): contain media yield callback failures * fix(agents): avoid eager bare-key owner resolution * chore: refresh rebased ownership baselines * chore: align hosted plugin SDK baseline * chore: refresh ownership baselines after main sync * chore: refresh ownership baselines after main sync * test: align routed event owner fixtures * chore: retrigger CI after runner startup failure * chore: refresh ownership SDK budgets after main sync * fix(tasks): require agent identity for bare owners * chore: align Linux plugin SDK baseline * chore: remove release-owned changelog entry
387 lines
12 KiB
TypeScript
387 lines
12 KiB
TypeScript
/** Handles /bash and ! shell command chat shortcuts. */
|
|
import {
|
|
normalizeLowercaseStringOrEmpty,
|
|
normalizeOptionalString,
|
|
} from "@openclaw/normalization-core/string-coerce";
|
|
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
|
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
|
import { cancelBackgroundExecSession } from "../../agents/bash-process-control.js";
|
|
import { getFinishedSession, getSession } from "../../agents/bash-process-registry.js";
|
|
import { createExecTool } from "../../agents/bash-tools.js";
|
|
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
|
|
import { isCommandFlagEnabled } from "../../config/commands.flags.js";
|
|
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
|
import { logVerbose } from "../../globals.js";
|
|
import { formatErrorMessage } from "../../infra/errors.js";
|
|
import { clampInt } from "../../utils.js";
|
|
import type { MsgContext } from "../templating.js";
|
|
import type { ReplyPayload } from "../types.js";
|
|
import { buildDisabledCommandReply } from "./command-gates.js";
|
|
import { formatElevatedUnavailableMessage } from "./elevated-unavailable.js";
|
|
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
|
import { resolveRuntimePolicySessionKey } from "./runtime-policy-session-key.js";
|
|
|
|
const CHAT_BASH_SCOPE_KEY = "chat:bash";
|
|
const DEFAULT_FOREGROUND_MS = 2000;
|
|
const MAX_FOREGROUND_MS = 30_000;
|
|
|
|
type BashRequest =
|
|
| { action: "help" }
|
|
| { action: "run"; command: string }
|
|
| { action: "poll"; sessionId?: string }
|
|
| { action: "stop"; sessionId?: string };
|
|
|
|
type ActiveBashJob =
|
|
| { state: "starting"; startedAt: number; command: string }
|
|
| {
|
|
state: "running";
|
|
sessionId: string;
|
|
startedAt: number;
|
|
command: string;
|
|
};
|
|
|
|
let activeJob: ActiveBashJob | null = null;
|
|
|
|
function resolveForegroundMs(cfg: OpenClawConfig): number {
|
|
const raw = cfg.commands?.bashForegroundMs;
|
|
if (typeof raw !== "number" || Number.isNaN(raw)) {
|
|
return DEFAULT_FOREGROUND_MS;
|
|
}
|
|
return clampInt(raw, 0, MAX_FOREGROUND_MS);
|
|
}
|
|
|
|
function formatSessionSnippet(sessionId: string) {
|
|
const trimmed = sessionId.trim();
|
|
if (trimmed.length <= 12) {
|
|
return trimmed;
|
|
}
|
|
return `${truncateUtf16Safe(trimmed, 8)}…`;
|
|
}
|
|
|
|
function formatOutputBlock(text: string) {
|
|
const trimmed = text.trim();
|
|
if (!trimmed) {
|
|
return "(no output)";
|
|
}
|
|
return `\`\`\`txt\n${trimmed}\n\`\`\``;
|
|
}
|
|
|
|
function parseBashRequest(raw: string): BashRequest | null {
|
|
const trimmed = raw.trimStart();
|
|
let restSource;
|
|
if (normalizeLowercaseStringOrEmpty(trimmed).startsWith("/bash")) {
|
|
const match = trimmed.match(/^\/bash(?:\s*:\s*|\s+|$)([\s\S]*)$/i);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
restSource = match[1] ?? "";
|
|
} else if (trimmed.startsWith("!")) {
|
|
restSource = trimmed.slice(1);
|
|
if (restSource.trimStart().startsWith(":")) {
|
|
restSource = restSource.trimStart().slice(1);
|
|
}
|
|
} else {
|
|
return null;
|
|
}
|
|
|
|
const rest = restSource.trimStart();
|
|
if (!rest) {
|
|
return { action: "help" };
|
|
}
|
|
const tokenMatch = rest.match(/^(\S+)(?:\s+([\s\S]+))?$/);
|
|
const token = normalizeOptionalString(tokenMatch?.[1]) ?? "";
|
|
const remainder = normalizeOptionalString(tokenMatch?.[2]) ?? "";
|
|
const lowered = normalizeLowercaseStringOrEmpty(token);
|
|
if (lowered === "poll") {
|
|
return { action: "poll", sessionId: remainder || undefined };
|
|
}
|
|
if (lowered === "stop") {
|
|
return { action: "stop", sessionId: remainder || undefined };
|
|
}
|
|
if (lowered === "help") {
|
|
return { action: "help" };
|
|
}
|
|
return { action: "run", command: rest };
|
|
}
|
|
|
|
function resolveRawCommandBody(params: {
|
|
ctx: MsgContext;
|
|
cfg: OpenClawConfig;
|
|
agentId?: string;
|
|
isGroup: boolean;
|
|
}) {
|
|
const source = params.ctx.commandText ?? "";
|
|
const stripped = stripStructuralPrefixes(source);
|
|
return params.isGroup
|
|
? stripMentions(stripped, params.ctx, params.cfg, params.agentId)
|
|
: stripped;
|
|
}
|
|
|
|
function getScopedSession(sessionId: string) {
|
|
const running = getSession(sessionId);
|
|
if (running && running.scopeKey === CHAT_BASH_SCOPE_KEY) {
|
|
return { running };
|
|
}
|
|
const finished = getFinishedSession(sessionId);
|
|
if (finished && finished.scopeKey === CHAT_BASH_SCOPE_KEY) {
|
|
return { finished };
|
|
}
|
|
return {};
|
|
}
|
|
|
|
function ensureActiveJobState() {
|
|
if (!activeJob) {
|
|
return null;
|
|
}
|
|
if (activeJob.state === "starting") {
|
|
return activeJob;
|
|
}
|
|
const { running, finished } = getScopedSession(activeJob.sessionId);
|
|
if (running) {
|
|
return activeJob;
|
|
}
|
|
if (finished) {
|
|
activeJob = null;
|
|
return null;
|
|
}
|
|
activeJob = null;
|
|
return null;
|
|
}
|
|
|
|
function buildUsageReply(): ReplyPayload {
|
|
return {
|
|
text: [
|
|
"⚙️ Usage:",
|
|
"- ! <command>",
|
|
"- !poll | ! poll",
|
|
"- !stop | ! stop",
|
|
"- /bash ... (alias; same subcommands as !)",
|
|
].join("\n"),
|
|
};
|
|
}
|
|
|
|
/** Parses, authorizes, starts, polls, or stops chat-driven bash commands. */
|
|
export async function handleBashChatCommand(params: {
|
|
ctx: MsgContext;
|
|
cfg: OpenClawConfig;
|
|
agentId?: string;
|
|
sessionKey: string;
|
|
isGroup: boolean;
|
|
elevated: {
|
|
enabled: boolean;
|
|
allowed: boolean;
|
|
failures: Array<{ gate: string; key: string }>;
|
|
};
|
|
}): Promise<ReplyPayload> {
|
|
if (!isCommandFlagEnabled(params.cfg, "bash")) {
|
|
return buildDisabledCommandReply({
|
|
label: "bash",
|
|
configKey: "bash",
|
|
docsUrl: "https://docs.openclaw.ai/tools/slash-commands#config",
|
|
});
|
|
}
|
|
|
|
const agentId =
|
|
params.agentId ??
|
|
resolveSessionAgentId({
|
|
sessionKey: params.sessionKey,
|
|
config: params.cfg,
|
|
});
|
|
|
|
if (!params.elevated.enabled || !params.elevated.allowed) {
|
|
const runtimeSandboxed = resolveSandboxRuntimeStatus({
|
|
cfg: params.cfg,
|
|
sessionKey: resolveRuntimePolicySessionKey({
|
|
agentId,
|
|
cfg: params.cfg,
|
|
ctx: params.ctx,
|
|
sessionKey: params.sessionKey,
|
|
}),
|
|
}).sandboxed;
|
|
return {
|
|
text: formatElevatedUnavailableMessage({
|
|
runtimeSandboxed,
|
|
failures: params.elevated.failures,
|
|
sessionKey: params.sessionKey,
|
|
}),
|
|
};
|
|
}
|
|
|
|
const rawBody = resolveRawCommandBody({
|
|
ctx: params.ctx,
|
|
cfg: params.cfg,
|
|
agentId,
|
|
isGroup: params.isGroup,
|
|
}).trim();
|
|
const request = parseBashRequest(rawBody);
|
|
if (!request) {
|
|
return { text: "⚠️ Unrecognized bash request." };
|
|
}
|
|
|
|
const liveJob = ensureActiveJobState();
|
|
|
|
if (request.action === "help") {
|
|
return buildUsageReply();
|
|
}
|
|
|
|
if (request.action === "poll") {
|
|
const sessionId =
|
|
normalizeOptionalString(request.sessionId) ||
|
|
(liveJob?.state === "running" ? liveJob.sessionId : "");
|
|
if (!sessionId) {
|
|
return { text: "⚙️ No active bash job." };
|
|
}
|
|
const { running, finished } = getScopedSession(sessionId);
|
|
if (running) {
|
|
const runtimeSec = Math.max(0, Math.floor((Date.now() - running.startedAt) / 1000));
|
|
const tail = running.tail || "(no output yet)";
|
|
return {
|
|
text: [
|
|
`⚙️ bash still running (session ${formatSessionSnippet(sessionId)}, ${runtimeSec}s).`,
|
|
formatOutputBlock(tail),
|
|
"Hint: !stop (or /bash stop)",
|
|
].join("\n"),
|
|
};
|
|
}
|
|
if (finished) {
|
|
if (activeJob?.state === "running" && activeJob.sessionId === sessionId) {
|
|
activeJob = null;
|
|
}
|
|
const exitLabel = finished.exitSignal
|
|
? `signal ${String(finished.exitSignal)}`
|
|
: `code ${String(finished.exitCode ?? 0)}`;
|
|
const prefix = finished.status === "completed" ? "⚙️" : "⚠️";
|
|
return {
|
|
text: [
|
|
`${prefix} bash finished (session ${formatSessionSnippet(sessionId)}).`,
|
|
`Exit: ${exitLabel}`,
|
|
formatOutputBlock(finished.aggregated || finished.tail),
|
|
].join("\n"),
|
|
};
|
|
}
|
|
if (activeJob?.state === "running" && activeJob.sessionId === sessionId) {
|
|
activeJob = null;
|
|
}
|
|
return {
|
|
text: `⚙️ No bash session found for ${formatSessionSnippet(sessionId)}.`,
|
|
};
|
|
}
|
|
|
|
if (request.action === "stop") {
|
|
const sessionId =
|
|
normalizeOptionalString(request.sessionId) ||
|
|
(liveJob?.state === "running" ? liveJob.sessionId : "");
|
|
if (!sessionId) {
|
|
return { text: "⚙️ No active bash job." };
|
|
}
|
|
const { running } = getScopedSession(sessionId);
|
|
if (!running) {
|
|
if (activeJob?.state === "running" && activeJob.sessionId === sessionId) {
|
|
activeJob = null;
|
|
}
|
|
return {
|
|
text: `⚙️ No running bash job found for ${formatSessionSnippet(sessionId)}.`,
|
|
};
|
|
}
|
|
if (!running.backgrounded) {
|
|
return {
|
|
text: `⚠️ Session ${formatSessionSnippet(sessionId)} is not backgrounded.`,
|
|
};
|
|
}
|
|
if (!cancelBackgroundExecSession(sessionId)) {
|
|
return {
|
|
text: `⚠️ Unable to stop bash session ${formatSessionSnippet(sessionId)} because no active cancellation handle is available. Use !poll ${sessionId} to check whether it is already exiting.`,
|
|
};
|
|
}
|
|
return {
|
|
text: `⚙️ bash stopping (session ${formatSessionSnippet(sessionId)}). Use !poll ${sessionId} to confirm exit.`,
|
|
};
|
|
}
|
|
|
|
// request.action === "run"
|
|
if (liveJob) {
|
|
const label =
|
|
liveJob.state === "running" ? formatSessionSnippet(liveJob.sessionId) : "starting";
|
|
return {
|
|
text: `⚠️ A bash job is already running (${label}). Use !poll / !stop (or /bash poll / /bash stop).`,
|
|
};
|
|
}
|
|
|
|
const commandText = request.command.trim();
|
|
if (!commandText) {
|
|
return buildUsageReply();
|
|
}
|
|
|
|
activeJob = {
|
|
state: "starting",
|
|
startedAt: Date.now(),
|
|
command: commandText,
|
|
};
|
|
|
|
try {
|
|
const foregroundMs = resolveForegroundMs(params.cfg);
|
|
const shouldBackgroundImmediately = foregroundMs <= 0;
|
|
const timeoutSec = params.cfg.tools?.exec?.timeoutSeconds;
|
|
const notifyOnExit = params.cfg.tools?.exec?.notifyOnExit;
|
|
const notifyOnExitEmptySuccess = params.cfg.tools?.exec?.notifyOnExitEmptySuccess;
|
|
const execTool = createExecTool({
|
|
scopeKey: CHAT_BASH_SCOPE_KEY,
|
|
allowBackground: true,
|
|
timeoutSec,
|
|
sessionKey: params.sessionKey,
|
|
mainKey: params.cfg.session?.mainKey,
|
|
sessionScope: params.cfg.session?.scope,
|
|
notifyOnExit,
|
|
notifyOnExitEmptySuccess,
|
|
elevated: {
|
|
enabled: params.elevated.enabled,
|
|
allowed: params.elevated.allowed,
|
|
defaultLevel: "on",
|
|
},
|
|
});
|
|
const result = await execTool.execute("chat-bash", {
|
|
command: commandText,
|
|
background: shouldBackgroundImmediately,
|
|
yieldMs: shouldBackgroundImmediately ? undefined : foregroundMs,
|
|
timeoutSeconds: timeoutSec,
|
|
elevated: true,
|
|
});
|
|
|
|
if (result.details?.status === "running") {
|
|
const sessionId = result.details.sessionId;
|
|
activeJob = {
|
|
state: "running",
|
|
sessionId,
|
|
startedAt: result.details.startedAt,
|
|
command: commandText,
|
|
};
|
|
const snippet = formatSessionSnippet(sessionId);
|
|
logVerbose(`Started bash session ${snippet}: ${commandText}`);
|
|
return {
|
|
text: `⚙️ bash started (session ${sessionId}). Still running; use !poll / !stop (or /bash poll / /bash stop).`,
|
|
};
|
|
}
|
|
|
|
// Completed in foreground.
|
|
activeJob = null;
|
|
const exitCode = result.details?.status === "completed" ? result.details.exitCode : 0;
|
|
const output =
|
|
result.details?.status === "completed"
|
|
? result.details.aggregated
|
|
: result.content.map((chunk) => (chunk.type === "text" ? chunk.text : "")).join("\n");
|
|
return {
|
|
text: [
|
|
`⚙️ bash: ${commandText}`,
|
|
`Exit: ${exitCode}`,
|
|
formatOutputBlock(output || "(no output)"),
|
|
].join("\n"),
|
|
};
|
|
} catch (err) {
|
|
activeJob = null;
|
|
const message = formatErrorMessage(err);
|
|
return {
|
|
text: [`⚠️ bash failed: ${commandText}`, formatOutputBlock(message)].join("\n"),
|
|
};
|
|
}
|
|
}
|