Files
openclaw/src/agents/cli-executable-identity.ts
Peter Steinberger a6a0716486 feat(setup): rename Crestodian to OpenClaw system agent
User-facing name is now OpenClaw (the system speaks); internal code name is
system-agent. Gateway methods crestodian.* -> openclaw.chat/openclaw.setup.*,
agent tool -> openclaw, reserved agent ids openclaw + retired crestodian.
openclaw setup routes: onboarding flags -> onboard, -m/--yes -> system agent,
bare configured interactive -> OpenClaw chat, unconfigured -> onboarding.
Hidden crestodian CLI and /crestodian TUI aliases kept; docs moved to
docs/cli/openclaw.md with redirect stub. macOS/Android strings in lockstep.

Refs #107237
2026-07-14 11:03:02 -07:00

581 lines
17 KiB
TypeScript

import crypto from "node:crypto";
import type { Dirent } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { resolveExecutablePath } from "../infra/executable-path.js";
import {
resolveWindowsExecutablePath,
resolveWindowsSpawnProgramCandidate,
} from "../plugin-sdk/windows-spawn.js";
import type { CliBackendRuntimeArtifactPolicy } from "../plugins/cli-backend.types.js";
type CliExecutableFileIdentity = Readonly<{
path: string;
device: string;
inode: string;
mode: string;
size: string;
modifiedNs: string;
changedNs: string;
contentSha256: string;
}>;
/** Immutable executable projection bound to one successful CLI process. */
export type CliExecutableIdentity = Readonly<{
command: string;
resolvedPath: string;
invocation: Readonly<{
command: string;
leadingArgv: readonly string[];
resolution: "direct" | "node-entrypoint" | "exe-entrypoint";
}>;
files: readonly CliExecutableFileIdentity[];
runtimeArtifact:
| Readonly<{ kind: "self-contained-executable" }>
| Readonly<{
kind: "package-tree";
packageName: string;
rootPath: string;
fileCount: number;
totalBytes: string;
treeSha256: string;
}>;
}>;
type ReadIdentityResult = {
identity: CliExecutableFileIdentity;
prefix: Buffer;
};
const MAX_PACKAGE_ARTIFACT_FILES = 8192;
const MAX_PACKAGE_ARTIFACT_ENTRIES = MAX_PACKAGE_ARTIFACT_FILES * 4;
const MAX_PACKAGE_ARTIFACT_BYTES = 1024n * 1024n * 1024n;
type StableBigIntFileStat = {
dev: bigint;
ino: bigint;
mode: bigint;
size: bigint;
mtimeNs: bigint;
ctimeNs: bigint;
};
function sameOpenedFile(left: StableBigIntFileStat, right: StableBigIntFileStat): boolean {
return (
left.dev === right.dev &&
left.ino === right.ino &&
left.mode === right.mode &&
left.size === right.size &&
left.mtimeNs === right.mtimeNs &&
left.ctimeNs === right.ctimeNs
);
}
function compareArtifactEntryNames(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
async function readExecutableFileIdentity(filePath: string): Promise<ReadIdentityResult | null> {
let canonicalPath: string;
try {
canonicalPath = await fs.realpath(filePath);
} catch {
return null;
}
let handle: Awaited<ReturnType<typeof fs.open>> | undefined;
try {
handle = await fs.open(canonicalPath, "r");
const before = await handle.stat({ bigint: true });
if (!before.isFile()) {
return null;
}
const hash = crypto.createHash("sha256");
const buffer = Buffer.allocUnsafe(64 * 1024);
const prefixChunks: Buffer[] = [];
let prefixBytes = 0;
let position = 0;
for (;;) {
const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
if (bytesRead === 0) {
break;
}
const chunk = buffer.subarray(0, bytesRead);
hash.update(chunk);
if (prefixBytes < 4096) {
const prefixChunk = Buffer.from(chunk.subarray(0, 4096 - prefixBytes));
prefixChunks.push(prefixChunk);
prefixBytes += prefixChunk.length;
}
position += bytesRead;
}
const after = await handle.stat({ bigint: true });
const current = await fs.stat(canonicalPath, { bigint: true });
if (!sameOpenedFile(before, after) || !sameOpenedFile(after, current)) {
return null;
}
return {
identity: {
path: canonicalPath,
device: String(after.dev),
inode: String(after.ino),
mode: String(after.mode),
size: String(after.size),
modifiedNs: String(after.mtimeNs),
changedNs: String(after.ctimeNs),
contentSha256: hash.digest("hex"),
},
prefix: Buffer.concat(prefixChunks, prefixBytes),
};
} catch {
return null;
} finally {
await handle?.close().catch(() => {});
}
}
function hasPathSeparator(value: string): boolean {
return value.includes("/") || value.includes("\\");
}
function isDurableRootedCommand(value: string): boolean {
return path.isAbsolute(value) || /^~[\\/]/u.test(value);
}
function pathEntriesAreAbsolute(env: NodeJS.ProcessEnv): boolean {
const pathValue = env.PATH ?? env.Path ?? "";
const delimiter = process.platform === "win32" ? ";" : path.delimiter;
return pathValue
.split(delimiter)
.filter(Boolean)
.every((entry) => path.isAbsolute(entry));
}
function resolveCommandPath(params: {
command: string;
cwd?: string;
env: NodeJS.ProcessEnv;
}): string | undefined {
if (!hasPathSeparator(params.command) && !pathEntriesAreAbsolute(params.env)) {
// A relative PATH entry resolves against the child cwd. That cwd is not a
// durable route fact, so it cannot safely back a persistent owner proof.
return undefined;
}
if (hasPathSeparator(params.command) && !isDurableRootedCommand(params.command)) {
// The setup probe and later OpenClaw turns intentionally use different
// workspaces. A cwd-relative executable cannot name one durable owner.
return undefined;
}
return resolveExecutablePath(params.command, {
...(params.cwd ? { cwd: params.cwd } : {}),
env: params.env,
});
}
function hasShebang(prefix: Buffer): boolean {
return prefix.subarray(0, 2).toString("utf8") === "#!";
}
function parseShebangInterpreter(
prefix: Buffer,
): { executable: string; args: string[]; viaEnv?: string } | null {
const firstLine = prefix.toString("utf8").split(/\r?\n/u, 1)[0] ?? "";
if (!firstLine.startsWith("#!")) {
return null;
}
const tokens = firstLine.slice(2).trim().split(/\s+/u).filter(Boolean);
const executable = tokens[0];
if (!executable) {
return null;
}
if (path.basename(executable) !== "env") {
return { executable, args: tokens.slice(1) };
}
const envArgs = tokens.slice(1);
const commandStart = envArgs[0] === "-S" ? 1 : 0;
const viaEnv = envArgs[commandStart];
if (!viaEnv || viaEnv.startsWith("-")) {
return null;
}
return { executable, viaEnv, args: envArgs.slice(commandStart + 1) };
}
async function findOwnedPackageRoot(params: {
entrypointPath: string;
policy: CliBackendRuntimeArtifactPolicy;
}): Promise<string | undefined> {
let directory = path.dirname(params.entrypointPath);
for (;;) {
const packageJsonPath = path.join(directory, "package.json");
try {
const parsed = JSON.parse(await fs.readFile(packageJsonPath, "utf8")) as { name?: unknown };
if (parsed.name === params.policy.packageName) {
return await fs.realpath(directory);
}
} catch {
// Continue through parent package scopes.
}
const parent = path.dirname(directory);
if (parent === directory) {
return undefined;
}
directory = parent;
}
}
async function resolvePackageTreeArtifact(params: {
entrypointPath: string;
policy: CliBackendRuntimeArtifactPolicy | undefined;
}): Promise<CliExecutableIdentity["runtimeArtifact"] | undefined> {
if (!params.policy || params.policy.kind !== "bundled-package-tree") {
return undefined;
}
const rootPath = await findOwnedPackageRoot({
entrypointPath: params.entrypointPath,
policy: params.policy,
});
if (!rootPath) {
return undefined;
}
try {
const packageJson = JSON.parse(
await fs.readFile(path.join(rootPath, "package.json"), "utf8"),
) as {
dependencies?: unknown;
peerDependencies?: unknown;
};
const externalDependencyDeclarations = [packageJson.dependencies, packageJson.peerDependencies];
if (
externalDependencyDeclarations.some(
(dependencies) =>
dependencies !== undefined &&
(dependencies === null ||
typeof dependencies !== "object" ||
Array.isArray(dependencies) ||
Object.keys(dependencies).length > 0),
)
) {
// The package-tree contract is intentionally self-contained. Required
// dependencies or peers may be hoisted outside this root and escape hashing.
return undefined;
}
} catch {
return undefined;
}
const hash = crypto.createHash("sha256");
let entryCount = 0;
let fileCount = 0;
let totalBytes = 0n;
const visit = async (directory: string): Promise<boolean> => {
let entries: Dirent[];
try {
entries = await fs.readdir(directory, { withFileTypes: true, encoding: "utf8" });
} catch {
return false;
}
// Locale collation can differ across hosts. Artifact bytes must have one
// process- and locale-independent traversal order.
for (const entry of entries.toSorted((left, right) =>
compareArtifactEntryNames(left.name, right.name),
)) {
entryCount += 1;
if (entryCount > MAX_PACKAGE_ARTIFACT_ENTRIES) {
return false;
}
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (!(await visit(entryPath))) {
return false;
}
continue;
}
if (!entry.isFile()) {
// Symlinks and special files can redirect outside the declared package.
return false;
}
let size: bigint;
try {
const stat = await fs.stat(entryPath, { bigint: true });
if (!stat.isFile()) {
return false;
}
size = stat.size;
} catch {
return false;
}
if (
fileCount + 1 > MAX_PACKAGE_ARTIFACT_FILES ||
totalBytes + size > MAX_PACKAGE_ARTIFACT_BYTES
) {
return false;
}
const file = await readExecutableFileIdentity(entryPath);
if (!file) {
return false;
}
fileCount += 1;
totalBytes += BigInt(file.identity.size);
if (fileCount > MAX_PACKAGE_ARTIFACT_FILES || totalBytes > MAX_PACKAGE_ARTIFACT_BYTES) {
return false;
}
hash.update(
JSON.stringify([
path.relative(rootPath, file.identity.path).split(path.sep).join("/"),
file.identity.mode,
file.identity.size,
file.identity.contentSha256,
]),
);
hash.update("\n");
}
return true;
};
if (!(await visit(rootPath)) || fileCount === 0) {
return undefined;
}
return {
kind: "package-tree",
packageName: params.policy.packageName,
rootPath,
fileCount,
totalBytes: String(totalBytes),
treeSha256: hash.digest("hex"),
};
}
function allowsSelfContainedExecutable(
filePath: string,
resolvedCommandPath: string,
policy: CliBackendRuntimeArtifactPolicy | undefined,
): boolean {
if (!policy) {
return false;
}
const basenames = new Set(
[filePath, resolvedCommandPath].map((candidate) => {
const basename = path.basename(candidate);
return process.platform === "win32" ? basename.toLowerCase() : basename;
}),
);
return (
policy.nativeExecutableNames?.some((name) =>
basenames.has(process.platform === "win32" ? name.toLowerCase() : name),
) === true
);
}
async function resolvePosixIdentity(params: {
command: string;
resolvedPath: string;
cwd?: string;
env: NodeJS.ProcessEnv;
runtimeArtifact?: CliBackendRuntimeArtifactPolicy;
}): Promise<CliExecutableIdentity | undefined> {
const commandFile = await readExecutableFileIdentity(params.resolvedPath);
if (!commandFile) {
return undefined;
}
const files = [commandFile.identity];
const commandHasShebang = hasShebang(commandFile.prefix);
const shebang = parseShebangInterpreter(commandFile.prefix);
if (commandHasShebang && !shebang) {
return undefined;
}
if (shebang && shebang.args.length > 0) {
// Interpreter flags can load code outside the declared package tree.
return undefined;
}
const packageEntrypoint = shebang ? commandFile.identity : undefined;
const runtimeArtifact = packageEntrypoint
? await resolvePackageTreeArtifact({
entrypointPath: packageEntrypoint.path,
policy: params.runtimeArtifact,
})
: allowsSelfContainedExecutable(
commandFile.identity.path,
params.resolvedPath,
params.runtimeArtifact,
)
? ({ kind: "self-contained-executable" } as const)
: undefined;
if (!runtimeArtifact) {
return undefined;
}
if (shebang) {
const interpreterPath = resolveCommandPath({
command: shebang.executable,
cwd: params.cwd,
env: params.env,
});
if (!interpreterPath) {
return undefined;
}
const interpreter = await readExecutableFileIdentity(interpreterPath);
if (!interpreter || hasShebang(interpreter.prefix)) {
return undefined;
}
files.push(interpreter.identity);
let invocationInterpreter = interpreter.identity.path;
if (shebang.viaEnv) {
const targetPath = resolveCommandPath({
command: shebang.viaEnv,
cwd: params.cwd,
env: params.env,
});
if (!targetPath) {
return undefined;
}
const target = await readExecutableFileIdentity(targetPath);
if (!target || hasShebang(target.prefix)) {
return undefined;
}
files.push(target.identity);
invocationInterpreter = target.identity.path;
}
return {
command: params.command,
resolvedPath: commandFile.identity.path,
invocation: {
command: invocationInterpreter,
leadingArgv: [...shebang.args, commandFile.identity.path],
resolution: "direct",
},
files: dedupeFileIdentities(files),
runtimeArtifact,
};
}
const resolvedPath = commandFile.identity.path;
return {
command: params.command,
resolvedPath,
invocation: {
// Spawn the exact file opened and hashed, not a mutable symlink alias.
command: resolvedPath,
leadingArgv: [],
resolution: "direct",
},
files: dedupeFileIdentities(files),
runtimeArtifact,
};
}
function dedupeFileIdentities(
files: readonly CliExecutableFileIdentity[],
): CliExecutableFileIdentity[] {
return files.filter(
(file, index) => files.findIndex((candidate) => candidate.path === file.path) === index,
);
}
async function resolveWindowsIdentity(params: {
command: string;
resolvedPath: string;
env: NodeJS.ProcessEnv;
runtimeArtifact?: CliBackendRuntimeArtifactPolicy;
}): Promise<CliExecutableIdentity | undefined> {
const nodePath = resolveWindowsExecutablePath("node", params.env);
const candidate = resolveWindowsSpawnProgramCandidate({
command: params.resolvedPath,
env: params.env,
execPath: nodePath,
});
if (candidate.resolution === "unresolved-wrapper") {
return undefined;
}
if (
candidate.resolution === "node-entrypoint" &&
path.extname(candidate.command).toLowerCase() !== ".exe"
) {
return undefined;
}
const configuredFile = await readExecutableFileIdentity(params.resolvedPath);
const invocationFile = await readExecutableFileIdentity(candidate.command);
if (!configuredFile || !invocationFile) {
return undefined;
}
const files = [configuredFile.identity, invocationFile.identity];
const leadingArgv: string[] = [];
for (const entry of candidate.leadingArgv) {
const entryFile = await readExecutableFileIdentity(entry);
if (!entryFile) {
return undefined;
}
files.push(entryFile.identity);
leadingArgv.push(entryFile.identity.path);
}
const commandEntrypoint =
candidate.resolution === "node-entrypoint"
? files.find((file) => file.path === leadingArgv[0])
: undefined;
if (candidate.resolution === "direct" && hasShebang(invocationFile.prefix)) {
return undefined;
}
const scriptEntrypoint = commandEntrypoint;
const runtimeArtifact = scriptEntrypoint
? await resolvePackageTreeArtifact({
entrypointPath: scriptEntrypoint.path,
policy: params.runtimeArtifact,
})
: allowsSelfContainedExecutable(
invocationFile.identity.path,
params.resolvedPath,
params.runtimeArtifact,
)
? ({ kind: "self-contained-executable" } as const)
: undefined;
if (!runtimeArtifact) {
return undefined;
}
return {
command: params.command,
resolvedPath: configuredFile.identity.path,
invocation: {
command: invocationFile.identity.path,
leadingArgv,
resolution: candidate.resolution,
},
files: dedupeFileIdentities(files),
runtimeArtifact,
};
}
/**
* Resolve and fingerprint the exact program a CLI child will execute.
*
* Call only while minting or revalidating verified setup authority: content
* hashing is deliberate and must not enter the normal CLI request hot path.
*/
export async function resolveCliExecutableIdentity(params: {
command: string;
cwd?: string;
env?: NodeJS.ProcessEnv;
runtimeArtifact?: CliBackendRuntimeArtifactPolicy;
}): Promise<CliExecutableIdentity | undefined> {
const command = params.command.trim();
if (!command) {
return undefined;
}
const env = params.env ?? process.env;
const resolvedPath = resolveCommandPath({
command,
...(params.cwd ? { cwd: params.cwd } : {}),
env,
});
if (!resolvedPath) {
return undefined;
}
return process.platform === "win32"
? await resolveWindowsIdentity({
command,
resolvedPath,
env,
...(params.runtimeArtifact ? { runtimeArtifact: params.runtimeArtifact } : {}),
})
: await resolvePosixIdentity({
command,
resolvedPath,
...(params.cwd ? { cwd: params.cwd } : {}),
env,
...(params.runtimeArtifact ? { runtimeArtifact: params.runtimeArtifact } : {}),
});
}