mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-04 14:41:40 +00:00
* fix(claws): wait for agent reload before cron add * test(claws): cover gateway config readiness * test(claws): cover gateway config readiness
554 lines
18 KiB
TypeScript
554 lines
18 KiB
TypeScript
import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url";
|
|
import {
|
|
listAgentEntries,
|
|
listAgentIds,
|
|
resolveAgentWorkspaceDir,
|
|
} from "../agents/agent-scope-config.js";
|
|
import { stableStringify } from "../agents/stable-stringify.js";
|
|
import {
|
|
applyClawAddPlan,
|
|
CLAW_ADD_RESULT_SCHEMA_VERSION,
|
|
ClawAddMutationError,
|
|
} from "../claws/add.js";
|
|
import { assertExperimentalClawsEnabled } from "../claws/experimental.js";
|
|
import {
|
|
CLAW_EXPORT_RESULT_SCHEMA_VERSION,
|
|
ClawExportError,
|
|
exportClawAgent,
|
|
} from "../claws/export.js";
|
|
import {
|
|
applyClawRemovePlan,
|
|
buildClawRemovePlan,
|
|
CLAW_REMOVE_PLAN_SCHEMA_VERSION,
|
|
CLAW_REMOVE_RESULT_SCHEMA_VERSION,
|
|
ClawRemoveError,
|
|
readClawStatus,
|
|
} from "../claws/lifecycle-state.js";
|
|
import { buildClawAddPlan } from "../claws/lifecycle.js";
|
|
import { preflightClawPackage } from "../claws/packages.js";
|
|
import { readClawInstallRecord } from "../claws/provenance.js";
|
|
import { readClawManifestFile } from "../claws/reader.js";
|
|
import {
|
|
CLAW_INSPECT_RESULT_SCHEMA_VERSION,
|
|
CLAW_ADD_PLAN_SCHEMA_VERSION,
|
|
CLAW_OUTPUT_STABILITY,
|
|
type ClawAddPlan,
|
|
} from "../claws/types.js";
|
|
// Runtime handlers for experimental local Claws commands.
|
|
import { getRuntimeConfig } from "../config/config.js";
|
|
import { listConfiguredMcpServers } from "../config/mcp-config.js";
|
|
import { redactSensitiveArgv } from "../config/redact-argv.js";
|
|
import {
|
|
loadCronJobsStoreWithConfigJobsReadOnly,
|
|
resolveCronJobsStorePath,
|
|
} from "../cron/store.js";
|
|
import { redactSensitiveText } from "../logging/redact.js";
|
|
import { defaultRuntime, writeRuntimeJson, type RuntimeEnv } from "../runtime.js";
|
|
import { waitUntilGatewayConfigApplied } from "./claws-cli.gateway-readiness.js";
|
|
import type {
|
|
ClawsAddOptions,
|
|
ClawsExportOptions,
|
|
ClawsInspectOptions,
|
|
ClawsRemoveOptions,
|
|
ClawsStatusOptions,
|
|
} from "./claws-cli.js";
|
|
import { callGatewayFromCli } from "./gateway-rpc.js";
|
|
|
|
type DiagnosticLike = { level: string; code: string; path: string; message: string };
|
|
|
|
function formatDiagnostics(diagnostics: DiagnosticLike[]): string {
|
|
return diagnostics
|
|
.map(
|
|
(diagnostic) =>
|
|
`${diagnostic.level.toUpperCase()} ${diagnostic.code} ${diagnostic.path}: ${diagnostic.message}`,
|
|
)
|
|
.join("\n");
|
|
}
|
|
|
|
function logExperimentalWarning(runtime: RuntimeEnv): void {
|
|
runtime.log("Experimental: Claws contracts may change while RFC 0016 is under review.");
|
|
}
|
|
|
|
function logClawAddPlanSummary(plan: ClawAddPlan, runtime: RuntimeEnv): void {
|
|
runtime.log(`Agent: ${plan.agent.finalId}`);
|
|
runtime.log(`Workspace: ${plan.agent.workspace}`);
|
|
runtime.log(`Actions: ${plan.summary.totalActions}`);
|
|
runtime.log(`Packages: ${plan.summary.packageActions}`);
|
|
runtime.log(`MCP servers: ${plan.summary.mcpServerActions}`);
|
|
for (const action of plan.actions.filter((candidate) => candidate.kind === "mcpServer")) {
|
|
const server = action.details as Record<string, unknown> | undefined;
|
|
const target =
|
|
typeof server?.url === "string"
|
|
? redactSensitiveUrlLikeString(server.url)
|
|
: typeof server?.command === "string"
|
|
? redactSensitiveArgv([
|
|
server.command,
|
|
...(Array.isArray(server.args)
|
|
? server.args.filter((arg): arg is string => typeof arg === "string")
|
|
: []),
|
|
]).join(" ")
|
|
: "invalid declaration";
|
|
runtime.log(` MCP ${action.id}: ${target}`);
|
|
}
|
|
runtime.log(`Cron jobs: ${plan.summary.cronJobActions}`);
|
|
if (plan.capabilityChanges.length > 0) {
|
|
runtime.log(`Capability escalations (${plan.capabilityChanges.length}):`);
|
|
for (const change of plan.capabilityChanges) {
|
|
runtime.log(
|
|
redactSensitiveText(` ! ${change.kind}:${change.id} ${JSON.stringify(change.effect)}`),
|
|
);
|
|
}
|
|
runtime.log("The plan integrity binds every capability line above.");
|
|
}
|
|
if (plan.summary.blockedActions > 0) {
|
|
runtime.log(`Blocked actions: ${plan.summary.blockedActions}`);
|
|
}
|
|
}
|
|
|
|
function matchingResumeRecord(plan: ClawAddPlan, opts: ClawsAddOptions) {
|
|
if (opts.dryRun || !opts.yes || !opts.planIntegrity) {
|
|
return undefined;
|
|
}
|
|
const record = readClawInstallRecord(plan.agent.finalId);
|
|
if (
|
|
!record ||
|
|
record.status === "complete" ||
|
|
record.planIntegrity !== opts.planIntegrity ||
|
|
record.workspace !== plan.agent.workspace ||
|
|
record.claw.kind !== plan.claw.kind ||
|
|
record.claw.name !== plan.claw.name ||
|
|
record.claw.version !== plan.claw.version ||
|
|
record.claw.integrity !== plan.claw.integrity
|
|
) {
|
|
return undefined;
|
|
}
|
|
return record;
|
|
}
|
|
|
|
function failNonDryRun(opts: ClawsAddOptions, runtime: RuntimeEnv): boolean {
|
|
if (opts.dryRun) {
|
|
return false;
|
|
}
|
|
const consented = opts.yes && opts.planIntegrity;
|
|
if (consented) {
|
|
return false;
|
|
}
|
|
const code = opts.yes ? "plan_integrity_required" : "consent_required";
|
|
const message = opts.yes
|
|
? "Claw add consent must include --plan-integrity from the exact dry-run plan."
|
|
: "Claw add requires explicit consent; pass --dry-run to preview or --yes with --plan-integrity to create the new agent and workspace.";
|
|
if (opts.json) {
|
|
writeRuntimeJson(runtime, {
|
|
schemaVersion: CLAW_ADD_PLAN_SCHEMA_VERSION,
|
|
stability: CLAW_OUTPUT_STABILITY,
|
|
ok: false,
|
|
error: { code, message },
|
|
});
|
|
} else {
|
|
runtime.error(message);
|
|
}
|
|
runtime.exit(1);
|
|
return true;
|
|
}
|
|
|
|
function requireRemoveConsent(opts: ClawsRemoveOptions, runtime: RuntimeEnv): boolean {
|
|
if (opts.dryRun || (opts.yes && opts.planIntegrity)) {
|
|
return false;
|
|
}
|
|
const code = opts.yes ? "plan_integrity_required" : "consent_required";
|
|
const message = opts.yes
|
|
? "Claw remove consent must include --plan-integrity from the exact dry-run plan."
|
|
: "Claw remove requires explicit consent; pass --dry-run to preview or --yes with --plan-integrity to remove owned state.";
|
|
if (opts.json) {
|
|
writeRuntimeJson(runtime, {
|
|
schemaVersion: CLAW_REMOVE_PLAN_SCHEMA_VERSION,
|
|
stability: CLAW_OUTPUT_STABILITY,
|
|
ok: false,
|
|
error: { code, message },
|
|
});
|
|
} else {
|
|
runtime.error(message);
|
|
}
|
|
runtime.exit(1);
|
|
return true;
|
|
}
|
|
|
|
export async function runClawsInspectCommand(
|
|
sourcePath: string,
|
|
opts: ClawsInspectOptions,
|
|
runtime: RuntimeEnv = defaultRuntime,
|
|
): Promise<void> {
|
|
assertExperimentalClawsEnabled();
|
|
const result = await readClawManifestFile(sourcePath);
|
|
if (!result.ok) {
|
|
if (opts.json) {
|
|
writeRuntimeJson(runtime, {
|
|
schemaVersion: CLAW_INSPECT_RESULT_SCHEMA_VERSION,
|
|
stability: CLAW_OUTPUT_STABILITY,
|
|
valid: false,
|
|
diagnostics: result.diagnostics,
|
|
});
|
|
} else {
|
|
runtime.error(formatDiagnostics(result.diagnostics));
|
|
}
|
|
runtime.exit(1);
|
|
return;
|
|
}
|
|
|
|
const payload = {
|
|
schemaVersion: CLAW_INSPECT_RESULT_SCHEMA_VERSION,
|
|
stability: CLAW_OUTPUT_STABILITY,
|
|
valid: true,
|
|
source: result.source,
|
|
manifest: result.manifest,
|
|
...(result.openClawProfile ? { openClawProfile: result.openClawProfile } : {}),
|
|
diagnostics: result.diagnostics,
|
|
};
|
|
if (opts.json) {
|
|
writeRuntimeJson(runtime, payload);
|
|
return;
|
|
}
|
|
logExperimentalWarning(runtime);
|
|
runtime.log(`Claw: ${result.source.name}@${result.source.version}`);
|
|
runtime.log(`Agent: ${result.manifest.agent.name ?? result.manifest.agent.id}`);
|
|
runtime.log(`Packages: ${result.manifest.packages.length}`);
|
|
runtime.log(`MCP servers: ${Object.keys(result.manifest.mcpServers).length}`);
|
|
runtime.log(`Cron jobs: ${result.manifest.cronJobs.length}`);
|
|
}
|
|
|
|
export async function runClawsAddCommand(
|
|
sourcePath: string,
|
|
opts: ClawsAddOptions,
|
|
runtime: RuntimeEnv = defaultRuntime,
|
|
): Promise<void> {
|
|
assertExperimentalClawsEnabled();
|
|
if (failNonDryRun(opts, runtime)) {
|
|
return;
|
|
}
|
|
const result = await readClawManifestFile(sourcePath);
|
|
if (!result.ok) {
|
|
if (opts.json) {
|
|
writeRuntimeJson(runtime, {
|
|
schemaVersion: CLAW_ADD_PLAN_SCHEMA_VERSION,
|
|
stability: CLAW_OUTPUT_STABILITY,
|
|
valid: false,
|
|
diagnostics: result.diagnostics,
|
|
});
|
|
} else {
|
|
runtime.error(formatDiagnostics(result.diagnostics));
|
|
}
|
|
runtime.exit(1);
|
|
return;
|
|
}
|
|
|
|
const config = getRuntimeConfig();
|
|
const listedMcpServers = await listConfiguredMcpServers();
|
|
if (!listedMcpServers.ok) {
|
|
runtime.error(listedMcpServers.error);
|
|
runtime.exit(1);
|
|
return;
|
|
}
|
|
const existingAgentIds = listAgentIds(config);
|
|
const existingWorkspacePaths = existingAgentIds.map((agentId) =>
|
|
resolveAgentWorkspaceDir(config, agentId),
|
|
);
|
|
const cronStore = await loadCronJobsStoreWithConfigJobsReadOnly(resolveCronJobsStorePath());
|
|
const basePlanContext = {
|
|
...(opts.agentId ? { agentId: opts.agentId } : {}),
|
|
...(opts.workspace ? { workspace: opts.workspace } : {}),
|
|
existingAgentIds,
|
|
existingWorkspacePaths,
|
|
existingMcpServers: listedMcpServers.mcpServers,
|
|
existingCronJobIds: cronStore.store.jobs.map((job) => job.id),
|
|
packagePreflight: preflightClawPackage,
|
|
};
|
|
let plan = await buildClawAddPlan({
|
|
manifest: result.manifest,
|
|
clawMarkdownBody: result.clawMarkdownBody,
|
|
openClawProfile: result.openClawProfile,
|
|
source: result.source,
|
|
diagnostics: result.diagnostics,
|
|
context: basePlanContext,
|
|
});
|
|
const resumeRecord = matchingResumeRecord(plan, opts);
|
|
if (resumeRecord && plan.blockers.length > 0) {
|
|
const canResumeWorkspace =
|
|
resumeRecord.status === "workspace_ready" || resumeRecord.status === "config_committed";
|
|
const committedAgent = listAgentEntries(config).find(
|
|
(agent) => stableStringify(agent) === stableStringify(plan.agent.config),
|
|
);
|
|
const canResumeAgent =
|
|
resumeRecord.status === "config_committed" ||
|
|
(resumeRecord.status === "workspace_ready" && committedAgent !== undefined);
|
|
plan = await buildClawAddPlan({
|
|
manifest: result.manifest,
|
|
clawMarkdownBody: result.clawMarkdownBody,
|
|
openClawProfile: result.openClawProfile,
|
|
source: result.source,
|
|
diagnostics: result.diagnostics,
|
|
context: {
|
|
...basePlanContext,
|
|
existingAgentIds: canResumeAgent
|
|
? existingAgentIds.filter((agentId) => agentId !== resumeRecord.agentId)
|
|
: existingAgentIds,
|
|
existingWorkspacePaths: canResumeWorkspace
|
|
? existingAgentIds
|
|
.filter((agentId) => agentId !== resumeRecord.agentId)
|
|
.map((agentId) => resolveAgentWorkspaceDir(config, agentId))
|
|
: existingWorkspacePaths,
|
|
...(canResumeWorkspace ? { resumableWorkspace: resumeRecord.workspace } : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
if (plan.blockers.length > 0) {
|
|
if (opts.json) {
|
|
writeRuntimeJson(runtime, plan);
|
|
} else {
|
|
logExperimentalWarning(runtime);
|
|
logClawAddPlanSummary(plan, runtime);
|
|
runtime.error(formatDiagnostics(plan.blockers));
|
|
}
|
|
runtime.exit(1);
|
|
return;
|
|
}
|
|
|
|
if (opts.dryRun) {
|
|
if (opts.json) {
|
|
writeRuntimeJson(runtime, plan);
|
|
} else {
|
|
logExperimentalWarning(runtime);
|
|
runtime.log(`Claw add plan: ${plan.claw.name}@${plan.claw.version}`);
|
|
logClawAddPlanSummary(plan, runtime);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (opts.planIntegrity !== plan.planIntegrity) {
|
|
const message = "The consented Claw plan no longer matches; run add --dry-run again.";
|
|
if (opts.json) {
|
|
writeRuntimeJson(runtime, {
|
|
schemaVersion: CLAW_ADD_RESULT_SCHEMA_VERSION,
|
|
stability: CLAW_OUTPUT_STABILITY,
|
|
status: "failed",
|
|
planIntegrity: plan.planIntegrity,
|
|
error: { code: "plan_integrity_mismatch", message },
|
|
});
|
|
} else {
|
|
runtime.error(message);
|
|
}
|
|
runtime.exit(1);
|
|
return;
|
|
}
|
|
|
|
let addResult;
|
|
try {
|
|
addResult = await applyClawAddPlan(plan, {
|
|
consentPlanIntegrity: opts.planIntegrity,
|
|
runtime: opts.json ? { ...runtime, log: () => undefined } : runtime,
|
|
cronGateway: {
|
|
add: async (input) => await callGatewayFromCli("cron.add", {}, input),
|
|
list: async (agentId) =>
|
|
await callGatewayFromCli("cron.list", {}, { agentId, includeDisabled: true }),
|
|
waitUntilAgentAvailable: async () => await waitUntilGatewayConfigApplied(),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
const code = error instanceof ClawAddMutationError ? error.code : "add_failed";
|
|
const message = (error as Error).message;
|
|
if (opts.json) {
|
|
writeRuntimeJson(runtime, {
|
|
schemaVersion: CLAW_ADD_RESULT_SCHEMA_VERSION,
|
|
stability: CLAW_OUTPUT_STABILITY,
|
|
status: "failed",
|
|
error: { code, message },
|
|
});
|
|
} else {
|
|
runtime.error(message);
|
|
}
|
|
runtime.exit(1);
|
|
return;
|
|
}
|
|
|
|
if (opts.json) {
|
|
writeRuntimeJson(runtime, addResult);
|
|
} else {
|
|
logExperimentalWarning(runtime);
|
|
runtime.log(`Added agent: ${addResult.agent.finalId}`);
|
|
runtime.log(`Workspace: ${addResult.agent.workspace}`);
|
|
runtime.log(`Status: ${addResult.status}`);
|
|
}
|
|
if (addResult.status !== "complete") {
|
|
runtime.exit(1);
|
|
}
|
|
}
|
|
|
|
export async function runClawsStatusCommand(
|
|
target: string | undefined,
|
|
opts: ClawsStatusOptions,
|
|
runtime: RuntimeEnv = defaultRuntime,
|
|
): Promise<void> {
|
|
assertExperimentalClawsEnabled();
|
|
const status = await readClawStatus(target);
|
|
if (opts.json) {
|
|
writeRuntimeJson(runtime, status);
|
|
} else {
|
|
logExperimentalWarning(runtime);
|
|
runtime.log(`Installed Claws: ${status.summary.claws}`);
|
|
for (const record of status.records) {
|
|
runtime.log(
|
|
`${record.install.agentId}: ${record.install.claw.name}@${record.install.claw.version} (${record.install.status})`,
|
|
);
|
|
runtime.log(
|
|
` Agent: ${record.agentState}; files: ${record.workspaceFiles.length}; packages: ${record.packages.length}`,
|
|
);
|
|
}
|
|
}
|
|
if (target && status.records.length === 0) {
|
|
runtime.exit(1);
|
|
}
|
|
}
|
|
|
|
export { runClawsUpdateCommand } from "./claws-update-cli.runtime.js";
|
|
|
|
export async function runClawsRemoveCommand(
|
|
target: string,
|
|
opts: ClawsRemoveOptions,
|
|
runtime: RuntimeEnv = defaultRuntime,
|
|
): Promise<void> {
|
|
assertExperimentalClawsEnabled();
|
|
if (requireRemoveConsent(opts, runtime)) {
|
|
return;
|
|
}
|
|
const selected = opts.removeReferenced ?? [];
|
|
if (opts.removeUnused && selected.length > 0) {
|
|
runtime.error("Choose either --remove-unused or --remove-referenced, not both.");
|
|
runtime.exit(1);
|
|
return;
|
|
}
|
|
if (opts.forceReferenced && selected.length === 0) {
|
|
runtime.error("--force-referenced requires at least one --remove-referenced selector.");
|
|
runtime.exit(1);
|
|
return;
|
|
}
|
|
const referencedCleanup = selected.length
|
|
? {
|
|
mode: "remove-selected" as const,
|
|
selected,
|
|
allowConflicts: Boolean(opts.forceReferenced),
|
|
}
|
|
: opts.removeUnused
|
|
? { mode: "remove-if-unused" as const }
|
|
: { mode: "retain" as const };
|
|
const plan = await buildClawRemovePlan(target, { referencedCleanup });
|
|
if (opts.dryRun || plan.blockers.length > 0) {
|
|
if (opts.json) {
|
|
writeRuntimeJson(runtime, plan);
|
|
} else {
|
|
logExperimentalWarning(runtime);
|
|
runtime.log(`Remove actions: ${plan.actions.length}`);
|
|
runtime.log(`Plan integrity: ${plan.planIntegrity}`);
|
|
for (const action of plan.actions.filter((candidate) => candidate.kind === "packageRef")) {
|
|
runtime.log(
|
|
` Package ${action.target}: ${action.action}${action.reason ? ` (${action.reason})` : ""}`,
|
|
);
|
|
}
|
|
for (const action of plan.actions.filter((candidate) => candidate.kind === "mcpServer")) {
|
|
runtime.log(
|
|
` MCP ${action.id}: ${action.action}${action.reason ? ` (${action.reason})` : ""}`,
|
|
);
|
|
}
|
|
if (plan.blockers.length > 0) {
|
|
runtime.error(plan.blockers.map((blocker) => blocker.message).join("\n"));
|
|
}
|
|
}
|
|
if (plan.blockers.length > 0) {
|
|
runtime.exit(1);
|
|
}
|
|
return;
|
|
}
|
|
try {
|
|
const result = await applyClawRemovePlan(plan, {
|
|
consentPlanIntegrity: opts.planIntegrity,
|
|
referencedCleanup,
|
|
cronGateway: {
|
|
get: async (id) => await callGatewayFromCli("cron.get", {}, { id }),
|
|
remove: async (id) => await callGatewayFromCli("cron.remove", {}, { id }),
|
|
},
|
|
});
|
|
if (opts.json) {
|
|
writeRuntimeJson(runtime, result);
|
|
} else {
|
|
logExperimentalWarning(runtime);
|
|
runtime.log(`Removed agent: ${result.agentId}`);
|
|
runtime.log(`Status: ${result.status}`);
|
|
for (const pkg of result.packages) {
|
|
runtime.log(
|
|
` Package ${pkg.kind}:${pkg.ref}@${pkg.version}: ${pkg.action}${pkg.reason ? ` (${pkg.reason})` : ""}`,
|
|
);
|
|
}
|
|
runtime.log(`Package references released: ${result.packageRefsReleased}`);
|
|
}
|
|
if (result.status !== "complete") {
|
|
runtime.exit(1);
|
|
}
|
|
} catch (error) {
|
|
const code = error instanceof ClawRemoveError ? error.code : "remove_failed";
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
if (opts.json) {
|
|
writeRuntimeJson(runtime, {
|
|
schemaVersion: CLAW_REMOVE_RESULT_SCHEMA_VERSION,
|
|
stability: CLAW_OUTPUT_STABILITY,
|
|
status: "failed",
|
|
error: { code, message },
|
|
});
|
|
} else {
|
|
runtime.error(message);
|
|
}
|
|
runtime.exit(1);
|
|
}
|
|
}
|
|
|
|
export async function runClawsExportCommand(
|
|
agentId: string,
|
|
opts: ClawsExportOptions,
|
|
runtime: RuntimeEnv = defaultRuntime,
|
|
): Promise<void> {
|
|
assertExperimentalClawsEnabled();
|
|
try {
|
|
const listedMcpServers = await listConfiguredMcpServers();
|
|
if (!listedMcpServers.ok) {
|
|
throw new ClawExportError("mcp_config_unavailable", listedMcpServers.error);
|
|
}
|
|
const result = await exportClawAgent(agentId, opts.out, {
|
|
config: getRuntimeConfig(),
|
|
sourceMcpServers: listedMcpServers.mcpServers,
|
|
});
|
|
if (opts.json) {
|
|
writeRuntimeJson(runtime, result);
|
|
return;
|
|
}
|
|
logExperimentalWarning(runtime);
|
|
runtime.log(`Exported agent: ${result.agentId}`);
|
|
runtime.log(`Package directory: ${result.outputDirectory}`);
|
|
runtime.log(
|
|
`Workspace files: ${result.manifest.workspace.files.length + Object.keys(result.manifest.workspace.bootstrapFiles).length}`,
|
|
);
|
|
runtime.log(`Packages: ${result.manifest.packages.length}`);
|
|
} catch (error) {
|
|
const code = error instanceof ClawExportError ? error.code : "export_failed";
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
if (opts.json) {
|
|
writeRuntimeJson(runtime, {
|
|
schemaVersion: CLAW_EXPORT_RESULT_SCHEMA_VERSION,
|
|
stability: CLAW_OUTPUT_STABILITY,
|
|
status: "failed",
|
|
error: { code, message },
|
|
});
|
|
} else {
|
|
runtime.error(message);
|
|
}
|
|
runtime.exit(1);
|
|
}
|
|
}
|