mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 15:47:28 +00:00
chore: clean up root clutter
This commit is contained in:
2
.github/labeler.yml
vendored
2
.github/labeler.yml
vendored
@@ -195,7 +195,6 @@
|
|||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file:
|
- any-glob-to-any-file:
|
||||||
- "docs/**"
|
- "docs/**"
|
||||||
- "docs.acp.md"
|
|
||||||
|
|
||||||
"cli":
|
"cli":
|
||||||
- changed-files:
|
- changed-files:
|
||||||
@@ -222,6 +221,7 @@
|
|||||||
- "setup-podman.sh"
|
- "setup-podman.sh"
|
||||||
- ".dockerignore"
|
- ".dockerignore"
|
||||||
- "scripts/docker/setup.sh"
|
- "scripts/docker/setup.sh"
|
||||||
|
- "scripts/docker/sandbox/Dockerfile*"
|
||||||
- "scripts/podman/setup.sh"
|
- "scripts/podman/setup.sh"
|
||||||
- "scripts/**/*docker*"
|
- "scripts/**/*docker*"
|
||||||
- "scripts/**/Dockerfile*"
|
- "scripts/**/Dockerfile*"
|
||||||
|
|||||||
4
.github/workflows/labeler.yml
vendored
4
.github/workflows/labeler.yml
vendored
@@ -92,7 +92,7 @@ jobs:
|
|||||||
const excludedLockfiles = new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"]);
|
const excludedLockfiles = new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"]);
|
||||||
const totalChangedLines = files.reduce((total, file) => {
|
const totalChangedLines = files.reduce((total, file) => {
|
||||||
const path = file.filename ?? "";
|
const path = file.filename ?? "";
|
||||||
if (path === "docs.acp.md" || path.startsWith("docs/") || excludedLockfiles.has(path)) {
|
if (path.startsWith("docs/") || excludedLockfiles.has(path)) {
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
return total + (file.additions ?? 0) + (file.deletions ?? 0);
|
return total + (file.additions ?? 0) + (file.deletions ?? 0);
|
||||||
@@ -606,7 +606,7 @@ jobs:
|
|||||||
const excludedLockfiles = new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"]);
|
const excludedLockfiles = new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"]);
|
||||||
const totalChangedLines = files.reduce((total, file) => {
|
const totalChangedLines = files.reduce((total, file) => {
|
||||||
const path = file.filename ?? "";
|
const path = file.filename ?? "";
|
||||||
if (path === "docs.acp.md" || path.startsWith("docs/") || excludedLockfiles.has(path)) {
|
if (path.startsWith("docs/") || excludedLockfiles.has(path)) {
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
return total + (file.additions ?? 0) + (file.deletions ?? 0);
|
return total + (file.additions ?? 0) + (file.deletions ?? 0);
|
||||||
|
|||||||
8
.github/workflows/sandbox-common-smoke.yml
vendored
8
.github/workflows/sandbox-common-smoke.yml
vendored
@@ -4,14 +4,14 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- Dockerfile.sandbox
|
- scripts/docker/sandbox/Dockerfile
|
||||||
- Dockerfile.sandbox-common
|
- scripts/docker/sandbox/Dockerfile.common
|
||||||
- scripts/sandbox-common-setup.sh
|
- scripts/sandbox-common-setup.sh
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
|
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
|
||||||
paths:
|
paths:
|
||||||
- Dockerfile.sandbox
|
- scripts/docker/sandbox/Dockerfile
|
||||||
- Dockerfile.sandbox-common
|
- scripts/docker/sandbox/Dockerfile.common
|
||||||
- scripts/sandbox-common-setup.sh
|
- scripts/sandbox-common-setup.sh
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -14,7 +14,7 @@ coverage
|
|||||||
__openclaw_vitest__/
|
__openclaw_vitest__/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
.worktrees/
|
.worktrees/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -93,7 +93,7 @@ docs/internal/
|
|||||||
tmp/
|
tmp/
|
||||||
IDENTITY.md
|
IDENTITY.md
|
||||||
USER.md
|
USER.md
|
||||||
.tgz
|
*.tgz
|
||||||
.idea
|
.idea
|
||||||
|
|
||||||
# local tooling
|
# local tooling
|
||||||
@@ -187,6 +187,10 @@ changelog/fragments/
|
|||||||
.tmp/
|
.tmp/
|
||||||
.vmux*
|
.vmux*
|
||||||
.artifacts/
|
.artifacts/
|
||||||
|
.openclaw-config-doc-cache/
|
||||||
|
openclaw-path-alias-*/
|
||||||
|
/.pi/
|
||||||
|
/C:\\openclaw/
|
||||||
test/fixtures/openclaw-vitest-unit-report.json
|
test/fixtures/openclaw-vitest-unit-report.json
|
||||||
analysis/
|
analysis/
|
||||||
.artifacts/qa-e2e/
|
.artifacts/qa-e2e/
|
||||||
|
|||||||
244
docs.acp.md
244
docs.acp.md
@@ -1,244 +0,0 @@
|
|||||||
# OpenClaw ACP Bridge
|
|
||||||
|
|
||||||
This document describes how the OpenClaw ACP (Agent Client Protocol) bridge works,
|
|
||||||
how it maps ACP sessions to Gateway sessions, and how IDEs should invoke it.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
`openclaw acp` exposes an ACP agent over stdio and forwards prompts to a running
|
|
||||||
OpenClaw Gateway over WebSocket. It keeps ACP session ids mapped to Gateway
|
|
||||||
session keys so IDEs can reconnect to the same agent transcript or reset it on
|
|
||||||
request.
|
|
||||||
|
|
||||||
Key goals:
|
|
||||||
|
|
||||||
- Minimal ACP surface area (stdio, NDJSON).
|
|
||||||
- Stable session mapping across reconnects.
|
|
||||||
- Works with existing Gateway session store (list/resolve/reset).
|
|
||||||
- Safe defaults (isolated ACP session keys by default).
|
|
||||||
|
|
||||||
## Bridge Scope
|
|
||||||
|
|
||||||
`openclaw acp` is a Gateway-backed ACP bridge, not a full ACP-native editor
|
|
||||||
runtime. It is designed to route IDE prompts into an existing OpenClaw Gateway
|
|
||||||
session with predictable session mapping and basic streaming updates.
|
|
||||||
|
|
||||||
## Compatibility Matrix
|
|
||||||
|
|
||||||
| ACP area | Status | Notes |
|
|
||||||
| --------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. |
|
|
||||||
| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. |
|
|
||||||
| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key and replays stored user/assistant text history. Tool/system history is not reconstructed yet. |
|
|
||||||
| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. |
|
|
||||||
| Session modes | Partial | `session/set_mode` is supported and the bridge exposes initial Gateway-backed session controls for thought level, tool verbosity, reasoning, usage detail, and elevated actions. Broader ACP-native mode/config surfaces are still out of scope. |
|
|
||||||
| Session info and usage updates | Partial | The bridge emits `session_info_update` and best-effort `usage_update` notifications from cached Gateway session snapshots. Usage is approximate and only sent when Gateway token totals are marked fresh. |
|
|
||||||
| Tool streaming | Partial | `tool_call` / `tool_call_update` events include raw I/O, text content, and best-effort file locations when Gateway tool args/results expose them. Embedded terminals and richer diff-native output are still not exposed. |
|
|
||||||
| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. |
|
|
||||||
| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. |
|
|
||||||
| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. |
|
|
||||||
| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. |
|
|
||||||
|
|
||||||
## Known Limitations
|
|
||||||
|
|
||||||
- `loadSession` replays stored user and assistant text history, but it does not
|
|
||||||
reconstruct historic tool calls, system notices, or richer ACP-native event
|
|
||||||
types.
|
|
||||||
- If multiple ACP clients share the same Gateway session key, event and cancel
|
|
||||||
routing are best-effort rather than strictly isolated per client. Prefer the
|
|
||||||
default isolated `acp:<uuid>` sessions when you need clean editor-local
|
|
||||||
turns.
|
|
||||||
- Gateway stop states are translated into ACP stop reasons, but that mapping is
|
|
||||||
less expressive than a fully ACP-native runtime.
|
|
||||||
- Initial session controls currently surface a focused subset of Gateway knobs:
|
|
||||||
thought level, tool verbosity, reasoning, usage detail, and elevated
|
|
||||||
actions. Model selection and exec-host controls are not yet exposed as ACP
|
|
||||||
config options.
|
|
||||||
- `session_info_update` and `usage_update` are derived from Gateway session
|
|
||||||
snapshots, not live ACP-native runtime accounting. Usage is approximate,
|
|
||||||
carries no cost data, and is only emitted when the Gateway marks total token
|
|
||||||
data as fresh.
|
|
||||||
- Tool follow-along data is best-effort. The bridge can surface file paths that
|
|
||||||
appear in known tool args/results, but it does not yet emit ACP terminals or
|
|
||||||
structured file diffs.
|
|
||||||
|
|
||||||
## How can I use this
|
|
||||||
|
|
||||||
Use ACP when an IDE or tooling speaks Agent Client Protocol and you want it to
|
|
||||||
drive a OpenClaw Gateway session.
|
|
||||||
|
|
||||||
Quick steps:
|
|
||||||
|
|
||||||
1. Run a Gateway (local or remote).
|
|
||||||
2. Configure the Gateway target (`gateway.remote.url` + auth) or pass flags.
|
|
||||||
3. Point the IDE to run `openclaw acp` over stdio.
|
|
||||||
|
|
||||||
Example config:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
openclaw config set gateway.remote.url wss://gateway-host:18789
|
|
||||||
openclaw config set gateway.remote.token <token>
|
|
||||||
```
|
|
||||||
|
|
||||||
Example run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
openclaw acp --url wss://gateway-host:18789 --token <token>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Selecting agents
|
|
||||||
|
|
||||||
ACP does not pick agents directly. It routes by the Gateway session key.
|
|
||||||
|
|
||||||
Use agent-scoped session keys to target a specific agent:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
openclaw acp --session agent:main:main
|
|
||||||
openclaw acp --session agent:design:main
|
|
||||||
openclaw acp --session agent:qa:bug-123
|
|
||||||
```
|
|
||||||
|
|
||||||
Each ACP session maps to a single Gateway session key. One agent can have many
|
|
||||||
sessions; ACP defaults to an isolated `acp:<uuid>` session unless you override
|
|
||||||
the key or label.
|
|
||||||
|
|
||||||
## Zed editor setup
|
|
||||||
|
|
||||||
Add a custom ACP agent in `~/.config/zed/settings.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agent_servers": {
|
|
||||||
"OpenClaw ACP": {
|
|
||||||
"type": "custom",
|
|
||||||
"command": "openclaw",
|
|
||||||
"args": ["acp"],
|
|
||||||
"env": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
To target a specific Gateway or agent:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agent_servers": {
|
|
||||||
"OpenClaw ACP": {
|
|
||||||
"type": "custom",
|
|
||||||
"command": "openclaw",
|
|
||||||
"args": [
|
|
||||||
"acp",
|
|
||||||
"--url",
|
|
||||||
"wss://gateway-host:18789",
|
|
||||||
"--token",
|
|
||||||
"<token>",
|
|
||||||
"--session",
|
|
||||||
"agent:design:main"
|
|
||||||
],
|
|
||||||
"env": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
In Zed, open the Agent panel and select “OpenClaw ACP” to start a thread.
|
|
||||||
|
|
||||||
## Execution Model
|
|
||||||
|
|
||||||
- ACP client spawns `openclaw acp` and speaks ACP messages over stdio.
|
|
||||||
- The bridge connects to the Gateway using existing auth config (or CLI flags).
|
|
||||||
- ACP `prompt` translates to Gateway `chat.send`.
|
|
||||||
- Gateway streaming events are translated back into ACP streaming events.
|
|
||||||
- ACP `cancel` maps to Gateway `chat.abort` for the active run.
|
|
||||||
|
|
||||||
## Session Mapping
|
|
||||||
|
|
||||||
By default each ACP session is mapped to a dedicated Gateway session key:
|
|
||||||
|
|
||||||
- `acp:<uuid>` unless overridden.
|
|
||||||
|
|
||||||
You can override or reuse sessions in two ways:
|
|
||||||
|
|
||||||
1. CLI defaults
|
|
||||||
|
|
||||||
```bash
|
|
||||||
openclaw acp --session agent:main:main
|
|
||||||
openclaw acp --session-label "support inbox"
|
|
||||||
openclaw acp --reset-session
|
|
||||||
```
|
|
||||||
|
|
||||||
2. ACP metadata per session
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"_meta": {
|
|
||||||
"sessionKey": "agent:main:main",
|
|
||||||
"sessionLabel": "support inbox",
|
|
||||||
"resetSession": true,
|
|
||||||
"requireExisting": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- `sessionKey`: direct Gateway session key.
|
|
||||||
- `sessionLabel`: resolve an existing session by label.
|
|
||||||
- `resetSession`: mint a new transcript for the key before first use.
|
|
||||||
- `requireExisting`: fail if the key/label does not exist.
|
|
||||||
|
|
||||||
### Session Listing
|
|
||||||
|
|
||||||
ACP `listSessions` maps to Gateway `sessions.list` and returns a filtered
|
|
||||||
summary suitable for IDE session pickers. `_meta.limit` can cap the number of
|
|
||||||
sessions returned.
|
|
||||||
|
|
||||||
## Prompt Translation
|
|
||||||
|
|
||||||
ACP prompt inputs are converted into a Gateway `chat.send`:
|
|
||||||
|
|
||||||
- `text` and `resource` blocks become prompt text.
|
|
||||||
- `resource_link` with image mime types become attachments.
|
|
||||||
- The working directory can be prefixed into the prompt (default on, can be
|
|
||||||
disabled with `--no-prefix-cwd`).
|
|
||||||
|
|
||||||
Gateway streaming events are translated into ACP `message` and `tool_call`
|
|
||||||
updates. Terminal Gateway states map to ACP `done` with stop reasons:
|
|
||||||
|
|
||||||
- `complete` -> `stop`
|
|
||||||
- `aborted` -> `cancel`
|
|
||||||
- `error` -> `error`
|
|
||||||
|
|
||||||
## Auth + Gateway Discovery
|
|
||||||
|
|
||||||
`openclaw acp` resolves the Gateway URL and auth from CLI flags or config:
|
|
||||||
|
|
||||||
- `--url` / `--token` / `--password` take precedence.
|
|
||||||
- Otherwise use configured `gateway.remote.*` settings.
|
|
||||||
|
|
||||||
## Operational Notes
|
|
||||||
|
|
||||||
- ACP sessions are stored in memory for the bridge process lifetime.
|
|
||||||
- Gateway session state is persisted by the Gateway itself.
|
|
||||||
- `--verbose` logs ACP/Gateway bridge events to stderr (never stdout).
|
|
||||||
- ACP runs can be canceled and the active run id is tracked per session.
|
|
||||||
|
|
||||||
## Compatibility
|
|
||||||
|
|
||||||
- ACP bridge uses `@agentclientprotocol/sdk` (currently 0.15.x).
|
|
||||||
- Works with ACP clients that implement `initialize`, `newSession`,
|
|
||||||
`loadSession`, `prompt`, `cancel`, and `listSessions`.
|
|
||||||
- Bridge mode rejects per-session `mcpServers` instead of silently ignoring
|
|
||||||
them. Configure MCP at the Gateway or agent layer.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- Unit: `src/acp/session.test.ts` covers run id lifecycle.
|
|
||||||
- Full gate: `pnpm build && pnpm check && pnpm test && pnpm docs:build`.
|
|
||||||
|
|
||||||
## Related Docs
|
|
||||||
|
|
||||||
- CLI usage: `docs/cli/acp.md`
|
|
||||||
- Session model: `docs/concepts/session.md`
|
|
||||||
- Session management internals: `docs/reference/session-management-compaction.md`
|
|
||||||
@@ -409,7 +409,7 @@ If you installed OpenClaw via `npm install -g openclaw`, use the inline `docker
|
|||||||
scripts/sandbox-common-setup.sh
|
scripts/sandbox-common-setup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
From an npm install, build the default image first (see above), then build the common image on top using the [`Dockerfile.sandbox-common`](https://github.com/openclaw/openclaw/blob/main/Dockerfile.sandbox-common) from the repository.
|
From an npm install, build the default image first (see above), then build the common image on top using the [`scripts/docker/sandbox/Dockerfile.common`](https://github.com/openclaw/openclaw/blob/main/scripts/docker/sandbox/Dockerfile.common) from the repository.
|
||||||
|
|
||||||
Then set `agents.defaults.sandbox.docker.image` to `openclaw-sandbox-common:bookworm-slim`.
|
Then set `agents.defaults.sandbox.docker.image` to `openclaw-sandbox-common:bookworm-slim`.
|
||||||
|
|
||||||
@@ -421,7 +421,7 @@ If you installed OpenClaw via `npm install -g openclaw`, use the inline `docker
|
|||||||
scripts/sandbox-browser-setup.sh
|
scripts/sandbox-browser-setup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
From an npm install, build using the [`Dockerfile.sandbox-browser`](https://github.com/openclaw/openclaw/blob/main/Dockerfile.sandbox-browser) from the repository.
|
From an npm install, build using the [`scripts/docker/sandbox/Dockerfile.browser`](https://github.com/openclaw/openclaw/blob/main/scripts/docker/sandbox/Dockerfile.browser) from the repository.
|
||||||
|
|
||||||
</Step>
|
</Step>
|
||||||
</Steps>
|
</Steps>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const APP_PATH_RE = /^(?:apps\/|Swabble\/|appcast\.xml$)/u;
|
|||||||
const EXTENSION_PATH_RE = /^extensions\/[^/]+(?:\/|$)/u;
|
const EXTENSION_PATH_RE = /^extensions\/[^/]+(?:\/|$)/u;
|
||||||
const CORE_PATH_RE = /^(?:src\/|ui\/|packages\/)/u;
|
const CORE_PATH_RE = /^(?:src\/|ui\/|packages\/)/u;
|
||||||
const TOOLING_PATH_RE =
|
const TOOLING_PATH_RE =
|
||||||
/^(?:scripts\/|test\/vitest\/|\.github\/|git-hooks\/|vitest(?:\..+)?\.config\.ts$|tsconfig.*\.json$|\.gitignore$|\.oxlint.*|\.oxfmt.*)/u;
|
/^(?:scripts\/|test\/vitest\/|\.github\/|git-hooks\/|Dockerfile\.sandbox(?:-(?:browser|common))?$|vitest(?:\..+)?\.config\.ts$|tsconfig.*\.json$|\.gitignore$|\.oxlint.*|\.oxfmt.*)/u;
|
||||||
const ROOT_GLOBAL_PATH_RE =
|
const ROOT_GLOBAL_PATH_RE =
|
||||||
/^(?:package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|tsdown\.config\.ts$|vitest\.config\.ts$)/u;
|
/^(?:package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|tsdown\.config\.ts$|vitest\.config\.ts$)/u;
|
||||||
const LIVE_DOCKER_TOOLING_PATH_RE =
|
const LIVE_DOCKER_TOOLING_PATH_RE =
|
||||||
|
|||||||
@@ -576,15 +576,15 @@ if [[ -n "$SANDBOX_ENABLED" ]]; then
|
|||||||
echo ""
|
echo ""
|
||||||
echo "==> Sandbox setup"
|
echo "==> Sandbox setup"
|
||||||
|
|
||||||
# Build sandbox image if Dockerfile.sandbox exists.
|
sandbox_dockerfile="$ROOT_DIR/scripts/docker/sandbox/Dockerfile"
|
||||||
if [[ -f "$ROOT_DIR/Dockerfile.sandbox" ]]; then
|
if [[ -f "$sandbox_dockerfile" ]]; then
|
||||||
echo "Building sandbox image: openclaw-sandbox:bookworm-slim"
|
echo "Building sandbox image: openclaw-sandbox:bookworm-slim"
|
||||||
run_docker_build \
|
run_docker_build \
|
||||||
-t "openclaw-sandbox:bookworm-slim" \
|
-t "openclaw-sandbox:bookworm-slim" \
|
||||||
-f "$ROOT_DIR/Dockerfile.sandbox" \
|
-f "$sandbox_dockerfile" \
|
||||||
"$ROOT_DIR"
|
"$ROOT_DIR"
|
||||||
else
|
else
|
||||||
echo "WARNING: Dockerfile.sandbox not found in $ROOT_DIR" >&2
|
echo "WARNING: sandbox Dockerfile not found at $sandbox_dockerfile" >&2
|
||||||
echo " Sandbox config will be applied but no sandbox image will be built." >&2
|
echo " Sandbox config will be applied but no sandbox image will be built." >&2
|
||||||
echo " Agent exec may fail if the configured sandbox image does not exist." >&2
|
echo " Agent exec may fail if the configured sandbox image does not exist." >&2
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -6,5 +6,5 @@ source "$ROOT_DIR/scripts/lib/docker-build.sh"
|
|||||||
|
|
||||||
IMAGE_NAME="openclaw-sandbox-browser:bookworm-slim"
|
IMAGE_NAME="openclaw-sandbox-browser:bookworm-slim"
|
||||||
|
|
||||||
docker_build_exec -t "${IMAGE_NAME}" -f "$ROOT_DIR/Dockerfile.sandbox-browser" "$ROOT_DIR"
|
docker_build_exec -t "${IMAGE_NAME}" -f "$ROOT_DIR/scripts/docker/sandbox/Dockerfile.browser" "$ROOT_DIR"
|
||||||
echo "Built ${IMAGE_NAME}"
|
echo "Built ${IMAGE_NAME}"
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ echo "Building ${TARGET_IMAGE} with: ${PACKAGES}"
|
|||||||
|
|
||||||
docker_build_exec \
|
docker_build_exec \
|
||||||
-t "${TARGET_IMAGE}" \
|
-t "${TARGET_IMAGE}" \
|
||||||
-f "$ROOT_DIR/Dockerfile.sandbox-common" \
|
-f "$ROOT_DIR/scripts/docker/sandbox/Dockerfile.common" \
|
||||||
--build-arg BASE_IMAGE="${BASE_IMAGE}" \
|
--build-arg BASE_IMAGE="${BASE_IMAGE}" \
|
||||||
--build-arg PACKAGES="${PACKAGES}" \
|
--build-arg PACKAGES="${PACKAGES}" \
|
||||||
--build-arg INSTALL_PNPM="${INSTALL_PNPM}" \
|
--build-arg INSTALL_PNPM="${INSTALL_PNPM}" \
|
||||||
|
|||||||
@@ -6,5 +6,5 @@ source "$ROOT_DIR/scripts/lib/docker-build.sh"
|
|||||||
|
|
||||||
IMAGE_NAME="openclaw-sandbox:bookworm-slim"
|
IMAGE_NAME="openclaw-sandbox:bookworm-slim"
|
||||||
|
|
||||||
docker_build_exec -t "${IMAGE_NAME}" -f "$ROOT_DIR/Dockerfile.sandbox" "$ROOT_DIR"
|
docker_build_exec -t "${IMAGE_NAME}" -f "$ROOT_DIR/scripts/docker/sandbox/Dockerfile" "$ROOT_DIR"
|
||||||
echo "Built ${IMAGE_NAME}"
|
echo "Built ${IMAGE_NAME}"
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { beforeAll, describe, expect, it } from "vitest";
|
|||||||
const repoRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
|
const repoRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
|
||||||
const dockerfilePaths = [
|
const dockerfilePaths = [
|
||||||
"Dockerfile",
|
"Dockerfile",
|
||||||
"Dockerfile.sandbox",
|
"scripts/docker/sandbox/Dockerfile",
|
||||||
"Dockerfile.sandbox-browser",
|
"scripts/docker/sandbox/Dockerfile.browser",
|
||||||
"Dockerfile.sandbox-common",
|
"scripts/docker/sandbox/Dockerfile.common",
|
||||||
"scripts/docker/cleanup-smoke/Dockerfile",
|
"scripts/docker/cleanup-smoke/Dockerfile",
|
||||||
"scripts/docker/install-sh-smoke/Dockerfile",
|
"scripts/docker/install-sh-smoke/Dockerfile",
|
||||||
"scripts/docker/install-sh-e2e/Dockerfile",
|
"scripts/docker/install-sh-e2e/Dockerfile",
|
||||||
@@ -85,7 +85,7 @@ describe("docker build cache layout", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not leave empty shell continuation lines in sandbox-common", async () => {
|
it("does not leave empty shell continuation lines in sandbox-common", async () => {
|
||||||
const dockerfile = await readRepoFile("Dockerfile.sandbox-common");
|
const dockerfile = await readRepoFile("scripts/docker/sandbox/Dockerfile.common");
|
||||||
expect(dockerfile).not.toContain("apt-get install -y --no-install-recommends ${PACKAGES} \\");
|
expect(dockerfile).not.toContain("apt-get install -y --no-install-recommends ${PACKAGES} \\");
|
||||||
expect(dockerfile).toContain(
|
expect(dockerfile).toContain(
|
||||||
'RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi',
|
'RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi',
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ const repoRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
|
|||||||
|
|
||||||
const DIGEST_PINNED_DOCKERFILES = [
|
const DIGEST_PINNED_DOCKERFILES = [
|
||||||
"Dockerfile",
|
"Dockerfile",
|
||||||
"Dockerfile.sandbox",
|
"scripts/docker/sandbox/Dockerfile",
|
||||||
"Dockerfile.sandbox-browser",
|
"scripts/docker/sandbox/Dockerfile.browser",
|
||||||
"scripts/docker/cleanup-smoke/Dockerfile",
|
"scripts/docker/cleanup-smoke/Dockerfile",
|
||||||
"scripts/docker/install-sh-e2e/Dockerfile",
|
"scripts/docker/install-sh-e2e/Dockerfile",
|
||||||
"scripts/docker/install-sh-nonroot/Dockerfile",
|
"scripts/docker/install-sh-nonroot/Dockerfile",
|
||||||
|
|||||||
@@ -281,7 +281,11 @@ describe("scripts/docker/setup.sh", () => {
|
|||||||
|
|
||||||
it("forces BuildKit for local and sandbox docker builds", async () => {
|
it("forces BuildKit for local and sandbox docker builds", async () => {
|
||||||
const activeSandbox = requireSandbox(sandbox);
|
const activeSandbox = requireSandbox(sandbox);
|
||||||
await writeFile(join(activeSandbox.rootDir, "Dockerfile.sandbox"), "FROM scratch\n");
|
await mkdir(join(activeSandbox.rootDir, "scripts", "docker", "sandbox"), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(activeSandbox.rootDir, "scripts", "docker", "sandbox", "Dockerfile"),
|
||||||
|
"FROM scratch\n",
|
||||||
|
);
|
||||||
await resetDockerLog(activeSandbox);
|
await resetDockerLog(activeSandbox);
|
||||||
|
|
||||||
const result = runDockerSetup(activeSandbox, {
|
const result = runDockerSetup(activeSandbox, {
|
||||||
|
|||||||
73
src/test-helpers/temp-dir.test.ts
Normal file
73
src/test-helpers/temp-dir.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import fsSync from "node:fs";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { withTempDir, withTempDirSync } from "./temp-dir.js";
|
||||||
|
|
||||||
|
const parentRoots: string[] = [];
|
||||||
|
|
||||||
|
async function makeParentRoot(): Promise<string> {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-temp-dir-helper-test-"));
|
||||||
|
parentRoots.push(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
parentRoots.splice(0).map((root) =>
|
||||||
|
fs.rm(root, {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
maxRetries: 20,
|
||||||
|
retryDelay: 25,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("withTempDir", () => {
|
||||||
|
it("removes the cached async prefix root when the case finishes", async () => {
|
||||||
|
const parentDir = await makeParentRoot();
|
||||||
|
|
||||||
|
await withTempDir({ prefix: "openclaw-leak-check-", parentDir }, async (dir) => {
|
||||||
|
await fs.writeFile(path.join(dir, "marker.txt"), "ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(fs.readdir(parentDir)).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the cached async prefix root while another case is active", async () => {
|
||||||
|
const parentDir = await makeParentRoot();
|
||||||
|
let releaseFirst: (() => void) | undefined;
|
||||||
|
const firstCanFinish = new Promise<void>((resolve) => {
|
||||||
|
releaseFirst = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = withTempDir({ prefix: "openclaw-shared-root-", parentDir }, async (dir) => {
|
||||||
|
await fs.writeFile(path.join(dir, "first.txt"), "ok");
|
||||||
|
await firstCanFinish;
|
||||||
|
});
|
||||||
|
|
||||||
|
await withTempDir({ prefix: "openclaw-shared-root-", parentDir }, async (dir) => {
|
||||||
|
await fs.writeFile(path.join(dir, "second.txt"), "ok");
|
||||||
|
await expect(fs.readdir(parentDir)).resolves.toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(releaseFirst).toBeDefined();
|
||||||
|
releaseFirst?.();
|
||||||
|
await first;
|
||||||
|
|
||||||
|
await expect(fs.readdir(parentDir)).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the cached sync prefix root when the case finishes", async () => {
|
||||||
|
const parentDir = await makeParentRoot();
|
||||||
|
|
||||||
|
withTempDirSync({ prefix: "openclaw-leak-check-sync-", parentDir }, (dir) => {
|
||||||
|
fsSync.writeFileSync(path.join(dir, "marker.txt"), "ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(fs.readdir(parentDir)).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,9 +3,14 @@ import fs from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
const asyncPrefixRoots = new Map<string, string>();
|
type PrefixRootState = {
|
||||||
const pendingAsyncPrefixRoots = new Map<string, Promise<string>>();
|
path: string;
|
||||||
const syncPrefixRoots = new Map<string, string>();
|
activeCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const asyncPrefixRoots = new Map<string, PrefixRootState>();
|
||||||
|
const pendingAsyncPrefixRoots = new Map<string, Promise<PrefixRootState>>();
|
||||||
|
const syncPrefixRoots = new Map<string, PrefixRootState>();
|
||||||
let nextAsyncDirIndex = 0;
|
let nextAsyncDirIndex = 0;
|
||||||
let nextSyncDirIndex = 0;
|
let nextSyncDirIndex = 0;
|
||||||
|
|
||||||
@@ -13,39 +18,88 @@ function getRootKey(options: { prefix: string; parentDir?: string }): string {
|
|||||||
return `${options.parentDir ?? os.tmpdir()}\u0000${options.prefix}`;
|
return `${options.parentDir ?? os.tmpdir()}\u0000${options.prefix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureAsyncPrefixRoot(options: {
|
async function acquireAsyncPrefixRoot(options: {
|
||||||
prefix: string;
|
prefix: string;
|
||||||
parentDir?: string;
|
parentDir?: string;
|
||||||
}): Promise<string> {
|
}): Promise<PrefixRootState> {
|
||||||
const key = getRootKey(options);
|
const key = getRootKey(options);
|
||||||
const cached = asyncPrefixRoots.get(key);
|
const cached = asyncPrefixRoots.get(key);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
|
cached.activeCount += 1;
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
const pending = pendingAsyncPrefixRoots.get(key);
|
const pending = pendingAsyncPrefixRoots.get(key);
|
||||||
if (pending) {
|
if (pending) {
|
||||||
return await pending;
|
const state = await pending;
|
||||||
|
state.activeCount += 1;
|
||||||
|
return state;
|
||||||
}
|
}
|
||||||
const create = fs.mkdtemp(path.join(options.parentDir ?? os.tmpdir(), options.prefix));
|
const create = fs
|
||||||
|
.mkdtemp(path.join(options.parentDir ?? os.tmpdir(), options.prefix))
|
||||||
|
.then((root) => ({ path: root, activeCount: 0 }));
|
||||||
pendingAsyncPrefixRoots.set(key, create);
|
pendingAsyncPrefixRoots.set(key, create);
|
||||||
try {
|
try {
|
||||||
const root = await create;
|
const state = await create;
|
||||||
asyncPrefixRoots.set(key, root);
|
asyncPrefixRoots.set(key, state);
|
||||||
return root;
|
state.activeCount += 1;
|
||||||
|
return state;
|
||||||
} finally {
|
} finally {
|
||||||
pendingAsyncPrefixRoots.delete(key);
|
pendingAsyncPrefixRoots.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureSyncPrefixRoot(options: { prefix: string; parentDir?: string }): string {
|
function acquireSyncPrefixRoot(options: { prefix: string; parentDir?: string }): PrefixRootState {
|
||||||
const key = getRootKey(options);
|
const key = getRootKey(options);
|
||||||
const cached = syncPrefixRoots.get(key);
|
const cached = syncPrefixRoots.get(key);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
|
cached.activeCount += 1;
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
const root = fsSync.mkdtempSync(path.join(options.parentDir ?? os.tmpdir(), options.prefix));
|
const root = fsSync.mkdtempSync(path.join(options.parentDir ?? os.tmpdir(), options.prefix));
|
||||||
syncPrefixRoots.set(key, root);
|
const state = { path: root, activeCount: 1 };
|
||||||
return root;
|
syncPrefixRoots.set(key, state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function releaseAsyncPrefixRoot(options: {
|
||||||
|
prefix: string;
|
||||||
|
parentDir?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const key = getRootKey(options);
|
||||||
|
const state = asyncPrefixRoots.get(key);
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.activeCount -= 1;
|
||||||
|
if (state.activeCount > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
asyncPrefixRoots.delete(key);
|
||||||
|
await fs.rm(state.path, {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
maxRetries: 20,
|
||||||
|
retryDelay: 25,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseSyncPrefixRoot(options: { prefix: string; parentDir?: string }) {
|
||||||
|
const key = getRootKey(options);
|
||||||
|
const state = syncPrefixRoots.get(key);
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.activeCount -= 1;
|
||||||
|
if (state.activeCount > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
syncPrefixRoots.delete(key);
|
||||||
|
fsSync.rmSync(state.path, {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
maxRetries: 20,
|
||||||
|
retryDelay: 25,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function withTempDir<T>(
|
export async function withTempDir<T>(
|
||||||
@@ -56,15 +110,15 @@ export async function withTempDir<T>(
|
|||||||
},
|
},
|
||||||
run: (dir: string) => Promise<T>,
|
run: (dir: string) => Promise<T>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const root = await ensureAsyncPrefixRoot(options);
|
const root = await acquireAsyncPrefixRoot(options);
|
||||||
const base = path.join(root, `dir-${String(nextAsyncDirIndex)}`);
|
const base = path.join(root.path, `dir-${String(nextAsyncDirIndex)}`);
|
||||||
nextAsyncDirIndex += 1;
|
nextAsyncDirIndex += 1;
|
||||||
await fs.mkdir(base, { recursive: true });
|
|
||||||
const dir = options.subdir ? path.join(base, options.subdir) : base;
|
|
||||||
if (options.subdir) {
|
|
||||||
await fs.mkdir(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
|
await fs.mkdir(base, { recursive: true });
|
||||||
|
const dir = options.subdir ? path.join(base, options.subdir) : base;
|
||||||
|
if (options.subdir) {
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
}
|
||||||
return await run(dir);
|
return await run(dir);
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(base, {
|
await fs.rm(base, {
|
||||||
@@ -73,6 +127,7 @@ export async function withTempDir<T>(
|
|||||||
maxRetries: 20,
|
maxRetries: 20,
|
||||||
retryDelay: 25,
|
retryDelay: 25,
|
||||||
});
|
});
|
||||||
|
await releaseAsyncPrefixRoot(options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,15 +171,15 @@ export function withTempDirSync<T>(
|
|||||||
},
|
},
|
||||||
run: (dir: string) => T,
|
run: (dir: string) => T,
|
||||||
): T {
|
): T {
|
||||||
const root = ensureSyncPrefixRoot(options);
|
const root = acquireSyncPrefixRoot(options);
|
||||||
const base = path.join(root, `dir-${String(nextSyncDirIndex)}`);
|
const base = path.join(root.path, `dir-${String(nextSyncDirIndex)}`);
|
||||||
nextSyncDirIndex += 1;
|
nextSyncDirIndex += 1;
|
||||||
fsSync.mkdirSync(base, { recursive: true });
|
|
||||||
const dir = options.subdir ? path.join(base, options.subdir) : base;
|
|
||||||
if (options.subdir) {
|
|
||||||
fsSync.mkdirSync(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
|
fsSync.mkdirSync(base, { recursive: true });
|
||||||
|
const dir = options.subdir ? path.join(base, options.subdir) : base;
|
||||||
|
if (options.subdir) {
|
||||||
|
fsSync.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
return run(dir);
|
return run(dir);
|
||||||
} finally {
|
} finally {
|
||||||
fsSync.rmSync(base, {
|
fsSync.rmSync(base, {
|
||||||
@@ -133,5 +188,6 @@ export function withTempDirSync<T>(
|
|||||||
maxRetries: 20,
|
maxRetries: 20,
|
||||||
retryDelay: 25,
|
retryDelay: 25,
|
||||||
});
|
});
|
||||||
|
releaseSyncPrefixRoot(options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -320,6 +320,26 @@ describe("scripts/changed-lanes", () => {
|
|||||||
expect(plan.commands.map((command) => command.args[0])).not.toContain("test");
|
expect(plan.commands.map((command) => command.args[0])).not.toContain("test");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("routes legacy root sandbox Dockerfile moves to tooling instead of all lanes", () => {
|
||||||
|
const result = detectChangedLanes([
|
||||||
|
"Dockerfile.sandbox",
|
||||||
|
"Dockerfile.sandbox-browser",
|
||||||
|
"Dockerfile.sandbox-common",
|
||||||
|
"scripts/docker/sandbox/Dockerfile",
|
||||||
|
"scripts/docker/sandbox/Dockerfile.browser",
|
||||||
|
"scripts/docker/sandbox/Dockerfile.common",
|
||||||
|
]);
|
||||||
|
const plan = createChangedCheckPlan(result);
|
||||||
|
|
||||||
|
expect(result.lanes).toMatchObject({
|
||||||
|
tooling: true,
|
||||||
|
all: false,
|
||||||
|
});
|
||||||
|
expect(plan.commands.map((command) => command.args[0])).toContain("lint:scripts");
|
||||||
|
expect(plan.commands.map((command) => command.args[0])).not.toContain("tsgo:all");
|
||||||
|
expect(plan.commands.map((command) => command.args[0])).not.toContain("test");
|
||||||
|
});
|
||||||
|
|
||||||
it("routes live Docker ACP tooling changes through a focused gate", () => {
|
it("routes live Docker ACP tooling changes through a focused gate", () => {
|
||||||
const result = detectChangedLanes([
|
const result = detectChangedLanes([
|
||||||
"scripts/lib/live-docker-auth.sh",
|
"scripts/lib/live-docker-auth.sh",
|
||||||
|
|||||||
Reference in New Issue
Block a user