diff --git a/AGENTS.md b/AGENTS.md index 15d7bb1222c4..35612846f00e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,7 +145,9 @@ Skills own workflows; root owns hard policy and routing. - Crabbox request means real scenario proof: install/update/call/repro user path; not just copy tests and run them remotely. - Visual proof: use Crabbox, set up like a user, then screenshot-verify. No harness/bypass/shortcut unless explicitly asked. - Local agent work is limited to lightweight non-test checks such as `git diff --check`, targeted formatting, and cheap static probes. Tests and computationally intensive work default to the selected remote box. -- In Codex or linked worktrees, direct local `pnpm test*`, `pnpm check*`, `pnpm crabbox:run`, and `scripts/committer` can trigger pnpm dependency reconciliation or install prompts. Prefer `node` wrappers locally and Crabbox/Testbox for pnpm-gated proof. +- Agent direct local `pnpm` can reconcile dependencies instead of running the requested script. Prefer `node` wrappers locally and Crabbox/Testbox for pnpm-gated work. +- Direct Blacksmith lease: use `blacksmith testbox run`; Crabbox wrapper reuse needs a wrapper-created lease. +- Dirty-sync generator proof: compare hashes before/after; `git diff` includes the synced patch. - Crabbox wrapper `stop` has no `--timing-json`; use `node scripts/crabbox-wrapper.mjs stop --provider --id `. - Repo-native PR worktree may omit `node_modules`; prove remotely, then use `git commit --no-verify`, not `scripts/committer`. - Release-branch formatting: Testbox or existing binary; never local `pnpm exec` reconciliation. diff --git a/packages/sdk/src/package.e2e.test.ts b/packages/sdk/src/package.e2e.test.ts index 4f58d492405c..e6236cf07067 100644 --- a/packages/sdk/src/package.e2e.test.ts +++ b/packages/sdk/src/package.e2e.test.ts @@ -18,12 +18,6 @@ type CommandResult = { const COMMAND_TIMEOUT_MS = 120_000; const tempDirs: string[] = []; -const WORKSPACE_PACKAGE_NAMES = [ - "@openclaw/gateway-protocol", - "@openclaw/retry", - "@openclaw/gateway-client", - "@openclaw/sdk", -] as const; type PackageManifest = { name: string; @@ -37,6 +31,14 @@ type PackedPackage = { tarball: string; }; +function resolveWorkspacePackageRoot(repoRoot: string, packageName: string): string { + const prefix = "@openclaw/"; + if (!packageName.startsWith(prefix)) { + throw new Error(`unsupported workspace package name: ${packageName}`); + } + return path.join(repoRoot, "packages", packageName.slice(prefix.length)); +} + function runCommand( command: string, args: string[], @@ -191,20 +193,57 @@ function normalizeWorkspaceDependencies( const normalized: Record = {}; for (const [name, spec] of Object.entries(dependencies)) { normalized[name] = - name.startsWith("@openclaw/") && spec === "workspace:*" ? "0.0.0-private" : spec; + name.startsWith("@openclaw/") && spec.startsWith("workspace:") ? "0.0.0-private" : spec; } return normalized; } -async function readPackageManifest(packageRoot: string): Promise { +async function readRawPackageManifest(packageRoot: string): Promise { const packageJson = await fs.readFile(path.join(packageRoot, "package.json"), "utf8"); - const manifest = JSON.parse(packageJson) as PackageManifest; + return JSON.parse(packageJson) as PackageManifest; +} + +async function readPackageManifest(packageRoot: string): Promise { + const manifest = await readRawPackageManifest(packageRoot); return { ...manifest, dependencies: normalizeWorkspaceDependencies(manifest.dependencies), }; } +async function collectWorkspacePackageRoots(params: { + repoRoot: string; + entryRoot: string; +}): Promise { + const orderedRoots: string[] = []; + const visitedNames = new Set(); + + async function visit(packageRoot: string, expectedName?: string): Promise { + const manifest = await readRawPackageManifest(packageRoot); + if (expectedName && manifest.name !== expectedName) { + throw new Error( + `workspace dependency ${expectedName} resolved to unexpected package ${manifest.name}`, + ); + } + if (visitedNames.has(manifest.name)) { + return; + } + visitedNames.add(manifest.name); + + const workspaceDependencies = Object.entries(manifest.dependencies ?? {}) + .filter(([, spec]) => spec.startsWith("workspace:")) + .map(([name]) => name) + .toSorted(); + for (const dependencyName of workspaceDependencies) { + await visit(resolveWorkspacePackageRoot(params.repoRoot, dependencyName), dependencyName); + } + orderedRoots.push(packageRoot); + } + + await visit(params.entryRoot); + return orderedRoots; +} + function tarballFileName(manifest: PackageManifest): string { return `${manifest.name.replace(/^@/, "").replace("/", "-")}-${manifest.version}.tgz`; } @@ -344,17 +383,16 @@ describe("OpenClaw SDK package e2e", () => { it("packs and imports from an external temp consumer", async () => { const repoRoot = process.cwd(); - const packageRoots = [ - path.join(repoRoot, "packages", "gateway-protocol"), - path.join(repoRoot, "packages", "retry"), - path.join(repoRoot, "packages", "gateway-client"), - path.join(repoRoot, "packages", "sdk"), - ]; + const packageRoots = await collectWorkspacePackageRoots({ + repoRoot, + entryRoot: path.join(repoRoot, "packages", "sdk"), + }); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sdk-consumer-")); tempDirs.push(tempDir); - for (const packageName of WORKSPACE_PACKAGE_NAMES) { - await runPnpmCommand(["--filter", packageName, "build"], { + for (const packageRoot of packageRoots) { + const manifest = await readRawPackageManifest(packageRoot); + await runPnpmCommand(["--filter", manifest.name, "build"], { cwd: repoRoot, timeoutMs: 180_000, }); diff --git a/src/agents/sandbox-agent-config.agent-specific-sandbox-config.e2e.test.ts b/src/agents/sandbox-agent-config.agent-specific-sandbox-config.e2e.test.ts index d4549892ac99..daac4f974c00 100644 --- a/src/agents/sandbox-agent-config.agent-specific-sandbox-config.e2e.test.ts +++ b/src/agents/sandbox-agent-config.agent-specific-sandbox-config.e2e.test.ts @@ -1,7 +1,5 @@ // Verifies agent-specific sandbox config, workspace roots, and Docker setup commands. -import { EventEmitter } from "node:events"; import path from "node:path"; -import { Readable } from "node:stream"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { createRestrictedAgentSandboxConfig } from "./test-helpers/sandbox-agent-config-fixtures.js"; @@ -11,41 +9,29 @@ type SpawnCall = { args: string[]; }; -const spawnCalls: SpawnCall[] = []; +const spawnCalls = vi.hoisted(() => [] as SpawnCall[]); -vi.mock("node:child_process", () => ({ - execFile: (...args: unknown[]) => { - // Docker availability probes should succeed without invoking real Docker. - const callback = args.findLast( - (arg): arg is (error: null, stdout: string, stderr: string) => void => - typeof arg === "function", - ); - queueMicrotask(() => callback?.(null, "", "")); - return new EventEmitter(); - }, - spawn: (command: string, args: string[]) => { - spawnCalls.push({ command, args }); - const child = new EventEmitter() as { - stdout?: Readable; - stderr?: Readable; - on: (event: string, cb: (...args: unknown[]) => void) => void; - emit: (event: string, ...args: unknown[]) => boolean; - }; - child.stdout = new Readable({ read() {} }); - child.stderr = new Readable({ read() {} }); +async function spawnDockerProcess(commandAndArgs: string[]) { + const [command = "", ...args] = commandAndArgs; + spawnCalls.push({ command, args }); + const shouldFailContainerInspect = + command === "docker" && + args[0] === "inspect" && + args[1] === "-f" && + args[2] === "{{.State.Running}}"; + const code = command === "docker" && !shouldFailContainerInspect ? 0 : 1; + return { + failed: code !== 0, + isCanceled: false, + exitCode: code, + stdout: Buffer.alloc(0), + stderr: Buffer.from(code === 0 ? "" : "No such container"), + }; +} - const dockerArgs = command === "docker" ? args : []; - const shouldFailContainerInspect = - dockerArgs[0] === "inspect" && - dockerArgs[1] === "-f" && - dockerArgs[2] === "{{.State.Running}}"; - const shouldSucceedImageInspect = dockerArgs[0] === "image" && dockerArgs[1] === "inspect"; - - queueMicrotask(() => - child.emit("close", shouldFailContainerInspect && !shouldSucceedImageInspect ? 1 : 0), - ); - return child; - }, +vi.mock("../process/exec.js", async (importOriginal) => ({ + ...(await importOriginal()), + spawnCommand: spawnDockerProcess, })); vi.mock("../skills/loading/workspace.js", () => ({ @@ -67,15 +53,14 @@ async function resolveContext(config: OpenClawConfig, sessionKey: string, worksp function expectDockerSetupCommand(command: string) { // Setup commands are executed through docker exec in the resolved container. - expect( - spawnCalls.some( - (call) => - call.command === "docker" && - call.args[0] === "exec" && - call.args.includes("-lc") && - call.args.includes(command), - ), - ).toBe(true); + const matched = spawnCalls.some( + (call) => + call.command === "docker" && + call.args[0] === "exec" && + call.args.includes("-lc") && + call.args.includes(command), + ); + expect(matched, `expected docker setup command; calls=${JSON.stringify(spawnCalls)}`).toBe(true); } function createDefaultsSandboxConfig( @@ -257,31 +242,31 @@ describe("Agent-specific sandbox config", () => { expect(sandbox.mode).toBe("all"); }); - it("should resolve setupCommand overrides based on sandbox scope", async () => { - for (const scenario of [ - { - scope: "agent" as const, - expectedSetup: "echo work", - expectedContainerFragment: "agent-work", - }, - { - scope: "shared" as const, - expectedSetup: "echo global", - expectedContainerFragment: "shared", - }, - ]) { - const cfg = createWorkSetupCommandConfig(scenario.scope); + it.each([ + { + scope: "agent" as const, + expectedSetup: "echo work", + expectedContainerFragment: "agent-work", + }, + { + scope: "shared" as const, + expectedSetup: "echo global", + expectedContainerFragment: "shared", + }, + ])( + "should resolve $scope setupCommand overrides", + async ({ scope, expectedSetup, expectedContainerFragment }) => { + const cfg = createWorkSetupCommandConfig(scope); const context = await resolveContext(cfg, "agent:work:main", "/tmp/test-work"); if (!context) { - throw new Error(`Expected sandbox context for ${scenario.scope} scoped setup`); + throw new Error(`Expected sandbox context for ${scope} scoped setup`); } - expect(context.docker?.setupCommand).toBe(scenario.expectedSetup); - expect(context.containerName).toContain(scenario.expectedContainerFragment); - expectDockerSetupCommand(scenario.expectedSetup); - spawnCalls.length = 0; - } - }); + expect(context.docker?.setupCommand).toBe(expectedSetup); + expect(context.containerName).toContain(expectedContainerFragment); + expectDockerSetupCommand(expectedSetup); + }, + ); it("should allow agent-specific docker settings beyond setupCommand", () => { const cfg: OpenClawConfig = { diff --git a/test/e2e/qa-lab/runtime/agent-bundle-mcp-tools-docker-client.ts b/test/e2e/qa-lab/runtime/agent-bundle-mcp-tools-docker-client.ts index 4d0fba9bbcdd..7b0f193c734b 100644 --- a/test/e2e/qa-lab/runtime/agent-bundle-mcp-tools-docker-client.ts +++ b/test/e2e/qa-lab/runtime/agent-bundle-mcp-tools-docker-client.ts @@ -10,10 +10,8 @@ import { disposeAllSessionMcpRuntimes, getOrCreateSessionMcpRuntime, } from "../../../../dist/agents/agent-bundle-mcp-runtime.js"; -import { - applyFinalEffectiveToolPolicy, - resolveConversationCapabilityProfile, -} from "../../../../dist/agents/embedded-agent-runner/effective-tool-policy.js"; +import { resolveConversationCapabilityProfile } from "../../../../dist/agents/conversation-capability-profile.js"; +import { applyFinalEffectiveToolPolicy } from "../../../../dist/agents/embedded-agent-runner/effective-tool-policy.js"; import { splitSdkTools } from "../../../../dist/agents/embedded-agent-runner/tool-split.js"; import type { OpenClawConfig } from "../../../../dist/config/types.openclaw.js"; import { getPluginToolMeta } from "../../../../dist/plugins/tools.js"; diff --git a/tsdown.config.ts b/tsdown.config.ts index 221efd3e2886..e0cff8964696 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -311,10 +311,10 @@ function buildCoreDistEntries(): Record { function buildDockerE2eHarnessEntries(): Record { return { - // Mounted Docker harnesses run against the npm tarball image, so any - // internal module they assert must have a stable package dist entry. + // Mounted Docker harnesses need stable package dist entries for asserted internal modules. "agents/agent-bundle-mcp-materialize": "src/agents/agent-bundle-mcp-materialize.ts", "agents/agent-bundle-mcp-runtime": "src/agents/agent-bundle-mcp-runtime.ts", + "agents/conversation-capability-profile": "src/agents/conversation-capability-profile.ts", "agents/embedded-agent-runner/effective-tool-policy": "src/agents/embedded-agent-runner/effective-tool-policy.ts", "agents/embedded-agent-runner/tool-split": "src/agents/embedded-agent-runner/tool-split.ts",