Skip to content

Commit a0c9a1e

Browse files
committed
feat: harden daemon startup, capability policy, and gateway protocol
1 parent 112a44b commit a0c9a1e

File tree

17 files changed

+811
-116
lines changed

17 files changed

+811
-116
lines changed

README.md

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,12 @@ SwarmClaw can spawn **Claude Code CLI** processes with full shell access on your
5656
## Quick Start
5757

5858
```bash
59-
curl -fsSL https://swarmclaw.ai/install.sh | bash
59+
curl -fsSL https://raw.githubusercontent.com/swarmclawai/swarmclaw/main/install.sh | bash
6060
```
6161

62+
The installer resolves the latest stable release tag and installs that version by default.
63+
To pin a version: `SWARMCLAW_VERSION=v0.1.0 curl ... | bash`
64+
6265
Or run locally from the repo (friendly for non-technical users):
6366

6467
```bash
@@ -290,7 +293,7 @@ Token usage and estimated costs are tracked per message for API-based providers
290293
The daemon auto-processes queued tasks from the scheduler on a 30-second interval. It also runs recurring health checks that detect stale heartbeat sessions and can send proactive WhatsApp alerts when issues are detected. Toggle the daemon from the sidebar indicator or via API.
291294

292295
- **API:** `GET /api/daemon` (status), `POST /api/daemon` with `{"action": "start"}` or `{"action": "stop"}`
293-
- Auto-starts on boot if queued tasks are found
296+
- Auto-starts on first authenticated runtime traffic (`/api/auth` or `/api/daemon`) unless `SWARMCLAW_DAEMON_AUTOSTART=0`
294297

295298
## Main Agent Loop
296299

@@ -316,6 +319,17 @@ Configure loop behavior in **Settings → Runtime & Loop Controls**:
316319

317320
You can also tune shell timeout, Claude Code delegation timeout, and CLI provider process timeout from the same settings panel.
318321

322+
## Capability Policy
323+
324+
Configure this in **Settings → Capability Policy** to centrally govern tool access:
325+
326+
- **Mode:** `permissive`, `balanced`, or `strict`
327+
- **Blocked categories:** e.g. `execution`, `filesystem`, `platform`, `outbound`
328+
- **Blocked tools:** specific tool families or concrete tool names
329+
- **Allowed tools:** explicit overrides when running stricter modes
330+
331+
Policy is enforced in both session tool construction and direct forced tool invocations, so auto-routing and explicit tool requests use the same guardrails.
332+
319333
## CLI Troubleshooting
320334

321335
- **Claude delegate returns no output or fails quickly:** verify Claude auth on the host with:
@@ -419,6 +433,18 @@ docker compose up -d
419433

420434
Data is persisted in `data/` and `.env.local` via volume mounts. Updates: `git pull && docker compose up -d --build`.
421435

436+
For prebuilt images (recommended for non-technical users after releases):
437+
438+
```bash
439+
docker pull ghcr.io/swarmclawai/swarmclaw:latest
440+
docker run -d \
441+
--name swarmclaw \
442+
-p 3456:3456 \
443+
-v "$(pwd)/data:/app/data" \
444+
-v "$(pwd)/.env.local:/app/.env.local" \
445+
ghcr.io/swarmclawai/swarmclaw:latest
446+
```
447+
422448
### Updating
423449

424450
SwarmClaw has a built-in update checker — a banner appears in the sidebar when new commits are available, with a one-click update button. Your data in `data/` and `.env.local` is never touched by updates.
@@ -429,7 +455,7 @@ For terminal users, run:
429455
npm run update:easy
430456
```
431457

432-
This command fetches/pulls `origin/main`, installs dependencies when needed, and runs a production build check before restart.
458+
This command updates to the latest stable release tag when available (fallback: `origin/main`), installs dependencies when needed, and runs a production build check before restart.
433459

434460
## Development
435461

