mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-30 12:21:03 +00:00
* refactor: extract agent core package Introduce packages/agent-core as the OpenClaw-owned home for reusable agent loop, harness, session, prompt, and runtime dependency contracts. * refactor: extract shared llm runtime Move provider model registries, stream wrappers, OAuth helpers, and LLM utilities into src/llm with plugin-sdk barrels instead of depending on the old embedded runtime layout. * refactor: remove pi runtime internals Rename remaining Pi-shaped agent surfaces to OpenClaw agent runtime names, delete obsolete Pi docs and package graph checks, and add the third-party notice for incorporated code. * refactor: tighten agent session runtime Make agent-core/runtime dependencies explicit, consolidate compaction and session transcript helpers, and move model/session helpers behind OpenClaw-owned contracts. * refactor: remove static model and pi auth paths Drop static model catalogs and Pi auth bridges, move model/provider facts to manifest-owned runtime contracts, and harden internal embedded-agent utilities. * refactor: remove legacy provider compat paths * docs: remove agent parity notes * fix: skip provider wildcard metadata parsing * refactor: share session extension sdk loading * refactor: inline acpx proxy error formatter * refactor: fold edit recovery into edit tool * fix: accept extension batch separator * test: align startup provider plugin expectations * fix: restore provider-scoped release discovery * test: align static asset packaging expectations * fix: run static provider catalogs during scoped discovery * fix: add provider entry catalogs for scoped live discovery * fix: load lightweight provider catalog entries * fix: refresh provider-scoped plugin metadata * fix: keep provider catalog entries on release live path * fix: keep static manifest models in release live checks * fix: harden release model discovery * fix: reduce OpenAI live cache probe reasoning * fix: disable OpenAI cache probe reasoning * ci: extend OpenAI gateway live timeout * fix: extend live gateway model budget * fix: stabilize release validation regressions * fix: honor provider aliases in model rows * fix: stabilize release validation lanes * fix: stabilize release memory qa * ci: stabilize release validation lanes * ci: prefer ipv4 for live docker node calls * fix: restore shared tool-call stream wrapper * ci: remove legacy pi test shard alias * fix: clean up embedded agent test drift * fix: stabilize runtime alias status * fix: clean up embedded agent ci drift * fix: restore release ci invariants * fix: clean up post-rebase runtime drift * fix: restore release ci checks * fix: restore release ci after rebase * fix: remove stale pi runtime path * test: align compaction runtime expectations * test: update plugin prerelease expectations * fix: handle claude live tool approvals * fix: stabilize release validation gates * fix: finish agent runtime import * test: finish post-rebase agent runtime mocks * fix: keep codex compaction native * fix: stabilize codex app-server hook tests * test: isolate codex diagnostic active run * test: remove codex diagnostic completion race # Conflicts: # extensions/codex/src/app-server/run-attempt.test.ts * ci: fix full release manifest performance run id * refactor: narrow llm plugin sdk boundary * chore: drop generated google boundary stamps * fix: repair rebase fallout * fix: clean up rebased runtime references * fix: decode codex jwt payloads as base64url * fix: preserve shipped pi runtime alias * fix: add scoped sdk virtual modules * fix: decode llm codex oauth jwt as base64url * fix: avoid stale vertex adc negative cache * fix: harden tool arg decoding and codeql path * fix: keep vertex adc negative checks live * refactor: consolidate codex jwt and edit helpers * fix: await codex oauth node runtime imports * fix: preserve sdk tool and notice contracts * fix: preserve shipped compat config boundaries * fix: align codex oauth callback host * fix: terminate agent-core loop streams on failure * fix: keep codex oauth callback alive during fallback * ci: include session tools in critical codeql scans * fix: keep Cloudflare Anthropic provider auth header * docs: redirect legacy pi runtime pages * fix: honor bundled web provider compat discovery * fix: protect session output spill files * fix: keep legacy agent dir env blocked * fix: contain auto-discovered skill symlinks * fix: harden agent core sdk proxy surfaces * fix: restore approval reaction sdk compat * fix: keep live docker runs bounded * fix: keep codex oauth redirect host aligned * fix: resolve post-rebase agent runtime drift * fix: redact anthropic oauth parse failures * fix: preserve responses strict tool shaping * fix: repair agent runtime rebase cleanup * docs: redirect retired parity pages * fix: bound auto-discovered resources to roots * fix: repair post-rebase agent test drift * fix: preserve bundled provider allowlist migration * fix: preserve manifest-owned provider aliases * fix: declare photon image dependency * fix: keep provider headers out of proxy body * fix: preserve shipped env aliases * fix: refresh control ui i18n generated state * fix: quote read fallback paths * fix: preview edits through configured backend * test: satisfy core test typecheck * fix: preserve ZAI usage auth fallback * test: repair codex diagnostic test * fix: repair agent runtime rebase drift * test: finish embedded runner import rename * fix: repair agent runtime rebase integrations * test: align compaction oauth fallback expectations * fix: allow sdk-auth session models * fix: update doctor tool schema import * fix: preserve bedrock plugin region * fix: stream harmony-like prose immediately * ci: include session runtime in codeql shards * fix: repair latest rebase integrations * fix: honor explicit codex websocket transport * fix: keep openai-compatible credentials provider-scoped * fix: refresh sdk api baseline after rebase * fix: route cli runtime aliases through openclaw harness * test: rename stale harness mock expectation * test: rename embedded agent overflow calls * test: clean embedded auth test wording * test: use openclaw stream types in deepinfra cache test * fix: refresh sdk api baseline on latest main * fix: honor bundled discovery compat allowlists * fix: refresh sdk api baseline after latest rebase * fix: remove stale rebase imports * test: rename stale model catalog mock * test: mock renamed doctor runtime modules * fix: map canonical kimi env auth * fix: use internal model registry in bench script * fix: migrate deepinfra provider catalog entry * fix: enforce builtin tool suppression * fix: route compaction auth and proxy payloads safely * refactor: prune unused llm registry leftovers * test: update codex hooks session import * test: fix model picker ci coverage * test: align model picker auth mock types
302 lines
8.7 KiB
TypeScript
302 lines
8.7 KiB
TypeScript
import { existsSync, statSync } from "node:fs";
|
|
import fs from "node:fs/promises";
|
|
import { homedir } from "node:os";
|
|
import path from "node:path";
|
|
import { sliceUtf16Safe } from "../utils.js";
|
|
import { assertSandboxPath } from "./sandbox-paths.js";
|
|
import type { SandboxBackendExecSpec } from "./sandbox/backend-handle.types.js";
|
|
|
|
const CHUNK_LIMIT = 8 * 1024;
|
|
|
|
export type BashSandboxConfig = {
|
|
containerName: string;
|
|
workspaceDir: string;
|
|
containerWorkdir: string;
|
|
env?: Record<string, string>;
|
|
buildExecSpec?: (params: {
|
|
command: string;
|
|
workdir?: string;
|
|
env: Record<string, string>;
|
|
usePty: boolean;
|
|
}) => Promise<SandboxBackendExecSpec>;
|
|
finalizeExec?: (params: {
|
|
status: "completed" | "failed";
|
|
exitCode: number | null;
|
|
timedOut: boolean;
|
|
token?: unknown;
|
|
}) => Promise<void>;
|
|
};
|
|
|
|
export function buildSandboxEnv(params: {
|
|
defaultPath: string;
|
|
paramsEnv?: Record<string, string>;
|
|
sandboxEnv?: Record<string, string>;
|
|
containerWorkdir: string;
|
|
}) {
|
|
const env: Record<string, string> = {
|
|
PATH: params.defaultPath,
|
|
HOME: params.containerWorkdir,
|
|
};
|
|
for (const [key, value] of Object.entries(params.sandboxEnv ?? {})) {
|
|
env[key] = value;
|
|
}
|
|
for (const [key, value] of Object.entries(params.paramsEnv ?? {})) {
|
|
env[key] = value;
|
|
}
|
|
return env;
|
|
}
|
|
|
|
export function coerceEnv(env?: NodeJS.ProcessEnv | Record<string, string>) {
|
|
const record: Record<string, string> = {};
|
|
if (!env) {
|
|
return record;
|
|
}
|
|
for (const [key, value] of Object.entries(env)) {
|
|
if (typeof value === "string") {
|
|
record[key] = value;
|
|
}
|
|
}
|
|
return record;
|
|
}
|
|
|
|
export function buildDockerExecArgs(params: {
|
|
containerName: string;
|
|
command: string;
|
|
workdir?: string;
|
|
env: Record<string, string>;
|
|
tty: boolean;
|
|
}) {
|
|
const args = ["exec", "-i"];
|
|
if (params.tty) {
|
|
args.push("-t");
|
|
}
|
|
if (params.workdir) {
|
|
args.push("-w", params.workdir);
|
|
}
|
|
for (const [key, value] of Object.entries(params.env)) {
|
|
// Skip PATH — passing a host PATH (e.g. Windows paths) via -e poisons
|
|
// Docker's executable lookup, causing "sh: not found" on Windows hosts.
|
|
// PATH is handled separately via OPENCLAW_PREPEND_PATH below.
|
|
if (key === "PATH") {
|
|
continue;
|
|
}
|
|
args.push("-e", `${key}=${value}`);
|
|
}
|
|
const hasCustomPath = typeof params.env.PATH === "string" && params.env.PATH.length > 0;
|
|
if (hasCustomPath) {
|
|
// Avoid interpolating PATH into the shell command; pass it via env instead.
|
|
args.push("-e", `OPENCLAW_PREPEND_PATH=${params.env.PATH}`);
|
|
}
|
|
// Login shell (-l) sources /etc/profile which resets PATH to a minimal set,
|
|
// overriding both Docker ENV and -e PATH=... environment variables.
|
|
// Prepend custom PATH after profile sourcing to ensure custom tools are accessible
|
|
// while preserving system paths that /etc/profile may have added.
|
|
const pathExport = hasCustomPath
|
|
? 'export PATH="${OPENCLAW_PREPEND_PATH}:$PATH"; unset OPENCLAW_PREPEND_PATH; '
|
|
: "";
|
|
// Use absolute path for sh to avoid dependency on PATH resolution during exec.
|
|
args.push(params.containerName, "/bin/sh", "-lc", `${pathExport}${params.command}`);
|
|
return args;
|
|
}
|
|
|
|
export async function resolveSandboxWorkdir(params: {
|
|
workdir: string;
|
|
sandbox: BashSandboxConfig;
|
|
warnings: string[];
|
|
}) {
|
|
const fallback = params.sandbox.workspaceDir;
|
|
const mappedHostWorkdir = mapContainerWorkdirToHost({
|
|
workdir: params.workdir,
|
|
sandbox: params.sandbox,
|
|
});
|
|
const candidateWorkdir = mappedHostWorkdir ?? params.workdir;
|
|
try {
|
|
const resolved = await assertSandboxPath({
|
|
filePath: candidateWorkdir,
|
|
cwd: process.cwd(),
|
|
root: params.sandbox.workspaceDir,
|
|
});
|
|
const stats = await fs.stat(resolved.resolved);
|
|
if (!stats.isDirectory()) {
|
|
throw new Error("workdir is not a directory");
|
|
}
|
|
const relative = resolved.relative
|
|
? resolved.relative.split(path.sep).join(path.posix.sep)
|
|
: "";
|
|
const containerWorkdir = relative
|
|
? path.posix.join(params.sandbox.containerWorkdir, relative)
|
|
: params.sandbox.containerWorkdir;
|
|
return { hostWorkdir: resolved.resolved, containerWorkdir };
|
|
} catch {
|
|
params.warnings.push(
|
|
`Warning: workdir "${params.workdir}" is unavailable; using "${fallback}".`,
|
|
);
|
|
return {
|
|
hostWorkdir: fallback,
|
|
containerWorkdir: params.sandbox.containerWorkdir,
|
|
};
|
|
}
|
|
}
|
|
|
|
function mapContainerWorkdirToHost(params: {
|
|
workdir: string;
|
|
sandbox: BashSandboxConfig;
|
|
}): string | undefined {
|
|
const workdir = normalizeContainerPath(params.workdir);
|
|
const containerRoot = normalizeContainerPath(params.sandbox.containerWorkdir);
|
|
if (containerRoot === ".") {
|
|
return undefined;
|
|
}
|
|
if (workdir === containerRoot) {
|
|
return path.resolve(params.sandbox.workspaceDir);
|
|
}
|
|
if (!workdir.startsWith(`${containerRoot}/`)) {
|
|
return undefined;
|
|
}
|
|
const rel = workdir
|
|
.slice(containerRoot.length + 1)
|
|
.split("/")
|
|
.filter(Boolean);
|
|
return path.resolve(params.sandbox.workspaceDir, ...rel);
|
|
}
|
|
|
|
function normalizeContainerPath(input: string): string {
|
|
const normalized = input.trim().replace(/\\/g, "/");
|
|
if (!normalized) {
|
|
return ".";
|
|
}
|
|
return path.posix.normalize(normalized);
|
|
}
|
|
|
|
export function resolveWorkdir(workdir: string, warnings: string[]) {
|
|
const current = safeCwd();
|
|
const fallback = current ?? homedir();
|
|
try {
|
|
const stats = statSync(workdir);
|
|
if (stats.isDirectory()) {
|
|
return workdir;
|
|
}
|
|
} catch {
|
|
// ignore, fallback below
|
|
}
|
|
warnings.push(`Warning: workdir "${workdir}" is unavailable; using "${fallback}".`);
|
|
return fallback;
|
|
}
|
|
|
|
function safeCwd() {
|
|
try {
|
|
const cwd = process.cwd();
|
|
return existsSync(cwd) ? cwd : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clamp a number within min/max bounds, using defaultValue if undefined or NaN.
|
|
*/
|
|
export function clampWithDefault(
|
|
value: number | undefined,
|
|
defaultValue: number,
|
|
min: number,
|
|
max: number,
|
|
) {
|
|
if (value === undefined || Number.isNaN(value)) {
|
|
return defaultValue;
|
|
}
|
|
return Math.min(Math.max(value, min), max);
|
|
}
|
|
|
|
export function readEnvInt(key: string, legacyKey?: string) {
|
|
const raw = process.env[key] || (legacyKey ? process.env[legacyKey] : undefined);
|
|
if (!raw) {
|
|
return undefined;
|
|
}
|
|
const parsed = Number.parseInt(raw, 10);
|
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
}
|
|
|
|
export function chunkString(input: string, limit = CHUNK_LIMIT) {
|
|
const chunks: string[] = [];
|
|
for (let i = 0; i < input.length; i += limit) {
|
|
chunks.push(input.slice(i, i + limit));
|
|
}
|
|
return chunks;
|
|
}
|
|
|
|
export function truncateMiddle(str: string, max: number) {
|
|
if (str.length <= max) {
|
|
return str;
|
|
}
|
|
const half = Math.floor((max - 3) / 2);
|
|
return `${sliceUtf16Safe(str, 0, half)}...${sliceUtf16Safe(str, -half)}`;
|
|
}
|
|
|
|
export function sliceLogLines(
|
|
text: string,
|
|
offset?: number,
|
|
limit?: number,
|
|
): { slice: string; totalLines: number; totalChars: number } {
|
|
if (!text) {
|
|
return { slice: "", totalLines: 0, totalChars: 0 };
|
|
}
|
|
const normalized = text.replace(/\r\n/g, "\n");
|
|
const lines = normalized.split("\n");
|
|
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
lines.pop();
|
|
}
|
|
const totalLines = lines.length;
|
|
const totalChars = text.length;
|
|
let start =
|
|
typeof offset === "number" && Number.isFinite(offset) ? Math.max(0, Math.floor(offset)) : 0;
|
|
if (limit !== undefined && offset === undefined) {
|
|
const tailCount = Math.max(0, Math.floor(limit));
|
|
start = Math.max(totalLines - tailCount, 0);
|
|
}
|
|
const end =
|
|
typeof limit === "number" && Number.isFinite(limit)
|
|
? start + Math.max(0, Math.floor(limit))
|
|
: undefined;
|
|
return { slice: lines.slice(start, end).join("\n"), totalLines, totalChars };
|
|
}
|
|
|
|
export function deriveSessionName(command: string): string | undefined {
|
|
const tokens = tokenizeCommand(command);
|
|
if (tokens.length === 0) {
|
|
return undefined;
|
|
}
|
|
const verb = tokens[0];
|
|
let target = tokens.slice(1).find((t) => !t.startsWith("-"));
|
|
if (!target) {
|
|
target = tokens[1];
|
|
}
|
|
if (!target) {
|
|
return verb;
|
|
}
|
|
const cleaned = truncateMiddle(stripQuotes(target), 48);
|
|
return `${stripQuotes(verb)} ${cleaned}`;
|
|
}
|
|
|
|
function tokenizeCommand(command: string): string[] {
|
|
const matches = command.match(/(?:[^\s"']+|"(?:\\.|[^"])*"|'(?:\\.|[^'])*')+/g) ?? [];
|
|
return matches.map((token) => stripQuotes(token)).filter(Boolean);
|
|
}
|
|
|
|
function stripQuotes(value: string): string {
|
|
const trimmed = value.trim();
|
|
if (
|
|
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
) {
|
|
return trimmed.slice(1, -1);
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
export function pad(str: string, width: number) {
|
|
if (str.length >= width) {
|
|
return str;
|
|
}
|
|
return str + " ".repeat(width - str.length);
|
|
}
|