mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-21 18:30:15 +08:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6627d32e2 | |||
| 6227bd1a5b | |||
| 28032bc0ec | |||
| 3e3a2945a5 | |||
| f3fc18e56e | |||
| d85efea229 | |||
| c5b22cb308 | |||
| 1592369201 | |||
| 6420c0e254 | |||
| aef8d38b36 | |||
| 6d6f813deb | |||
| f9c86d0af2 | |||
| 9edb33dd3a | |||
| 8ba5261ae8 | |||
| 8c84dec8e9 | |||
| 092487d7ad | |||
| 87850aa7f8 | |||
| 526170fd47 | |||
| 2d9375531b | |||
| e734e5cddd | |||
| 728c6a641b | |||
| 8385236c30 |
@@ -28,6 +28,15 @@ Two phases: **Extract** (build the migration guide) and **Upgrade** (use it). If
|
||||
|
||||
---
|
||||
|
||||
# Phase 0: Refresh this skill first
|
||||
|
||||
The migration process itself evolves, so run its newest version before doing anything else:
|
||||
- Ensure the `upstream` remote exists (default `https://github.com/nanocoai/nanoclaw.git`) and fetch: `git fetch upstream --prune`. Detect the upstream branch (`main` or `master`).
|
||||
- Refresh this skill from upstream: `git checkout upstream/<branch> -- .claude/skills/migrate-nanoclaw/`
|
||||
- Re-read `.claude/skills/migrate-nanoclaw/SKILL.md`. If it changed, **follow the updated version from the top** instead of this one.
|
||||
|
||||
This is the only working-tree change expected before the preflight check below; changes limited to `.claude/skills/migrate-nanoclaw/` are this self-refresh — ignore them in the 1.0 clean-tree check and proceed.
|
||||
|
||||
# Phase 1: Extract
|
||||
|
||||
## 1.0 Preflight
|
||||
@@ -464,6 +473,11 @@ Point the branch at the upgraded state with `git reset --hard <upgrade-commit>`
|
||||
|
||||
Run `pnpm install && pnpm run build` in the main tree to confirm.
|
||||
|
||||
Stamp the upgrade marker (required — without it the startup tripwire stops the host on next start). Only do this after the build above succeeds:
|
||||
```bash
|
||||
pnpm exec tsx scripts/upgrade-state.ts set "" migrate-nanoclaw
|
||||
```
|
||||
|
||||
Restart the service. Service labels are per-install — derive them from `setup/lib/install-slug.sh`:
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
|
||||
@@ -60,11 +60,20 @@ Help a user with a customized NanoClaw install safely incorporate upstream chang
|
||||
- Default to MERGE (one-pass conflict resolution). Offer REBASE as an explicit option.
|
||||
- Keep token usage low: rely on `git status`, `git log`, `git diff`, and open only conflicted files.
|
||||
|
||||
# Step 0a: Refresh this skill first
|
||||
The update process itself evolves, so run its newest version before doing anything else:
|
||||
- Ensure the `upstream` remote exists (default `https://github.com/nanocoai/nanoclaw.git`) and fetch: `git fetch upstream --prune`. Detect the upstream branch (`main` or `master`).
|
||||
- Refresh this skill from upstream: `git checkout upstream/<branch> -- .claude/skills/update-nanoclaw/`
|
||||
- Re-read `.claude/skills/update-nanoclaw/SKILL.md`. If it changed, **follow the updated version from the top** instead of this one.
|
||||
|
||||
This is the only working-tree change expected before the preflight check; the full update commits it along with everything else.
|
||||
|
||||
# Step 0: Preflight (stop early if unsafe)
|
||||
Run:
|
||||
- `git status --porcelain`
|
||||
If output is non-empty:
|
||||
- Tell the user to commit or stash first, then stop.
|
||||
- Exception: changes limited to `.claude/skills/update-nanoclaw/` are the Step 0a self-refresh — ignore those and proceed.
|
||||
|
||||
Confirm remotes:
|
||||
- `git remote -v`
|
||||
@@ -256,6 +265,16 @@ If any channels/providers are installed AND `upstream/channels` or `upstream/pro
|
||||
|
||||
If no channels/providers are installed, skip silently.
|
||||
|
||||
Proceed to Step 7.9.
|
||||
|
||||
# Step 7.9: Stamp the upgrade marker (required)
|
||||
After validation has **succeeded**, record that this install reached the new version through the supported path. Without this, the startup tripwire stops the host on its next start.
|
||||
|
||||
- `pnpm exec tsx scripts/upgrade-state.ts set "" update-nanoclaw`
|
||||
- The empty version argument stamps the current `package.json` version.
|
||||
|
||||
If validation did NOT succeed, do not stamp — leave the tripwire to catch the broken state.
|
||||
|
||||
Proceed to Step 8.
|
||||
|
||||
# Step 8: Summary + rollback instructions
|
||||
|
||||
@@ -18,12 +18,20 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Bump patch version
|
||||
run: |
|
||||
# Skip the auto-bump when the pushed commits already changed the
|
||||
# version themselves (e.g. a release PR that set a minor/major).
|
||||
# Otherwise the bot would patch a deliberate 2.1.0 up to 2.1.1.
|
||||
if git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" | grep -qx 'package.json'; then
|
||||
echo "package.json already changed in this push; skipping auto-bump."
|
||||
exit 0
|
||||
fi
|
||||
pnpm version patch --no-git-tag-version
|
||||
git add package.json
|
||||
git diff --cached --quiet && exit 0
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
All notable changes to NanoClaw will be documented in this file.
|
||||
|
||||
## [2.1.0] - 2026-06-07
|
||||
|
||||
- [BREAKING] **Startup now requires an upgrade marker.** The host refuses to boot unless `data/upgrade-state.json` records that this install reached the current version through a sanctioned path (`/setup`, `/update-nanoclaw`, `/migrate-nanoclaw`). After this update completes — and before restarting the service — stamp the marker by running `pnpm exec tsx scripts/upgrade-state.ts set`. If the host has already tripped on restart with "update did not go through the supported path", that same command clears it. See [docs/upgrade-recovery.md](docs/upgrade-recovery.md).
|
||||
|
||||
## [2.0.64] - 2026-05-18
|
||||
|
||||
- **`ncl destinations add` and `remove` through the approval flow now reach the receiver immediately.** Approved destinations weren't being projected into the receiving agent's local session state, so a freshly-added destination silently failed at `send_message` with `unknown destination`, and a removed destination stayed resolvable until the next container restart. Both now take effect the moment the approval executes. Direct (non-approval) calls were unaffected.
|
||||
|
||||
@@ -19,7 +19,7 @@ ARG INSTALL_CJK_FONTS=false
|
||||
# Pin CLI versions for reproducibility. Bump deliberately — unpinned installs
|
||||
# mean every rebuild silently picks up the latest and can break in lockstep
|
||||
# across all users.
|
||||
ARG CLAUDE_CODE_VERSION=2.1.154
|
||||
ARG CLAUDE_CODE_VERSION=2.1.170
|
||||
ARG AGENT_BROWSER_VERSION=latest
|
||||
ARG VERCEL_VERSION=52.2.1
|
||||
ARG BUN_VERSION=1.3.12
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"": {
|
||||
"name": "nanoclaw-agent-runner",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.154",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.170",
|
||||
"@anthropic-ai/sdk": "^0.100.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"cron-parser": "^5.0.0",
|
||||
@@ -19,23 +19,23 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.154", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.154", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.154" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-iEn25urI2QrMPFIhId3h7v/7EG5gsmF7ooe+6EvsAosePeLmpVVerp5nXtHnlmBkMinLecurcPA+OddKw76jYw=="],
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.170", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.170", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.170" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-pAvhfk+iTodXZ6RF18Kz7BEUWFjL7EcR3tKuhUNdPpE1NAYCR3mSHGbafi72JsrNwKEDIs7FU31z3fqhwy8QzA=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.154", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oFW3LD5lYrKAU+AKu27Z8hrzqkrh362qQrwi/i3DxGcud9BXUycsXYjShpDj3D3JZu169UzZuSPhx1Wajmbiwg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.170", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rwfgArIa5WI0QPNqFsRBgvtSI0mrtpynUm0oK6+l6/KX4hcgnYGEzciZR1bOeD9/7sSZlTdIgt+T9alKeZmXcg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.154", "", { "os": "darwin", "cpu": "x64" }, "sha512-5BgWEueP+cqoctWjZYhCbyltuaV/N2DmKDXD3/69cKaVmJp8XL9OCzlq/HEirA/+Ssjskx6hDUBaOcpuZ3iwQA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.170", "", { "os": "darwin", "cpu": "x64" }, "sha512-0e58h8UQMtsQxLGIv9r4foxfBFWKZ7NeDtoplLhuD7EwQonehomw1sBXCch77t/IfUS+q5vQ5zv+fOGmap5nLQ=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.154", "", { "os": "linux", "cpu": "arm64" }, "sha512-rRkW4SBL3W7zQvKscCIfIGlmoeuTbMV6dXFbPdmpRGvmYZIs79RpzO6xrGBnnhmm+B7znQ9oHAnffi/2FBgJbA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.170", "", { "os": "linux", "cpu": "arm64" }, "sha512-gLbaFqcGppFJQd4DLNV4IXoeahejT/p2/M8bSSvRDbla9GOsBr1AxV5XLRyBn1e7xFGozZIAIQr3+1chp7NJgQ=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.154", "", { "os": "linux", "cpu": "arm64" }, "sha512-o2bCQN4Xn3UqCLErC5m4T7u0yYArJYmgFCUFnA6K96DdW2RERvx+gTKXxWuHEBkDO+eMoHLHLxk0u2jGES00Ng=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.170", "", { "os": "linux", "cpu": "arm64" }, "sha512-SRYfQcsXlOq+CD/FqkQBTSHbaD++w73GnnO+NUV9adLYrca3kfetRwWT1iguY1cNS0l34dCR3rlzCPq78vg1Jg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.154", "", { "os": "linux", "cpu": "x64" }, "sha512-GpiFF8Ez6PbM3m0gqtCo/FKM346qyRdP7VhbmJzdnbNKTiiUZ66vDQyEUPZPCG24ZkrG4m96KpRIUwY08rHiNg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.170", "", { "os": "linux", "cpu": "x64" }, "sha512-Xl/m7TaSC3T5IDBdHrZQ9fCQYyDmPELN34CL+MoyPIf7uSmuZnjE9fUOqDh2Rv26JxWssi1M6X+BBvVuKd6Cpg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.154", "", { "os": "linux", "cpu": "x64" }, "sha512-zA7S8Lm6O4QBsUpbhiOht8BgiXHOBBFUIo8ZLK6r5wAatK3Q44syWVxICeyCnR6wqfnkf3cugCw27ycS6vVgaA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.170", "", { "os": "linux", "cpu": "x64" }, "sha512-m4+I0qBEk7cxRKS+pL+eoWXbXTFOAo83fQ0tQvap4z/mDMm06IWJtEPoYTaMBwsp32GJWLkHWKbZSBCHZnp2DQ=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.154", "", { "os": "win32", "cpu": "arm64" }, "sha512-cDW1YFbU/PJFlrGXhlAGcbkXt80sEO6WtnH8nN8YHXLn5NWduy2q7o/qC6i8XozgvRGf6t/eMoH7IasGIEDhDw=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.170", "", { "os": "win32", "cpu": "arm64" }, "sha512-IG+8isJNNJKbnnhO7m+PGhfVCg+XoQ/MDxGde5eigFI0WsEfitjuWSWwx82bT9ghxI1aa6qNvI+UPgPcZuo5Fg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.154", "", { "os": "win32", "cpu": "x64" }, "sha512-tSKaIIpL72OPg3WfzZTCIl8OJgcbq4qieu8/fDWjsdeQuari9gQMIuEflFphk9HqNsxpSmDqKi8Sm5mW2V566Q=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.170", "", { "os": "win32", "cpu": "x64" }, "sha512-7cuqSKbHVItPGVwRbd3A0BEJwcNtc7Fhoh6qHN4C6yrmjSrvdYYx3MLvq/VI768/RoG7mAMDxb+j7WfEfoP9BA=="],
|
||||
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.100.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-cAm3aXm6qAiHIvHxyIIGd6tVmsD2gDqlc2h0R20ijNUzGgVnIN822bit4mKbF6CkuV7qIrLQIPoAepHEpanrQQ=="],
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.154",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.170",
|
||||
"@anthropic-ai/sdk": "^0.100.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"cron-parser": "^5.0.0",
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
* send_message(to="agent-name") since agents and channels share the
|
||||
* unified destinations namespace.
|
||||
*
|
||||
* create_agent is admin-only. Non-admin containers never see this tool
|
||||
* (see mcp-tools/index.ts). The host re-checks permission on receive.
|
||||
* create_agent writes central-DB state. The host authorizes it by CLI scope:
|
||||
* trusted owner agent groups (scope 'global') create directly; confined groups
|
||||
* require admin approval (see src/modules/agent-to-agent/create-agent.ts). This
|
||||
* tool just writes the outbound request; authorization is enforced host-side,
|
||||
* not here — the container is untrusted and cannot be relied on to gate itself.
|
||||
*/
|
||||
import { writeMessageOut } from '../db/messages-out.js';
|
||||
import { registerTools } from './server.js';
|
||||
@@ -32,7 +35,7 @@ export const createAgent: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'create_agent',
|
||||
description:
|
||||
'Create a long-lived companion sub-agent (research assistant, task manager, specialist) — the name becomes your destination for it. Admin-only. Fire-and-forget.',
|
||||
'Create a long-lived companion sub-agent (research assistant, task manager, specialist) — the name becomes your destination for it. May require admin approval before the agent is created. Fire-and-forget.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
|
||||
@@ -83,6 +83,48 @@ Each NanoClaw group gets its own OneCLI agent identity. This allows different cr
|
||||
- Any credentials matching blocked patterns
|
||||
- `.env` is shadowed with `/dev/null` in the project root mount
|
||||
|
||||
### 6. Egress Lockdown (Forced Proxy)
|
||||
|
||||
The `HTTPS_PROXY` env var only redirects *proxy-aware* clients — a tool that
|
||||
ignores it (or a raw socket) could reach the internet directly and bypass
|
||||
credential injection, approvals, and audit. Egress lockdown closes that hole at
|
||||
the network layer.
|
||||
|
||||
**How it works:** agents are placed on a Docker `--internal` network
|
||||
(`nanoclaw-egress`) that has **no route to the internet**. The OneCLI gateway
|
||||
container is attached to that network, aliased as `host.docker.internal`, so the
|
||||
injected proxy URL (`…@host.docker.internal:10255`) resolves to the gateway
|
||||
*container-to-container*. The gateway is therefore the **only reachable hop** —
|
||||
anything else has nowhere to go. The agent is non-root with no `NET_ADMIN`, so
|
||||
it cannot undo this. Identical mechanism on macOS and Linux (no host firewall,
|
||||
no `host-gateway` route).
|
||||
|
||||
- **Self-healing:** the gateway is re-attached to the network at every spawn and
|
||||
on each host-sweep tick, so an out-of-band detach (e.g. `docker compose up` on
|
||||
the OneCLI stack — its compose lives in `~/.onecli`, not this repo) recovers
|
||||
automatically.
|
||||
- **Fail-fast:** if lockdown is on but the network can't be created or the
|
||||
gateway can't be attached (e.g. a non-standard gateway container name, or the
|
||||
gateway isn't running), nanoclaw **refuses to spawn the agent** and surfaces a
|
||||
clear error — it never silently falls back to open egress. Fix the cause (or
|
||||
set `NANOCLAW_EGRESS_LOCKDOWN=false`) and retry. The host-sweep re-heal is the
|
||||
exception: a heal failure there is logged but not fatal, since already-running
|
||||
agents stay on the internal net (no leak) until the gateway returns.
|
||||
|
||||
**Configuration:**
|
||||
|
||||
| Env | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `NANOCLAW_EGRESS_LOCKDOWN` | `false` | Set `true` to opt in (otherwise the host-gateway path is used). Enabled automatically by `/add-golden-registry`. |
|
||||
| `NANOCLAW_EGRESS_NETWORK` | `nanoclaw-egress` | Network name. |
|
||||
| `ONECLI_GATEWAY_CONTAINER` | `onecli` | Gateway container to attach. |
|
||||
|
||||
**⚠ Behavior when enabled:** with lockdown on, agents have **no direct
|
||||
internet** — all traffic must go through OneCLI. Proxy-aware clients (npm, pnpm,
|
||||
pip, curl, node/bun with the proxy env) are unaffected. Any workflow that relies
|
||||
on a **non-proxy-aware** tool reaching the internet directly will fail by design.
|
||||
Lockdown is **off by default**; opt in with `NANOCLAW_EGRESS_LOCKDOWN=true`.
|
||||
|
||||
## Privilege Comparison
|
||||
|
||||
| Capability | Main Group | Non-Main Group |
|
||||
|
||||
@@ -53,6 +53,80 @@ Model selection considerations for Apple Silicon:
|
||||
|
||||
The agent uses tool calls extensively (read/write files, shell commands). Models that support tool use reliably work best. Gemma 4 and Qwen 3 Coder both handle structured tool calls well.
|
||||
|
||||
## Allowing Prompt Caching (filter the cache-busting hash)
|
||||
|
||||
Out of the box this path is slow — every reply re-reads the whole multi-thousand-token system prompt from scratch, even for a one-word answer. Ollama has a prompt cache that should skip that repeated work, but on this path it never kicks in.
|
||||
|
||||
**Cause.** The Claude Agent SDK adds a per-request hash to the front of every prompt — `x-anthropic-billing-header: ...; cch=<hash>;`. It changes on every request, and Ollama's cache only reuses a prompt whose start is unchanged. So that one shifting value at the front makes Ollama treat every prompt as new and re-read all of it. (Ollama ignores the hash itself, so filtering it has no effect on output.)
|
||||
|
||||
**Fix.** Run a tiny proxy between the container and Ollama that filters the hash out (pins `cch=<hash>` to a constant). The start of the prompt is now stable, so the cache kicks in and only the new message gets processed. In our setup — a 31B model on Apple Silicon — follow-up replies dropped from ~80s to ~4s; your numbers will vary with model size and hardware. Output is unchanged, since Ollama ignores the value anyway.
|
||||
|
||||
Point the agent group's `ANTHROPIC_BASE_URL` at the proxy instead of Ollama directly (everything else from the sections above is unchanged):
|
||||
|
||||
```
|
||||
ANTHROPIC_BASE_URL=http://host.docker.internal:11999 # the proxy
|
||||
# proxy forwards to http://127.0.0.1:11434 (Ollama)
|
||||
```
|
||||
|
||||
The proxy is ~40 lines of dependency-free Node:
|
||||
|
||||
```js
|
||||
// ollama-cch-proxy.mjs — normalize the SDK's per-request cch nonce so Ollama's
|
||||
// prefix cache survives across turns. Listens on :11999, forwards to Ollama.
|
||||
import http from 'node:http';
|
||||
|
||||
const TARGET_HOST = process.env.OLLAMA_HOST || '127.0.0.1';
|
||||
const TARGET_PORT = Number(process.env.OLLAMA_PORT || 11434);
|
||||
const LISTEN_PORT = Number(process.env.PROXY_PORT || 11999);
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const chunks = [];
|
||||
req.on('data', (c) => chunks.push(c));
|
||||
req.on('end', () => {
|
||||
let body = Buffer.concat(chunks);
|
||||
if (req.method === 'POST' && body.length) {
|
||||
body = Buffer.from(body.toString('utf8').replace(/cch=[0-9a-f]+;/g, 'cch=00000;'), 'utf8');
|
||||
}
|
||||
const headers = { ...req.headers, host: `${TARGET_HOST}:${TARGET_PORT}`, 'content-length': String(body.length) };
|
||||
const proxyReq = http.request(
|
||||
{ host: TARGET_HOST, port: TARGET_PORT, method: req.method, path: req.url, headers },
|
||||
(proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
|
||||
proxyRes.pipe(res);
|
||||
},
|
||||
);
|
||||
proxyReq.on('error', (e) => { res.writeHead(502); res.end(String(e)); });
|
||||
proxyReq.end(body);
|
||||
});
|
||||
});
|
||||
server.listen(LISTEN_PORT, '0.0.0.0', () => console.log(`cch-proxy :${LISTEN_PORT} -> ${TARGET_HOST}:${TARGET_PORT}`));
|
||||
```
|
||||
|
||||
Run it durably so it survives reboots. On Linux, a systemd user service:
|
||||
|
||||
```ini
|
||||
# ~/.config/systemd/user/ollama-cch-proxy.service
|
||||
[Unit]
|
||||
Description=Ollama cch-normalizing proxy for NanoClaw
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/node %h/.config/nanoclaw/ollama-cch-proxy.mjs
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
```bash
|
||||
systemctl --user enable --now ollama-cch-proxy
|
||||
loginctl enable-linger "$USER" # so it runs without an active login session
|
||||
```
|
||||
|
||||
On macOS use a `launchd` user agent (`~/Library/LaunchAgents/`) running the same script.
|
||||
|
||||
**Scope.** This only affects the Claude-Code-CLI → Ollama path described here. Codex and OpenCode don't use the Claude Agent SDK, so they never emit the `cch` hash and get prompt caching for free.
|
||||
|
||||
## What Changes at the Code Level
|
||||
|
||||
Three files need to support this feature. See `/add-ollama-provider` for the exact changes.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Recovering from the upgrade tripwire
|
||||
|
||||
If NanoClaw refuses to start with a message like *"update did not go through the supported path"*, this page explains what happened and how to clear it.
|
||||
|
||||
## What happened
|
||||
|
||||
NanoClaw records the version it reached each time you upgrade through a supported path — `/setup`, `/update-nanoclaw`, or `/migrate-nanoclaw`. That record lives in `data/upgrade-state.json`.
|
||||
|
||||
At startup the host checks that record against the running code. If it's **missing** or its version **doesn't match** the code, the host stops. This almost always means the code was updated by a raw `git pull` instead of the supported flow — so migrations, dependency installs, or container rebuilds that the flow would have run may not have happened.
|
||||
|
||||
## If you just ran a supported upgrade
|
||||
|
||||
If you reached this point by running `/update-nanoclaw`, `/migrate-nanoclaw`, or `/setup` and it **completed successfully**, this is expected the first time an existing install meets the tripwire (your previous version predated it). Clear it by stamping the current version:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/upgrade-state.ts set
|
||||
```
|
||||
|
||||
Then restart NanoClaw. It won't trip again.
|
||||
|
||||
**Coding agents:** if the upgrade you just ran reported success, run the command above automatically and restart. Only stop to ask a human if the upgrade itself failed or you can't confirm it finished.
|
||||
|
||||
## If you ran `git pull` yourself
|
||||
|
||||
Don't just clear the tripwire — that skips the work the supported flow does. Instead, run the supported upgrade so backups, migrations, and rebuilds happen:
|
||||
|
||||
```
|
||||
/update-nanoclaw
|
||||
```
|
||||
|
||||
Once it finishes it stamps the marker for you, and the next start is clean.
|
||||
|
||||
## If you have your own upgrade flow
|
||||
|
||||
If you've built your own way to upgrade — a custom skill, a deploy script, a CI job, a service that pulls and restarts — it won't stamp the marker, so the host will trip on the next start. Add the stamp as the **last step** of that flow, after the upgrade succeeds and before the restart:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/upgrade-state.ts set
|
||||
```
|
||||
|
||||
That's the same thing `/setup`, `/update-nanoclaw`, and `/migrate-nanoclaw` do at the end. Do it only when the upgrade actually completed — the marker is your assertion that this install reached the current version through a path you trust.
|
||||
|
||||
## The override
|
||||
|
||||
`pnpm exec tsx scripts/upgrade-state.ts set` is the override: it declares "this install is good at the current version." Use it when you know the install is actually in a good state (e.g. you completed the steps manually). It's safe to re-run.
|
||||
|
||||
To inspect the current marker:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/upgrade-state.ts get
|
||||
```
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.0.76",
|
||||
"version": "2.1.2",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="181k tokens, 91% of context window">
|
||||
<title>181k tokens, 91% of context window</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="183k tokens, 92% of context window">
|
||||
<title>183k tokens, 92% of context window</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,8 +15,8 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||
<text x="26" y="14">tokens</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">181k</text>
|
||||
<text x="71" y="14">181k</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">183k</text>
|
||||
<text x="71" y="14">183k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* scripts/upgrade-state.ts — read or stamp the upgrade marker.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec tsx scripts/upgrade-state.ts get
|
||||
* pnpm exec tsx scripts/upgrade-state.ts set [version] [via]
|
||||
*
|
||||
* `set` with no version stamps the current package.json version. The
|
||||
* sanctioned upgrade paths (setup / update / migrate) call `set` on
|
||||
* success; running it by hand is also the documented way to clear the
|
||||
* startup tripwire — see docs/upgrade-recovery.md.
|
||||
*/
|
||||
import { getCodeVersion, markerPath, readUpgradeState, writeUpgradeState } from '../src/upgrade-state.js';
|
||||
|
||||
const [, , cmd, versionArg, viaArg] = process.argv;
|
||||
|
||||
if (cmd === 'get') {
|
||||
const state = readUpgradeState();
|
||||
console.log(state ? JSON.stringify(state) : 'none');
|
||||
} else if (cmd === 'set') {
|
||||
const state = writeUpgradeState({ version: versionArg || getCodeVersion(), via: viaArg || 'manual' });
|
||||
console.log(`Stamped ${markerPath()}: ${JSON.stringify(state)}`);
|
||||
} else {
|
||||
console.error('Usage: pnpm exec tsx scripts/upgrade-state.ts get | set [version] [via]');
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import path from 'path';
|
||||
|
||||
import { log } from '../src/log.js';
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
||||
import { writeUpgradeState } from '../src/upgrade-state.js';
|
||||
import { cleanupUnhealthyPeers } from './peer-cleanup.js';
|
||||
import {
|
||||
commandExists,
|
||||
@@ -54,6 +55,11 @@ export async function run(_args: string[]): Promise<void> {
|
||||
|
||||
fs.mkdirSync(path.join(projectRoot, 'logs'), { recursive: true });
|
||||
|
||||
// Stamp the upgrade marker before the host first starts, so the startup
|
||||
// tripwire (enforceUpgradeTripwire) sees this as a sanctioned install.
|
||||
const stamped = writeUpgradeState({ via: 'setup' });
|
||||
log.info('Stamped upgrade marker', { version: stamped.version });
|
||||
|
||||
// Peer preflight — a crash-looping peer install (most often the legacy v1
|
||||
// `com.nanoclaw` plist) will keep trashing this install's containers on
|
||||
// every respawn via its own cleanupOrphans. Detect and unload any peer
|
||||
|
||||
@@ -23,6 +23,7 @@ import { materializeContainerJson } from './container-config.js';
|
||||
import { getContainerConfig } from './db/container-configs.js';
|
||||
import { updateContainerConfigScalars, updateContainerConfigJson } from './db/container-configs.js';
|
||||
import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js';
|
||||
import { EGRESS_NETWORK, egressNetworkArgs, ensureEgressNetwork } from './egress-lockdown.js';
|
||||
import { composeGroupClaudeMd } from './claude-md-compose.js';
|
||||
import { getAgentGroup } from './db/agent-groups.js';
|
||||
import { getDb, hasTable } from './db/connection.js';
|
||||
@@ -432,8 +433,14 @@ async function buildContainerArgs(
|
||||
}
|
||||
log.info('OneCLI gateway applied', { containerName });
|
||||
|
||||
// Host gateway
|
||||
args.push(...hostGatewayArgs());
|
||||
// Egress lockdown when enabled — throws if it can't be established, aborting
|
||||
// the spawn rather than running with open egress. Otherwise the host gateway.
|
||||
if (ensureEgressNetwork()) {
|
||||
args.push(...egressNetworkArgs());
|
||||
log.info('Egress lockdown active', { containerName, network: EGRESS_NETWORK });
|
||||
} else {
|
||||
args.push(...hostGatewayArgs());
|
||||
}
|
||||
|
||||
// User mapping
|
||||
const hostUid = process.getuid?.();
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Egress lockdown — force ALL agent traffic through the OneCLI gateway.
|
||||
* Agents run on a Docker `--internal` network (no internet route) with the
|
||||
* gateway attached as host.docker.internal, so the injected proxy is the only
|
||||
* reachable hop. Non-root, no NET_ADMIN — the agent can't undo it.
|
||||
*
|
||||
* Fail-fast: when the flag is on but the network/gateway can't be set up, throw
|
||||
* rather than silently spawn an agent with open egress.
|
||||
*/
|
||||
import { execFileSync } from 'child_process';
|
||||
|
||||
import { CONTAINER_RUNTIME_BIN } from './container-runtime.js';
|
||||
import { log } from './log.js';
|
||||
|
||||
/** Locked-down, no-internet network agents are placed on. */
|
||||
export const EGRESS_NETWORK = process.env.NANOCLAW_EGRESS_NETWORK || 'nanoclaw-egress';
|
||||
/** The OneCLI gateway container attached as the only egress hop. */
|
||||
const ONECLI_GATEWAY_CONTAINER = process.env.ONECLI_GATEWAY_CONTAINER || 'onecli';
|
||||
/** Off by default; set NANOCLAW_EGRESS_LOCKDOWN=true to opt in. */
|
||||
const EGRESS_LOCKDOWN = process.env.NANOCLAW_EGRESS_LOCKDOWN === 'true';
|
||||
|
||||
/** Raised when lockdown is requested but can't be established. */
|
||||
export class EgressLockdownError extends Error {
|
||||
constructor(reason: string) {
|
||||
super(
|
||||
`Egress lockdown is on (NANOCLAW_EGRESS_LOCKDOWN=true) but ${reason}. ` +
|
||||
`Refusing to spawn with open egress. Start the OneCLI gateway container ` +
|
||||
`"${ONECLI_GATEWAY_CONTAINER}", or set NANOCLAW_EGRESS_LOCKDOWN=false to opt out.`,
|
||||
);
|
||||
this.name = 'EgressLockdownError';
|
||||
}
|
||||
}
|
||||
|
||||
function dockerOk(args: string[]): boolean {
|
||||
try {
|
||||
execFileSync(CONTAINER_RUNTIME_BIN, args, { stdio: 'pipe', timeout: 15000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Is the OneCLI gateway currently attached to the egress network? */
|
||||
function gatewayAttached(): boolean {
|
||||
try {
|
||||
const out = execFileSync(
|
||||
CONTAINER_RUNTIME_BIN,
|
||||
['network', 'inspect', EGRESS_NETWORK, '--format', '{{range .Containers}}{{.Name}} {{end}}'],
|
||||
{ stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8', timeout: 15000 },
|
||||
);
|
||||
return out.split(/\s+/).includes(ONECLI_GATEWAY_CONTAINER);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the egress network exists with the OneCLI gateway attached (aliased
|
||||
* host.docker.internal). Idempotent + self-healing. Returns false when lockdown
|
||||
* is disabled (caller uses the host gateway), true when it's active. Throws
|
||||
* EgressLockdownError when enabled but unestablishable — fail fast rather than
|
||||
* spawn an agent with open egress.
|
||||
*/
|
||||
export function ensureEgressNetwork(): boolean {
|
||||
if (!EGRESS_LOCKDOWN) return false;
|
||||
|
||||
if (
|
||||
!dockerOk(['network', 'inspect', EGRESS_NETWORK]) &&
|
||||
!dockerOk(['network', 'create', '--internal', EGRESS_NETWORK])
|
||||
) {
|
||||
throw new EgressLockdownError(`the "${EGRESS_NETWORK}" internal network could not be created`);
|
||||
}
|
||||
|
||||
if (gatewayAttached()) return true;
|
||||
|
||||
if (
|
||||
dockerOk(['network', 'connect', '--alias', 'host.docker.internal', EGRESS_NETWORK, ONECLI_GATEWAY_CONTAINER]) &&
|
||||
gatewayAttached()
|
||||
) {
|
||||
log.info('Egress lockdown: OneCLI gateway attached', {
|
||||
network: EGRESS_NETWORK,
|
||||
gateway: ONECLI_GATEWAY_CONTAINER,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new EgressLockdownError(
|
||||
`the OneCLI gateway "${ONECLI_GATEWAY_CONTAINER}" could not be attached to "${EGRESS_NETWORK}"`,
|
||||
);
|
||||
}
|
||||
|
||||
/** CLI args placing a container on the locked-down egress network. */
|
||||
export function egressNetworkArgs(): string[] {
|
||||
return ['--network', EGRESS_NETWORK];
|
||||
}
|
||||
@@ -29,6 +29,7 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
|
||||
import { ensureEgressNetwork } from './egress-lockdown.js';
|
||||
import { getActiveSessions } from './db/sessions.js';
|
||||
import { getAgentGroup } from './db/agent-groups.js';
|
||||
import {
|
||||
@@ -132,6 +133,16 @@ export function stopHostSweep(): void {
|
||||
async function sweep(): Promise<void> {
|
||||
if (!running) return;
|
||||
|
||||
// Re-heal the egress network so already-running agents keep their gateway hop
|
||||
// if it was detached out-of-band. Best-effort here: a heal failure isn't a
|
||||
// leak (agents stay on the internal net), so log and continue. No-op when
|
||||
// lockdown is disabled.
|
||||
try {
|
||||
ensureEgressNetwork();
|
||||
} catch (err) {
|
||||
log.error('Egress lockdown re-heal failed', { err });
|
||||
}
|
||||
|
||||
try {
|
||||
const sessions = getActiveSessions();
|
||||
for (const session of sessions) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { startActiveDeliveryPoll, startSweepDeliveryPoll, setDeliveryAdapter, st
|
||||
import { startHostSweep, stopHostSweep } from './host-sweep.js';
|
||||
import { routeInbound } from './router.js';
|
||||
import { log } from './log.js';
|
||||
import { enforceUpgradeTripwire } from './upgrade-state.js';
|
||||
|
||||
// Response + shutdown registries live in response-registry.ts to break the
|
||||
// circular import cycle: src/index.ts imports src/modules/index.js for side
|
||||
@@ -69,6 +70,10 @@ async function main(): Promise<void> {
|
||||
// 0. Circuit breaker — backoff on rapid restarts
|
||||
await enforceStartupBackoff();
|
||||
|
||||
// 0.5 Upgrade tripwire — refuse to start if this install was updated
|
||||
// outside the sanctioned path (raw `git pull` instead of /update-nanoclaw).
|
||||
enforceUpgradeTripwire();
|
||||
|
||||
// 1. Init central DB
|
||||
const dbPath = path.join(DATA_DIR, 'v2.db');
|
||||
const db = initDb(dbPath);
|
||||
|
||||
@@ -443,4 +443,28 @@ describe('routeAgentMessage return-path', () => {
|
||||
expect(fs.existsSync(targetPath)).toBe(true);
|
||||
expect(fs.readFileSync(targetPath, 'utf-8')).toBe('fake-pdf-bytes');
|
||||
});
|
||||
|
||||
it('file forwarding: skips symlinked source files', async () => {
|
||||
const secretPath = path.join(TEST_DIR, 'host-secret.txt');
|
||||
fs.writeFileSync(secretPath, 'host-secret-bytes');
|
||||
|
||||
const outboxDir = path.join(sessionDir(A, S1.id), 'outbox', 'msg-with-symlink');
|
||||
fs.mkdirSync(outboxDir, { recursive: true });
|
||||
fs.symlinkSync(secretPath, path.join(outboxDir, 'safe-name.txt'));
|
||||
|
||||
await routeAgentMessage(
|
||||
{
|
||||
id: 'msg-with-symlink',
|
||||
platform_id: B,
|
||||
content: JSON.stringify({ text: 'see attached', files: ['safe-name.txt'] }),
|
||||
in_reply_to: null,
|
||||
},
|
||||
S1,
|
||||
);
|
||||
|
||||
const bRows = readInbound(B, SB.id);
|
||||
expect(bRows).toHaveLength(1);
|
||||
const parsed = JSON.parse(bRows[0].content);
|
||||
expect(parsed.attachments).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,11 @@ export interface ForwardedAttachment {
|
||||
localPath: string;
|
||||
}
|
||||
|
||||
function isPathInside(parent: string, child: string): boolean {
|
||||
const relative = path.relative(parent, child);
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy file attachments from the source agent's outbox into the target
|
||||
* agent's inbox. Returns attachments using the formatter's existing
|
||||
@@ -57,6 +62,11 @@ export function forwardAttachedFiles(
|
||||
): ForwardedAttachment[] {
|
||||
if (source.filenames.length === 0) return [];
|
||||
|
||||
if (!isSafeAttachmentName(source.messageId)) {
|
||||
log.warn('agent-route: rejecting unsafe source outbox message id', { sourceMsgId: source.messageId });
|
||||
return [];
|
||||
}
|
||||
|
||||
const sourceDir = path.join(sessionDir(source.agentGroupId, source.sessionId), 'outbox', source.messageId);
|
||||
if (!fs.existsSync(sourceDir)) {
|
||||
log.warn('agent-route: source outbox dir missing, no files forwarded', {
|
||||
@@ -66,6 +76,26 @@ export function forwardAttachedFiles(
|
||||
return [];
|
||||
}
|
||||
|
||||
let realSourceDir: string;
|
||||
try {
|
||||
const sourceDirStat = fs.lstatSync(sourceDir);
|
||||
if (!sourceDirStat.isDirectory() || sourceDirStat.isSymbolicLink()) {
|
||||
log.warn('agent-route: rejecting unsafe source outbox dir', {
|
||||
sourceMsgId: source.messageId,
|
||||
sourceDir,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
realSourceDir = fs.realpathSync(sourceDir);
|
||||
} catch (err) {
|
||||
log.warn('agent-route: failed to inspect source outbox dir', {
|
||||
sourceMsgId: source.messageId,
|
||||
sourceDir,
|
||||
err,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
const targetInboxDir = path.join(sessionDir(target.agentGroupId, target.sessionId), 'inbox', target.messageId);
|
||||
fs.mkdirSync(targetInboxDir, { recursive: true });
|
||||
|
||||
@@ -79,15 +109,33 @@ export function forwardAttachedFiles(
|
||||
continue;
|
||||
}
|
||||
const src = path.join(sourceDir, filename);
|
||||
if (!fs.existsSync(src)) {
|
||||
let realSrc: string;
|
||||
try {
|
||||
const srcStat = fs.lstatSync(src);
|
||||
if (!srcStat.isFile() || srcStat.isSymbolicLink()) {
|
||||
log.warn('agent-route: rejecting unsafe source outbox file', {
|
||||
sourceMsgId: source.messageId,
|
||||
filename,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
realSrc = fs.realpathSync(src);
|
||||
} catch {
|
||||
log.warn('agent-route: referenced file missing in source outbox, skipped', {
|
||||
sourceMsgId: source.messageId,
|
||||
filename,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!isPathInside(realSourceDir, realSrc)) {
|
||||
log.warn('agent-route: rejecting source file outside source outbox dir', {
|
||||
sourceMsgId: source.messageId,
|
||||
filename,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const dst = path.join(targetInboxDir, filename);
|
||||
fs.copyFileSync(src, dst);
|
||||
fs.copyFileSync(realSrc, dst);
|
||||
attachments.push({
|
||||
name: filename,
|
||||
filename,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Tests for create_agent host-side authorization.
|
||||
*
|
||||
* Regression guard for the audit finding: `create_agent` is a privileged
|
||||
* central-DB write with no host-side authz. The fix authorizes by CLI scope —
|
||||
* trusted owner agent groups ('global') create directly; confined groups
|
||||
* ('group', the default and the prompt-injection victim) must get admin
|
||||
* approval. These tests pin that branch decision.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Session } from '../../types.js';
|
||||
|
||||
// Mocks for the collaborators the branch decides between / depends on.
|
||||
const mockRequestApproval = vi.fn().mockResolvedValue(undefined);
|
||||
const mockGetContainerConfig = vi.fn();
|
||||
const mockCreateAgentGroup = vi.fn();
|
||||
const mockInitGroupFilesystem = vi.fn();
|
||||
const mockWriteDestinations = vi.fn();
|
||||
const mockNotifyWrite = vi.fn();
|
||||
|
||||
vi.mock('../approvals/index.js', () => ({
|
||||
requestApproval: (...a: unknown[]) => mockRequestApproval(...a),
|
||||
}));
|
||||
vi.mock('../../db/container-configs.js', () => ({
|
||||
getContainerConfig: (...a: unknown[]) => mockGetContainerConfig(...a),
|
||||
}));
|
||||
vi.mock('../../db/agent-groups.js', () => ({
|
||||
getAgentGroup: (id: string) => ({ id, name: id.toUpperCase(), folder: id, agent_provider: null, created_at: '' }),
|
||||
getAgentGroupByFolder: () => undefined,
|
||||
createAgentGroup: (...a: unknown[]) => mockCreateAgentGroup(...a),
|
||||
}));
|
||||
vi.mock('../../group-init.js', () => ({
|
||||
initGroupFilesystem: (...a: unknown[]) => mockInitGroupFilesystem(...a),
|
||||
}));
|
||||
vi.mock('./write-destinations.js', () => ({
|
||||
writeDestinations: (...a: unknown[]) => mockWriteDestinations(...a),
|
||||
}));
|
||||
vi.mock('./db/agent-destinations.js', () => ({
|
||||
getDestinationByName: () => undefined,
|
||||
createDestination: vi.fn(),
|
||||
normalizeName: (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||
}));
|
||||
// notifyAgent writes to the session inbound.db + wakes the container; stub both.
|
||||
vi.mock('../../session-manager.js', () => ({
|
||||
writeSessionMessage: (...a: unknown[]) => mockNotifyWrite(...a),
|
||||
}));
|
||||
vi.mock('../../container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock('../../db/sessions.js', () => ({
|
||||
getSession: (id: string) => ({ id, agent_group_id: 'ag-1' }),
|
||||
}));
|
||||
|
||||
import { handleCreateAgent } from './create-agent.js';
|
||||
|
||||
const SESSION = { id: 'sess-1', agent_group_id: 'ag-1' } as Session;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('handleCreateAgent — scope-based authorization', () => {
|
||||
it('global scope: creates directly, no approval requested', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
|
||||
|
||||
await handleCreateAgent({ name: 'Scout', instructions: 'help' }, SESSION);
|
||||
|
||||
expect(mockRequestApproval).not.toHaveBeenCalled();
|
||||
expect(mockCreateAgentGroup).toHaveBeenCalledTimes(1);
|
||||
expect(mockInitGroupFilesystem).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('group scope (default): requires approval, does NOT create directly', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
await handleCreateAgent({ name: 'Scout', instructions: 'help' }, SESSION);
|
||||
|
||||
expect(mockRequestApproval).toHaveBeenCalledTimes(1);
|
||||
expect(mockRequestApproval.mock.calls[0][0]).toMatchObject({ action: 'create_agent' });
|
||||
expect(mockCreateAgentGroup).not.toHaveBeenCalled();
|
||||
expect(mockInitGroupFilesystem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('missing config: fails closed to approval (no direct create)', async () => {
|
||||
mockGetContainerConfig.mockReturnValue(undefined);
|
||||
|
||||
await handleCreateAgent({ name: 'Scout' }, SESSION);
|
||||
|
||||
expect(mockRequestApproval).toHaveBeenCalledTimes(1);
|
||||
expect(mockCreateAgentGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disabled/other scope: requires approval', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'disabled' });
|
||||
|
||||
await handleCreateAgent({ name: 'Scout' }, SESSION);
|
||||
|
||||
expect(mockRequestApproval).toHaveBeenCalledTimes(1);
|
||||
expect(mockCreateAgentGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('empty name: neither creates nor requests approval', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
|
||||
|
||||
await handleCreateAgent({ name: '' }, SESSION);
|
||||
|
||||
expect(mockRequestApproval).not.toHaveBeenCalled();
|
||||
expect(mockCreateAgentGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,29 @@
|
||||
/**
|
||||
* `create_agent` delivery-action handler.
|
||||
*
|
||||
* Spawns a new agent group on demand from the parent agent, wires bidirectional
|
||||
* agent_destinations rows, projects the new destination into the parent's
|
||||
* running container, and notifies the parent.
|
||||
* SECURITY: `create_agent` writes to the CENTRAL DB (agent_groups,
|
||||
* container_configs, agent_destinations) and scaffolds host filesystem state —
|
||||
* a privileged operation a confined container is otherwise architecturally
|
||||
* barred from. The container's MCP tool gate is inside the (untrusted)
|
||||
* container and is trivially bypassed by writing the outbound system row
|
||||
* directly, so authorization MUST be enforced host-side. Trusted owner agent
|
||||
* groups (CLI scope 'global') create directly; every other (confined) group
|
||||
* requires admin approval via `requestApproval` — matching `ncl groups create`
|
||||
* (access: 'approval') and the self-mod actions. `applyCreateAgent` runs the
|
||||
* creation on approve; `performCreateAgent` is the shared body.
|
||||
*/
|
||||
import path from 'path';
|
||||
|
||||
import { GROUPS_DIR } from '../../config.js';
|
||||
import { createAgentGroup, getAgentGroup, getAgentGroupByFolder } from '../../db/agent-groups.js';
|
||||
import { getContainerConfig } from '../../db/container-configs.js';
|
||||
import { getSession } from '../../db/sessions.js';
|
||||
import { wakeContainer } from '../../container-runner.js';
|
||||
import { initGroupFilesystem } from '../../group-init.js';
|
||||
import { log } from '../../log.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
import type { AgentGroup, Session } from '../../types.js';
|
||||
import { requestApproval, type ApprovalHandler } from '../approvals/index.js';
|
||||
import { createDestination, getDestinationByName, normalizeName } from './db/agent-destinations.js';
|
||||
import { writeDestinations } from './write-destinations.js';
|
||||
|
||||
@@ -34,23 +43,95 @@ function notifyAgent(session: Session, text: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivery-action entry.
|
||||
*
|
||||
* Authorization depends on the calling group's CLI scope:
|
||||
* - `global` (set by init-first-agent for trusted owner agent groups):
|
||||
* create immediately. create_agent is the intended primitive for these
|
||||
* privileged agents, and an approval tap on every sub-agent spawn would be
|
||||
* needless friction.
|
||||
* - anything else (the default `group` scope — the realistic
|
||||
* prompt-injection victim): require an admin to approve before any
|
||||
* central-DB write. `applyCreateAgent` runs on approve.
|
||||
* Unknown/missing config fails closed to the approval path.
|
||||
*/
|
||||
export async function handleCreateAgent(content: Record<string, unknown>, session: Session): Promise<void> {
|
||||
const requestId = content.requestId as string;
|
||||
const name = content.name as string;
|
||||
const instructions = content.instructions as string | null;
|
||||
const name = typeof content.name === 'string' ? content.name : '';
|
||||
const instructions = typeof content.instructions === 'string' ? content.instructions : null;
|
||||
|
||||
if (!name) {
|
||||
notifyAgent(session, 'create_agent failed: name is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!sourceGroup) {
|
||||
notifyAgent(session, `create_agent failed: source agent group not found.`);
|
||||
notifyAgent(session, 'create_agent failed: source agent group not found.');
|
||||
log.warn('create_agent failed: missing source group', { sessionAgentGroup: session.agent_group_id, name });
|
||||
return;
|
||||
}
|
||||
|
||||
const cliScope = getContainerConfig(session.agent_group_id)?.cli_scope ?? 'group';
|
||||
if (cliScope === 'global') {
|
||||
// Trusted owner agent group — create directly, then notify (+wake) it.
|
||||
await performCreateAgent(name, instructions, session, sourceGroup, (text) => notifyAgent(session, text));
|
||||
return;
|
||||
}
|
||||
|
||||
await requestApproval({
|
||||
session,
|
||||
agentName: sourceGroup.name,
|
||||
action: 'create_agent',
|
||||
payload: { name, instructions },
|
||||
title: `Create agent: ${name}`,
|
||||
question: `Agent "${sourceGroup.name}" wants to create a new sub-agent "${name}" (a new agent group with its own workspace and container). Approve?`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Approval handler: performs the creation once an admin approves a request from
|
||||
* a confined (non-global) agent group. `session` is the requesting parent.
|
||||
*/
|
||||
export const applyCreateAgent: ApprovalHandler = async ({ session, payload, notify }) => {
|
||||
const name = typeof payload.name === 'string' ? payload.name : '';
|
||||
const instructions = typeof payload.instructions === 'string' ? payload.instructions : null;
|
||||
|
||||
if (!name) {
|
||||
notify('create_agent approved but the request had no name.');
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!sourceGroup) {
|
||||
notify('create_agent approved but the source agent group no longer exists.');
|
||||
log.warn('create_agent apply failed: missing source group', { sessionAgentGroup: session.agent_group_id, name });
|
||||
return;
|
||||
}
|
||||
|
||||
await performCreateAgent(name, instructions, session, sourceGroup, notify);
|
||||
};
|
||||
|
||||
/**
|
||||
* Core creation: writes the new agent group + bidirectional destinations and
|
||||
* scaffolds its filesystem, then reports via `notify`. Authorization is the
|
||||
* CALLER's responsibility (the global-scope shortcut in handleCreateAgent or
|
||||
* admin approval via applyCreateAgent) — never call this from an unauthorized
|
||||
* path, as it performs privileged central-DB writes a confined container is
|
||||
* otherwise barred from.
|
||||
*/
|
||||
async function performCreateAgent(
|
||||
name: string,
|
||||
instructions: string | null,
|
||||
session: Session,
|
||||
sourceGroup: AgentGroup,
|
||||
notify: (text: string) => void,
|
||||
): Promise<void> {
|
||||
const localName = normalizeName(name);
|
||||
|
||||
// Collision in the creator's destination namespace
|
||||
if (getDestinationByName(sourceGroup.id, localName)) {
|
||||
notifyAgent(session, `Cannot create agent "${name}": you already have a destination named "${localName}".`);
|
||||
notify(`Cannot create agent "${name}": you already have a destination named "${localName}".`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -66,7 +147,7 @@ export async function handleCreateAgent(content: Record<string, unknown>, sessio
|
||||
const resolvedPath = path.resolve(groupPath);
|
||||
const resolvedGroupsDir = path.resolve(GROUPS_DIR);
|
||||
if (!resolvedPath.startsWith(resolvedGroupsDir + path.sep)) {
|
||||
notifyAgent(session, `Cannot create agent "${name}": invalid folder path.`);
|
||||
notify(`Cannot create agent "${name}": invalid folder path.`);
|
||||
log.error('create_agent path traversal attempt', { folder, resolvedPath });
|
||||
return;
|
||||
}
|
||||
@@ -115,12 +196,6 @@ export async function handleCreateAgent(content: Record<string, unknown>, sessio
|
||||
// tries to send to the newly-created child.
|
||||
writeDestinations(session.agent_group_id, session.id);
|
||||
|
||||
// Fire-and-forget notification back to the creator
|
||||
notifyAgent(
|
||||
session,
|
||||
`Agent "${localName}" created. You can now message it with <message to="${localName}">...</message>.`,
|
||||
);
|
||||
notify(`Agent "${localName}" created. You can now message it with <message to="${localName}">...</message>.`);
|
||||
log.info('Agent group created', { agentGroupId, name, localName, folder, parent: sourceGroup.id });
|
||||
// Note: requestId is unused — this is fire-and-forget, not request/response.
|
||||
void requestId;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
/**
|
||||
* Agent-to-agent module — inter-agent messaging and on-demand agent creation.
|
||||
*
|
||||
* Registers one delivery action (`create_agent`). The sibling `channel_type === 'agent'`
|
||||
* routing path is NOT a system action — core `delivery.ts` dispatches into
|
||||
* `./agent-route.js` via a dynamic import when it sees `msg.channel_type === 'agent'`.
|
||||
* Registers one delivery action (`create_agent`) plus its matching approval
|
||||
* handler — `create_agent` writes central-DB state, so confined (non-global)
|
||||
* groups require admin approval (the delivery action queues the request;
|
||||
* `applyCreateAgent` runs on approve); trusted global-scope groups create
|
||||
* directly. The sibling `channel_type === 'agent'` routing path is NOT a system
|
||||
* action — core `delivery.ts` dispatches into `./agent-route.js` via a dynamic
|
||||
* import when it sees `msg.channel_type === 'agent'`.
|
||||
*
|
||||
* Host integration points:
|
||||
* - `src/container-runner.ts::spawnContainer` dynamically imports
|
||||
@@ -17,6 +21,8 @@
|
||||
* throw because the module isn't installed.
|
||||
*/
|
||||
import { registerDeliveryAction } from '../../delivery.js';
|
||||
import { handleCreateAgent } from './create-agent.js';
|
||||
import { registerApprovalHandler } from '../approvals/index.js';
|
||||
import { applyCreateAgent, handleCreateAgent } from './create-agent.js';
|
||||
|
||||
registerDeliveryAction('create_agent', handleCreateAgent);
|
||||
registerApprovalHandler('create_agent', applyCreateAgent);
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Regression coverage for approval response authorization.
|
||||
*
|
||||
* Approval cards may be delivered to an admin DM, but the callback payload is
|
||||
* still untrusted input. The response handler must not dispatch sensitive
|
||||
* approval handlers merely because a response carries a valid questionId.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { initTestDb, closeDb, runMigrations } from '../../db/index.js';
|
||||
import { createAgentGroup } from '../../db/agent-groups.js';
|
||||
import { createSession, createPendingApproval, getPendingApproval } from '../../db/sessions.js';
|
||||
import { upsertUser } from '../permissions/db/users.js';
|
||||
import { grantRole } from '../permissions/db/user-roles.js';
|
||||
|
||||
vi.mock('../../container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual('../../config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-approval-response-authz' };
|
||||
});
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-approval-response-authz';
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
|
||||
createAgentGroup({ id: 'ag-1', name: 'Agent', folder: 'agent', agent_provider: null, created_at: now() });
|
||||
createSession({
|
||||
id: 'sess-1',
|
||||
agent_group_id: 'ag-1',
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: now(),
|
||||
created_at: now(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('approval response authorization', () => {
|
||||
it('ignores a valid approval id clicked by a non-admin user', async () => {
|
||||
const { registerApprovalHandler } = await import('./primitive.js');
|
||||
const { handleApprovalsResponse } = await import('./response-handler.js');
|
||||
const handler = vi.fn().mockResolvedValue(undefined);
|
||||
registerApprovalHandler('install_packages', handler);
|
||||
|
||||
createPendingApproval({
|
||||
approval_id: 'appr-1',
|
||||
session_id: 'sess-1',
|
||||
request_id: 'appr-1',
|
||||
action: 'install_packages',
|
||||
payload: JSON.stringify({ packages: ['left-pad'] }),
|
||||
created_at: now(),
|
||||
title: 'Install packages',
|
||||
options_json: JSON.stringify([]),
|
||||
});
|
||||
|
||||
const claimed = await handleApprovalsResponse({
|
||||
questionId: 'appr-1',
|
||||
value: 'approve',
|
||||
userId: 'stranger',
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-stranger',
|
||||
threadId: null,
|
||||
});
|
||||
|
||||
expect(claimed).toBe(true);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(getPendingApproval('appr-1')).toBeDefined();
|
||||
});
|
||||
|
||||
it('allows an owner/admin click to dispatch the registered approval handler', async () => {
|
||||
upsertUser({ id: 'telegram:owner', kind: 'telegram', display_name: 'Owner', created_at: now() });
|
||||
grantRole({ user_id: 'telegram:owner', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() });
|
||||
|
||||
const { registerApprovalHandler } = await import('./primitive.js');
|
||||
const { handleApprovalsResponse } = await import('./response-handler.js');
|
||||
const handler = vi.fn().mockResolvedValue(undefined);
|
||||
registerApprovalHandler('install_packages_allowed', handler);
|
||||
|
||||
createPendingApproval({
|
||||
approval_id: 'appr-2',
|
||||
session_id: 'sess-1',
|
||||
request_id: 'appr-2',
|
||||
action: 'install_packages_allowed',
|
||||
payload: JSON.stringify({ packages: ['left-pad'] }),
|
||||
created_at: now(),
|
||||
title: 'Install packages',
|
||||
options_json: JSON.stringify([]),
|
||||
});
|
||||
|
||||
const claimed = await handleApprovalsResponse({
|
||||
questionId: 'appr-2',
|
||||
value: 'approve',
|
||||
userId: 'owner',
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-owner',
|
||||
threadId: null,
|
||||
});
|
||||
|
||||
expect(claimed).toBe(true);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ userId: 'telegram:owner' }));
|
||||
expect(getPendingApproval('appr-2')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows global admins to resolve approvals without a session-scoped agent group', async () => {
|
||||
upsertUser({ id: 'telegram:global-admin', kind: 'telegram', display_name: 'Global Admin', created_at: now() });
|
||||
grantRole({
|
||||
user_id: 'telegram:global-admin',
|
||||
role: 'admin',
|
||||
agent_group_id: null,
|
||||
granted_by: null,
|
||||
granted_at: now(),
|
||||
});
|
||||
|
||||
const { registerApprovalHandler } = await import('./primitive.js');
|
||||
const { handleApprovalsResponse } = await import('./response-handler.js');
|
||||
const handler = vi.fn().mockResolvedValue(undefined);
|
||||
registerApprovalHandler('global_admin_allowed', handler);
|
||||
|
||||
createPendingApproval({
|
||||
approval_id: 'appr-3',
|
||||
session_id: 'sess-1',
|
||||
agent_group_id: null,
|
||||
request_id: 'appr-3',
|
||||
action: 'global_admin_allowed',
|
||||
payload: JSON.stringify({ packages: ['left-pad'] }),
|
||||
created_at: now(),
|
||||
title: 'Install packages',
|
||||
options_json: JSON.stringify([]),
|
||||
});
|
||||
|
||||
const claimed = await handleApprovalsResponse({
|
||||
questionId: 'appr-3',
|
||||
value: 'approve',
|
||||
userId: 'global-admin',
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-global-admin',
|
||||
threadId: null,
|
||||
});
|
||||
|
||||
expect(claimed).toBe(true);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(getPendingApproval('appr-3')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -18,27 +18,35 @@ import type { ResponsePayload } from '../../response-registry.js';
|
||||
import { log } from '../../log.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
import type { PendingApproval } from '../../types.js';
|
||||
import { hasAdminPrivilege, isGlobalAdmin, isOwner } from '../permissions/db/user-roles.js';
|
||||
import { ONECLI_ACTION, resolveOneCLIApproval } from './onecli-approvals.js';
|
||||
import { getApprovalHandler } from './primitive.js';
|
||||
|
||||
export async function handleApprovalsResponse(payload: ResponsePayload): Promise<boolean> {
|
||||
// OneCLI credential approvals — resolved via in-memory Promise first.
|
||||
if (resolveOneCLIApproval(payload.questionId, payload.value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// DB-backed pending_approvals.
|
||||
const approval = getPendingApproval(payload.questionId);
|
||||
if (!approval) return false;
|
||||
|
||||
if (!isAuthorizedApprovalClick(approval, payload)) {
|
||||
log.warn('Ignoring unauthorized approval response', {
|
||||
approvalId: approval.approval_id,
|
||||
action: approval.action,
|
||||
userId: payload.userId,
|
||||
channelType: payload.channelType,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (approval.action === ONECLI_ACTION) {
|
||||
if (resolveOneCLIApproval(payload.questionId, payload.value)) {
|
||||
return true;
|
||||
}
|
||||
// Row exists but the in-memory resolver is gone (timer fired or the process
|
||||
// was in a weird state). Nothing to do — just drop the row.
|
||||
deletePendingApproval(payload.questionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
await handleRegisteredApproval(approval, payload.value, payload.userId ?? '');
|
||||
await handleRegisteredApproval(approval, payload.value, namespacedUserId(payload) ?? '');
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -104,3 +112,22 @@ async function handleRegisteredApproval(
|
||||
deletePendingApproval(approval.approval_id);
|
||||
await wakeContainer(session);
|
||||
}
|
||||
|
||||
function namespacedUserId(payload: ResponsePayload): string | null {
|
||||
if (!payload.userId) return null;
|
||||
return payload.userId.includes(':') ? payload.userId : `${payload.channelType}:${payload.userId}`;
|
||||
}
|
||||
|
||||
function isAuthorizedApprovalClick(approval: PendingApproval, payload: ResponsePayload): boolean {
|
||||
const userId = namespacedUserId(payload);
|
||||
if (!userId) return false;
|
||||
|
||||
const agentGroupId =
|
||||
approval.agent_group_id ?? (approval.session_id ? getSession(approval.session_id)?.agent_group_id : null);
|
||||
|
||||
if (!agentGroupId) {
|
||||
return isOwner(userId) || isGlobalAdmin(userId);
|
||||
}
|
||||
|
||||
return hasAdminPrivilege(userId, agentGroupId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('./config.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('./config.js')>('./config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-upgrade-state' };
|
||||
});
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-upgrade-state';
|
||||
|
||||
import {
|
||||
enforceUpgradeTripwire,
|
||||
getCodeVersion,
|
||||
isUpgradeCurrent,
|
||||
markerPath,
|
||||
readUpgradeState,
|
||||
writeUpgradeState,
|
||||
} from './upgrade-state.js';
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('upgrade-state', () => {
|
||||
it('getCodeVersion reads the package.json version', () => {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'));
|
||||
expect(getCodeVersion()).toBe(pkg.version);
|
||||
});
|
||||
|
||||
it('readUpgradeState returns null when the marker is absent', () => {
|
||||
expect(readUpgradeState()).toBeNull();
|
||||
});
|
||||
|
||||
it('write then read round-trips, with version/via/updatedAt', () => {
|
||||
const written = writeUpgradeState({ version: '9.9.9', via: 'test' });
|
||||
expect(written).toMatchObject({ version: '9.9.9', via: 'test' });
|
||||
expect(written.updatedAt).toBeTruthy();
|
||||
expect(readUpgradeState()).toEqual(written);
|
||||
});
|
||||
|
||||
it('write defaults the version to the code version', () => {
|
||||
expect(writeUpgradeState({ via: 'test' }).version).toBe(getCodeVersion());
|
||||
});
|
||||
|
||||
it('isUpgradeCurrent: false when absent, false on mismatch, true on match', () => {
|
||||
expect(isUpgradeCurrent()).toBe(false);
|
||||
writeUpgradeState({ version: '0.0.0-nope', via: 'test' });
|
||||
expect(isUpgradeCurrent()).toBe(false);
|
||||
writeUpgradeState({ version: getCodeVersion(), via: 'test' });
|
||||
expect(isUpgradeCurrent()).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a corrupt marker as absent (fails closed, never throws)', () => {
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
fs.writeFileSync(path.join(TEST_DIR, 'upgrade-state.json'), '{ this is not json');
|
||||
expect(() => readUpgradeState()).not.toThrow();
|
||||
expect(readUpgradeState()).toBeNull();
|
||||
expect(isUpgradeCurrent()).toBe(false);
|
||||
});
|
||||
|
||||
it('markerPath is upgrade-state.json under the data dir', () => {
|
||||
expect(markerPath()).toBe(path.join(TEST_DIR, 'upgrade-state.json'));
|
||||
});
|
||||
|
||||
it('enforceUpgradeTripwire exits when not current and passes when current', () => {
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
||||
throw new Error(`exit:${code}`);
|
||||
}) as never);
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
// No marker → trips.
|
||||
expect(() => enforceUpgradeTripwire()).toThrow('exit:1');
|
||||
|
||||
// Stale marker → trips.
|
||||
writeUpgradeState({ version: '0.0.0-nope', via: 'test' });
|
||||
expect(() => enforceUpgradeTripwire()).toThrow('exit:1');
|
||||
|
||||
// Matching marker → passes.
|
||||
writeUpgradeState({ version: getCodeVersion(), via: 'test' });
|
||||
expect(() => enforceUpgradeTripwire()).not.toThrow();
|
||||
|
||||
exitSpy.mockRestore();
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Upgrade marker — the record that an install reached its current version
|
||||
* through a sanctioned path (setup / `/update-nanoclaw` / `/migrate-nanoclaw`).
|
||||
*
|
||||
* The startup tripwire (enforceUpgradeTripwire) refuses to run if the marker
|
||||
* is missing or its version doesn't match the running code — i.e. if the
|
||||
* install was updated by a raw `git pull` instead of the supported flow.
|
||||
*
|
||||
* The marker lives in `data/` (gitignored), so a `git pull` can't touch it.
|
||||
* Only the sanctioned paths call writeUpgradeState(); clearing the tripwire
|
||||
* by hand is the same `set` — see docs/upgrade-recovery.md.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR } from './config.js';
|
||||
import { log } from './log.js';
|
||||
|
||||
export interface UpgradeState {
|
||||
version: string;
|
||||
updatedAt: string;
|
||||
via: string;
|
||||
}
|
||||
|
||||
const MARKER_PATH = path.join(DATA_DIR, 'upgrade-state.json');
|
||||
const FIX_COMMAND = 'pnpm exec tsx scripts/upgrade-state.ts set';
|
||||
|
||||
/** Version the running code declares, read from package.json. */
|
||||
export function getCodeVersion(): string {
|
||||
const pkgPath = path.join(process.cwd(), 'package.json');
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { version?: string };
|
||||
if (!pkg.version) throw new Error(`No version field in ${pkgPath}`);
|
||||
return pkg.version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the upgrade marker, or null if it's absent, unreadable, or corrupt.
|
||||
* Never throws — a boot gate must fail closed (treat anything it can't trust
|
||||
* as "no valid marker" → trip), not crash with a stack trace.
|
||||
*/
|
||||
export function readUpgradeState(): UpgradeState | null {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(MARKER_PATH, 'utf8');
|
||||
} catch (e: unknown) {
|
||||
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
log.warn('Could not read upgrade marker; treating as absent', { path: MARKER_PATH, err: String(e) });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw) as UpgradeState;
|
||||
} catch {
|
||||
log.warn('Upgrade marker is corrupt; treating as absent', { path: MARKER_PATH });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the marker. Only the sanctioned paths (setup / update / migrate)
|
||||
* call this on success; `version` defaults to the current code version.
|
||||
*/
|
||||
export function writeUpgradeState(opts: { version?: string; via: string }): UpgradeState {
|
||||
const state: UpgradeState = {
|
||||
version: opts.version ?? getCodeVersion(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
via: opts.via,
|
||||
};
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
fs.writeFileSync(MARKER_PATH, JSON.stringify(state, null, 2) + '\n');
|
||||
return state;
|
||||
}
|
||||
|
||||
/** True when the marker exists and matches the running code version. */
|
||||
export function isUpgradeCurrent(): boolean {
|
||||
const state = readUpgradeState();
|
||||
return state !== null && state.version === getCodeVersion();
|
||||
}
|
||||
|
||||
/** Absolute path to the marker file. */
|
||||
export function markerPath(): string {
|
||||
return MARKER_PATH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Startup gate. If the install didn't reach the current version through a
|
||||
* sanctioned path, stop with a message written for the coding agent that
|
||||
* just ran the upgrade to act on automatically.
|
||||
*/
|
||||
export function enforceUpgradeTripwire(): void {
|
||||
if (isUpgradeCurrent()) return;
|
||||
|
||||
const code = getCodeVersion();
|
||||
const recorded = readUpgradeState()?.version ?? 'none';
|
||||
|
||||
console.error(
|
||||
[
|
||||
'',
|
||||
'='.repeat(64),
|
||||
'NanoClaw stopped: update did not go through the supported path',
|
||||
'='.repeat(64),
|
||||
'This install was updated outside the sanctioned upgrade flow',
|
||||
'(no upgrade marker, or the marker version does not match the code).',
|
||||
'',
|
||||
` code version: ${code}`,
|
||||
` recorded version: ${recorded}`,
|
||||
'',
|
||||
"IF YOU'RE A HUMAN: you most likely ran `git pull` directly. Don't",
|
||||
'fix this by hand — open Claude Code in this folder and run',
|
||||
'/update-nanoclaw, which repairs the install and clears this stop.',
|
||||
'',
|
||||
'CODING AGENT: if you just ran /update-nanoclaw, /migrate-nanoclaw, or',
|
||||
'setup and it completed successfully, clear this automatically by running:',
|
||||
'',
|
||||
` ${FIX_COMMAND}`,
|
||||
'',
|
||||
'then restart NanoClaw. If the upgrade did NOT complete, stop and',
|
||||
'investigate instead of clearing.',
|
||||
'',
|
||||
'Details: docs/upgrade-recovery.md',
|
||||
'='.repeat(64),
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
log.error('Upgrade tripwire: install not on the sanctioned path', { code, recorded });
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user