@@ -472,6 +498,21 @@ npm run quickstart:prod # setup + build + start production server
472498
npm run update:easy # safe update helper for local installs
473499
```
474500

501+
### Release Process (Maintainers)
502+
503+
SwarmClaw uses tag-based releases (`vX.Y.Z`) as the stable channel.
504+
505+
```bash
506+
# example patch release
507+
npm version patch
508+
git push origin main --follow-tags
509+
```
510+
511+
On `v*` tags, GitHub Actions will:
512+
1. Run CI checks
513+
2. Create a GitHub Release
514+
3. Build and publish Docker images to `ghcr.io/swarmclawai/swarmclaw` (`:vX.Y.Z`, `:latest`, `:sha-*`)
515+
475516
## CLI
476517

477518
SwarmClaw ships a built-in CLI for core operational workflows:

package-lock.json

Lines changed: 60 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"lint:baseline:update": "node ./scripts/lint-baseline.mjs update",
2323
"cli": "node ./bin/swarmclaw.js",
2424
"test:cli": "node --test src/cli/index.test.js",
25-
"test:openclaw": "tsx --test src/lib/server/connectors/openclaw.test.ts src/lib/openclaw-endpoint.test.ts"
25+
"test:openclaw": "tsx --test src/lib/server/connectors/openclaw.test.ts src/lib/openclaw-endpoint.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/tool-capability-policy.test.ts"
2626
},
2727
"dependencies": {
2828
"@huggingface/transformers": "^3.8.1",

src/app/api/auth/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { NextResponse } from 'next/server'
22
import { validateAccessKey, getAccessKey, isFirstTimeSetup, markSetupComplete } from '@/lib/server/storage'
3+
import { ensureDaemonStarted } from '@/lib/server/daemon-state'
34

45
/** GET /api/auth — check if this is a first-time setup (returns key for initial display) */
56
export async function GET() {
@@ -19,5 +20,6 @@ export async function POST(req: Request) {
1920
if (isFirstTimeSetup()) {
2021
markSetupComplete()
2122
}
23+
ensureDaemonStarted('api/auth:post')
2224
return NextResponse.json({ ok: true })
2325
}

src/app/api/daemon/health-check/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { NextResponse } from 'next/server'
2-
import { getDaemonStatus, runDaemonHealthCheckNow } from '@/lib/server/daemon-state'
2+
import { ensureDaemonStarted, getDaemonStatus, runDaemonHealthCheckNow } from '@/lib/server/daemon-state'
33

44
export async function POST() {
5+
ensureDaemonStarted('api/daemon/health-check:post')
56
await runDaemonHealthCheckNow()
67
return NextResponse.json({
78
ok: true,

src/app/api/daemon/route.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { NextResponse } from 'next/server'
2-
import { getDaemonStatus, startDaemon, stopDaemon } from '@/lib/server/daemon-state'
2+
import { ensureDaemonStarted, getDaemonStatus, startDaemon, stopDaemon } from '@/lib/server/daemon-state'
33

44
export async function GET() {
5+
ensureDaemonStarted('api/daemon:get')
56
return NextResponse.json(getDaemonStatus())
67
}
78

@@ -10,10 +11,10 @@ export async function POST(req: Request) {
1011
const action = body.action
1112

1213
if (action === 'start') {
13-
startDaemon()
14+
startDaemon({ source: 'api/daemon:post:start', manualStart: true })
1415
return NextResponse.json({ ok: true, status: 'running' })
1516
} else if (action === 'stop') {
16-
stopDaemon()
17+
stopDaemon({ source: 'api/daemon:post:stop', manualStop: true })
1718
return NextResponse.json({ ok: true, status: 'stopped' })
1819
}
1920

src/components/shared/settings-sheet.tsx

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,93 @@ export function SettingsSheet() {
422422
</div>
423423
</div>
424424

425+
{/* Capability Policy */}
426+
<div className="mb-10">
427+
<h3 className="font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">
428+
Capability Policy
429+
</h3>
430+
<p className="text-[12px] text-text-3 mb-5">
431+
Centralized guardrails for agent tool families. Applies to direct tool calls and forced auto-routing.
432+
</p>
433+
<div className="p-6 rounded-[18px] bg-surface border border-white/[0.06]">
434+
<label className="block font-display text-[11px] font-600 text-text-3 uppercase tracking-[0.08em] mb-3">Policy Mode</label>
435+
<div className="grid grid-cols-3 gap-2 mb-5">
436+
{([
437+
{ id: 'permissive', name: 'Permissive' },
438+
{ id: 'balanced', name: 'Balanced' },
439+
{ id: 'strict', name: 'Strict' },
440+
] as const).map((mode) => (
441+
<button
442+
key={mode.id}
443+
onClick={() => updateSettings({ capabilityPolicyMode: mode.id })}
444+
className={`py-3 px-3 rounded-[12px] text-center cursor-pointer transition-all text-[13px] font-600 border
445+
${(appSettings.capabilityPolicyMode || 'permissive') === mode.id
446+
? 'bg-accent-soft border-accent-bright/25 text-accent-bright'
447+
: 'bg-bg border-white/[0.06] text-text-2 hover:bg-surface-2'}`}
448+
style={{ fontFamily: 'inherit' }}
449+
>
450+
{mode.name}
451+
</button>
452+
))}
453+
</div>
454+
455+
<div className="grid grid-cols-1 gap-4">
456+
<div>
457+
<label className="block font-display text-[11px] font-600 text-text-3 uppercase tracking-[0.08em] mb-2">Blocked Categories</label>
458+
<input
459+
type="text"
460+
value={(appSettings.capabilityBlockedCategories || []).join(', ')}
461+
onChange={(e) => updateSettings({
462+
capabilityBlockedCategories: e.target.value
463+
.split(',')
464+
.map((part) => part.trim())
465+
.filter(Boolean),
466+
})}
467+
placeholder="execution, filesystem, platform, outbound"
468+
className={inputClass}
469+
style={{ fontFamily: 'inherit' }}
470+
/>
471+
<p className="text-[11px] text-text-3/60 mt-2">Supported categories: filesystem, execution, network, browser, memory, delegation, platform, outbound.</p>
472+
</div>
473+
474+
<div>
475+
<label className="block font-display text-[11px] font-600 text-text-3 uppercase tracking-[0.08em] mb-2">Blocked Tools</label>
476+
<input
477+
type="text"
478+
value={(appSettings.capabilityBlockedTools || []).join(', ')}
479+
onChange={(e) => updateSettings({
480+
capabilityBlockedTools: e.target.value
481+
.split(',')
482+
.map((part) => part.trim())
483+
.filter(Boolean),
484+
})}
485+
placeholder="delete_file, manage_connectors, delegate_to_codex_cli"
486+
className={inputClass}
487+
style={{ fontFamily: 'inherit' }}
488+
/>
489+
</div>
490+
491+
<div>
492+
<label className="block font-display text-[11px] font-600 text-text-3 uppercase tracking-[0.08em] mb-2">Allowed Tools (Override)</label>
493+
<input
494+
type="text"
495+
value={(appSettings.capabilityAllowedTools || []).join(', ')}
496+
onChange={(e) => updateSettings({
497+
capabilityAllowedTools: e.target.value
498+
.split(',')
499+
.map((part) => part.trim())
500+
.filter(Boolean),
501+
})}
502+
placeholder="shell, web_fetch, browser"
503+
className={inputClass}
504+
style={{ fontFamily: 'inherit' }}
505+
/>
506+
<p className="text-[11px] text-text-3/60 mt-2">Use this to re-allow specific tool families when running in strict mode.</p>
507+
</div>
508+
</div>
509+
</div>
510+
</div>
511+
425512
{/* Voice */}
426513
<div className="mb-10">
427514
<h3 className="font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">

src/lib/server/chat-execution.ts

Lines changed: 12 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { stripMainLoopMetaForPersistence } from './main-agent-loop'
2020
import { normalizeProviderEndpoint } from '@/lib/openclaw-endpoint'
2121
import { getMemoryDb } from './memory-db'
2222
import { routeTaskIntent } from './capability-router'
23+
import { resolveConcreteToolPolicyBlock, resolveSessionToolPolicy } from './tool-capability-policy'
2324
import type { MessageToolEvent, SSEEvent } from '@/types'
2425
import { markProviderFailure, markProviderSuccess, rankDelegatesByHealth } from './provider-health'
2526

@@ -216,36 +217,6 @@ function hasToolEnabled(session: any, toolName: string): boolean {
216217
return Array.isArray(session?.tools) && session.tools.includes(toolName)
217218
}
218219

219-
function buildSafetyBlockedSet(settings: Record<string, unknown>): Set<string> {
220-
const raw = Array.isArray(settings?.safetyBlockedTools) ? settings.safetyBlockedTools : []
221-
const set = new Set<string>()
222-
for (const entry of raw) {
223-
const name = typeof entry === 'string' ? entry.trim().toLowerCase() : ''
224-
if (!name) continue
225-
set.add(name)
226-
}
227-
return set
228-
}
229-
230-
function filterSessionToolsBySafety(sessionTools: string[] | undefined, blocked: Set<string>): string[] {
231-
const source = Array.isArray(sessionTools) ? sessionTools : []
232-
if (!blocked.size) return [...source]
233-
return source.filter((toolName) => {
234-
const key = String(toolName || '').trim().toLowerCase()
235-
if (!key) return false
236-
if (blocked.has(key)) return false
237-
if (key === 'memory' && blocked.has('memory_tool')) return false
238-
if (key === 'manage_connectors' && blocked.has('connector_message_tool')) return false
239-
if (key === 'manage_sessions' && (blocked.has('sessions_tool') || blocked.has('search_history_tool'))) return false
240-
if (key === 'claude_code' && (
241-
blocked.has('delegate_to_claude_code')
242-
|| blocked.has('delegate_to_codex_cli')
243-
|| blocked.has('delegate_to_opencode_cli')
244-
)) return false
245-
return true
246-
})
247-
}
248-
249220
function parseUsdLimit(value: unknown): number | null {
250221
const parsed = typeof value === 'number'
251222
? value
@@ -434,17 +405,17 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
434405
if (!session) throw new Error(`Session not found: ${sessionId}`)
435406

436407
const appSettings = loadSettings()
437-
const safetyBlocked = buildSafetyBlockedSet(appSettings)
438-
const toolsForRun = filterSessionToolsBySafety(session.tools, safetyBlocked)
408+
const toolPolicy = resolveSessionToolPolicy(session.tools, appSettings)
409+
const toolsForRun = toolPolicy.enabledTools
439410
const sessionForRun = toolsForRun === session.tools
440411
? session
441412
: { ...session, tools: toolsForRun }
442413

443-
if (toolsForRun.length !== (session.tools || []).length) {
444-
const removed = (session.tools || []).filter((tool: string) => !toolsForRun.includes(tool))
445-
if (removed.length) {
446-
onEvent?.({ t: 'err', text: `Safety policy blocked tool categories for this run: ${removed.join(', ')}` })
447-
}
414+
if (toolPolicy.blockedTools.length > 0) {
415+
const blockedSummary = toolPolicy.blockedTools
416+
.map((entry) => `${entry.tool} (${entry.reason})`)
417+
.join(', ')
418+
onEvent?.({ t: 'err', text: `Capability policy blocked tools for this run: ${blockedSummary}` })
448419
}
449420

450421
const dailySpendLimitUsd = parseUsdLimit(appSettings.safetyMaxDailySpendUsd)
@@ -600,13 +571,14 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
600571
? requestedToolNamesFromMessage(message)
601572
: []
602573
const routingDecision = (!internal && source === 'chat')
603-
? routeTaskIntent(message, session.tools || [], appSettings)
574+
? routeTaskIntent(message, toolsForRun, appSettings)
604575
: null
605576
const calledNames = new Set((toolEvents || []).map((t) => t.name))
606577

607578
const invokeSessionTool = async (toolName: string, args: Record<string, unknown>, failurePrefix: string): Promise<boolean> => {
608-
if (safetyBlocked.has(toolName.toLowerCase())) {
609-
emit({ t: 'err', text: `Safety policy blocked tool invocation: ${toolName}` })
579+
const blockedReason = resolveConcreteToolPolicyBlock(toolName, toolPolicy, appSettings)
580+
if (blockedReason) {
581+
emit({ t: 'err', text: `Capability policy blocked tool invocation "${toolName}": ${blockedReason}` })
610582
return false
611583
}
612584
if (

0 commit comments

Comments
 (0)