@swarmclawai/swarmclaw 1.5.54 → 1.5.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +15 -0
  2. package/package.json +2 -2
  3. package/src/app/api/agents/[id]/route.ts +14 -2
  4. package/src/app/api/agents/agents-route.test.ts +65 -1
  5. package/src/app/api/chatrooms/[id]/chat/route.ts +5 -3
  6. package/src/app/api/chatrooms/[id]/route.ts +13 -3
  7. package/src/app/api/chatrooms/route.ts +3 -0
  8. package/src/app/api/documents/[id]/route.ts +20 -15
  9. package/src/app/api/external-agents/[id]/route.ts +15 -2
  10. package/src/app/api/goals/[id]/route.ts +12 -2
  11. package/src/app/api/missions/[id]/control/route.ts +21 -0
  12. package/src/app/api/providers/[id]/route.ts +11 -1
  13. package/src/app/api/secrets/[id]/route.ts +12 -6
  14. package/src/app/api/secrets/route.ts +10 -5
  15. package/src/app/api/tasks/[id]/route.ts +11 -1
  16. package/src/app/api/tasks/tasks-route.test.ts +81 -0
  17. package/src/app/api/tts/route.test.ts +50 -0
  18. package/src/app/api/tts/route.ts +4 -1
  19. package/src/app/api/tts/stream/route.ts +5 -1
  20. package/src/app/api/webhooks/[id]/route.ts +18 -15
  21. package/src/app/missions/page.tsx +38 -8
  22. package/src/components/missions/mission-edit-sheet.tsx +319 -0
  23. package/src/components/missions/mission-template-install-dialog.tsx +8 -3
  24. package/src/lib/server/agents/agent-service.ts +10 -2
  25. package/src/lib/server/agents/main-agent-loop-advanced.test.ts +36 -0
  26. package/src/lib/server/agents/main-agent-loop.ts +111 -4
  27. package/src/lib/server/chat-execution/chat-turn-preparation.test.ts +253 -0
  28. package/src/lib/server/chat-execution/chat-turn-preparation.ts +21 -12
  29. package/src/lib/server/chat-execution/message-classifier.ts +11 -7
  30. package/src/lib/server/chat-execution/post-stream-finalization.test.ts +85 -0
  31. package/src/lib/server/chat-execution/post-stream-finalization.ts +41 -16
  32. package/src/lib/server/chat-execution/response-completeness.test.ts +2 -1
  33. package/src/lib/server/chat-execution/response-completeness.ts +11 -3
  34. package/src/lib/server/chatrooms/chatroom-agent-signals.test.ts +54 -0
  35. package/src/lib/server/chatrooms/chatroom-agent-signals.ts +105 -9
  36. package/src/lib/server/connectors/email.test.ts +64 -0
  37. package/src/lib/server/connectors/email.ts +35 -6
  38. package/src/lib/server/connectors/response-media.ts +1 -0
  39. package/src/lib/server/daemon/daemon-runtime.ts +31 -19
  40. package/src/lib/server/memory/memory-db.test.ts +8 -0
  41. package/src/lib/server/memory/memory-db.ts +1 -1
  42. package/src/lib/server/runtime/session-run-manager/drain.ts +16 -0
  43. package/src/lib/server/storage.ts +1 -1
  44. package/src/lib/server/tasks/task-validation.test.ts +30 -0
  45. package/src/lib/server/tasks/task-validation.ts +21 -2
  46. package/src/lib/server/working-state/normalization.ts +5 -1
  47. package/src/lib/validation/schemas.test.ts +16 -0
  48. package/src/lib/validation/schemas.ts +137 -0
package/README.md CHANGED
@@ -399,6 +399,21 @@ Operational docs: https://swarmclaw.ai/docs/observability
399
399
 
400
400
  ## Releases
401
401
 
402
+ ### v1.5.56 Highlights
403
+
404
+ - **Fix: TTS error responses are now proper JSON instead of a raw Buffer blob.** `POST /api/tts` and `POST /api/tts/stream` previously returned `500` with the error message wrapped in a `new NextResponse(string, ...)` that the CLI JSON-decoded into `{"type":"Buffer","data":[78,111,...]}`. Both routes now return `NextResponse.json({error}, {status: 500})`. Regression test added.
405
+ - **Zod-validated PUT/PATCH endpoints — hardening sweep.** Extends the v1.5.55 work (agents, tasks, webhooks) to close the same silent-corruption bug class on the remaining vulnerable routes: `PUT /api/secrets/:id`, `POST /api/secrets`, `PATCH /api/goals/:id`, `PUT /api/providers/:id`, `PUT /api/documents/:id`, `PUT /api/external-agents/:id`, and `PUT /api/chatrooms/:id`. Each route validates against a dedicated schema (`SecretUpdateSchema`, `SecretCreateSchema`, `GoalUpdateSchema`, `ProviderUpdateSchema`, `DocumentUpdateSchema`, `ExternalAgentUpdateSchema`, `ChatroomUpdateSchema`) in `src/lib/validation/schemas.ts`, then filters parsed data to the keys actually present in the raw body so Zod defaults can't overwrite untouched stored fields. Endpoints already doing per-field `typeof` guards (knowledge, gateways, projects) were left as-is.
406
+
407
+ ### v1.5.55 Highlights
408
+
409
+ - **Fix: mission budget updates with decimal values no longer silently fail with a 400.** The mission UI's `numOrNull` parsed user input with `Number.parseFloat`, but the API requires `int()` for `maxTokens`, `maxToolCalls`, `maxWallclockSec`, and `maxTurns`. Typing `1000.5` returned a cryptic Zod error to the toast and the update was lost. Added `intOrNull` (rounds) in `mission-edit-sheet.tsx`, `mission-template-install-dialog.tsx`, and `app/missions/page.tsx`. `maxUsd` still accepts decimals.
410
+ - **Fix: mission edit sheet's connectors dropdown was always empty.** The sheet fetched `/connectors` expecting a `Connector[]`, but the endpoint returns `Record<string, Connector>`. The defensive `Array.isArray` fallback quietly rendered an empty list, so users could not attach report connectors when editing a running mission. Now typed as `Record<string, Connector>` and projected with `Object.values`.
411
+ - **Fix: memory search returns results for short (3-4 char) words like `cats`, `blue`, `dog`.** `buildFtsQuery` had a `unique[0].length >= 5` guard that returned an empty FTS query for any single-token search shorter than 5 chars, silently dropping valid searches. The upstream filter already requires ≥3 chars, so the extra guard just excluded useful queries. Removed; regression tests cover `cats`, `blue`, and `dog`.
412
+ - **Fix: `PUT /api/agents/:id` now validates its body with a Zod schema.** Previously the route did `{...current, ...body}` without validation, so sending `{"tools": "not_an_array"}` silently wiped the agent's tool list to `[]`. Added `AgentUpdateSchema = AgentCreateSchema.partial()` and a filter step that keeps only keys present in the raw body (so Zod defaults do not overwrite untouched fields). Bad types now return a 400 with field-level errors. `updateAgent()` keeps a `current.tools` / `current.extensions` fallback as defense-in-depth for internal callers.
413
+ - **Fix: `PUT /api/tasks/:id` now validates its body with a Zod schema.** Same class of bug: a numeric `title` silently corrupted the stored field. Added `TaskUpdateSchema = TaskCreateSchema.partial().extend({...})` with the update-only fields (`appendComment`, `result`, `error`, lifecycle timestamps) and the same raw-key filter pattern. Bad types now 400 with untouched storage.
414
+ - **Fix: `PUT /api/webhooks/:id` now validates its body with a Zod schema.** Previously `{"events": "not_an_array"}` wiped the events list. Added `WebhookUpdateSchema` and explicit `rawKeys.has(...)` guards in the mutate closure so only fields actually present in the body are applied.
415
+ - **Fix: classifier JSON no longer leaks into assistant responses.** Some Ollama / Ollama Cloud turns were emitting the internal `MessageClassification` object directly into the stream (e.g. `{"taskIntent":"research",...}` prepended to the real reply). The existing stripper only matched when `isDeliverableTask` was the first key, so leaks starting with `taskIntent` sailed through to the user. Replaced the regex with a principled detector that brace-matches candidate JSON (string-quote aware) and validates against `MessageClassificationSchema.safeParse` — the schema itself is the source of truth, so future schema changes can't break detection.
416
+
402
417
  ### v1.5.54 Highlights
403
418
 
404
419
  - **Mission templates library**: the `/missions` page now opens with a curated gallery of starter missions. Each template pre-wires a goal, success criteria, USD / token / turn / wallclock budgets, and a report cadence, so non-technical users can install a working autonomous run in one click. Initial lineup: Daily News Digest, Inbox Triage, Competitor Watch, Weekly Research Report, Social Listener, and Customer Support Triage. Setup notes flag any connector or permission prerequisites before installation. Power-user overrides (budget caps, success criteria, report cadence) live behind a collapsed **Advanced Settings** panel so the default install flow stays one click.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.5.54",
3
+ "version": "1.5.56",
4
4
  "description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
5
5
  "main": "electron-dist/main.js",
6
6
  "license": "MIT",
@@ -80,7 +80,7 @@
80
80
  "test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
81
81
  "test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts src/lib/server/storage-auth.test.ts src/lib/server/storage-auth-docker.test.ts",
82
82
  "test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/connectors/swarmdock.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/session-tools/swarmdock.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
83
- "test:runtime": "tsx --test src/lib/server/knowledge-sources.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/tts/route.test.ts",
83
+ "test:runtime": "tsx --test src/lib/server/knowledge-sources.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/tts/route.test.ts",
84
84
  "test:builder": "tsx --test src/features/protocols/builder/utils/nodes-to-template.test.ts src/features/protocols/builder/utils/template-to-nodes.test.ts src/features/protocols/builder/validators/dag-validator.test.ts",
85
85
  "test:e2e": "tsx .workbench/browser-e2e/run.ts",
86
86
  "test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
@@ -4,6 +4,7 @@ import { trashAgent, updateAgent } from '@/lib/server/agents/agent-service'
4
4
  import { loadAgent } from '@/lib/server/agents/agent-repository'
5
5
  import { notify } from '@/lib/server/ws-hub'
6
6
  import { safeParseBody } from '@/lib/server/safe-parse-body'
7
+ import { AgentUpdateSchema, formatZodError } from '@/lib/validation/schemas'
7
8
 
8
9
  export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
9
10
  const { id } = await params
@@ -14,9 +15,20 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
14
15
 
15
16
  export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
16
17
  const { id } = await params
17
- const { data: body, error } = await safeParseBody(req)
18
+ const { data: raw, error } = await safeParseBody<Record<string, unknown>>(req)
18
19
  if (error) return error
19
- const result = updateAgent(id, body as Record<string, unknown>)
20
+ const parsed = AgentUpdateSchema.safeParse(raw)
21
+ if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
22
+
23
+ // Filter to keys actually present in the raw body — zod re-applies `.default(...)`
24
+ // to absent fields, which would clobber untouched fields on the stored agent.
25
+ const rawKeys = new Set(Object.keys(raw ?? {}))
26
+ const body: Record<string, unknown> = {}
27
+ for (const [key, value] of Object.entries(parsed.data)) {
28
+ if (rawKeys.has(key)) body[key] = value
29
+ }
30
+
31
+ const result = updateAgent(id, body)
20
32
  if (!result) return notFound()
21
33
  return NextResponse.json(result)
22
34
  }
@@ -4,7 +4,7 @@ import test, { afterEach } from 'node:test'
4
4
  // Disable daemon autostart during tests
5
5
  process.env.SWARMCLAW_DAEMON_AUTOSTART = '0'
6
6
 
7
- import { GET as getAgent } from './[id]/route'
7
+ import { GET as getAgent, PUT as putAgent } from './[id]/route'
8
8
  import { POST as createAgent } from './route'
9
9
  import { loadAgents, saveAgents } from '@/lib/server/storage'
10
10
 
@@ -112,3 +112,67 @@ test('POST /api/agents rejects missing required fields with a 400', async () =>
112
112
  const body = await response.json()
113
113
  assert.equal(body.error, 'Validation failed')
114
114
  })
115
+
116
+ // --- PUT /api/agents/:id (validation & field preservation) ---
117
+
118
+ test('PUT /api/agents/:id rejects a non-array tools value with a 400', async () => {
119
+ seedAgent('agent-tools-reject', { tools: ['memory', 'files', 'web_search'] })
120
+
121
+ const response = await putAgent(new Request('http://local/api/agents/agent-tools-reject', {
122
+ method: 'PUT',
123
+ headers: { 'content-type': 'application/json' },
124
+ body: JSON.stringify({ tools: 'not_an_array' }),
125
+ }), routeParams('agent-tools-reject'))
126
+
127
+ assert.equal(response.status, 400)
128
+ const body = await response.json()
129
+ assert.equal(body.error, 'Validation failed')
130
+ assert.ok(body.issues.some((i: { path: string }) => i.path === 'tools'))
131
+
132
+ const agentAfter = loadAgents()['agent-tools-reject']
133
+ assert.deepEqual(agentAfter.tools, ['memory', 'files', 'web_search'], 'stored tools must be untouched')
134
+ })
135
+
136
+ test('PUT /api/agents/:id does not clobber untouched fields with schema defaults', async () => {
137
+ // Seed with non-default values; PUT a body that omits those fields. The route
138
+ // must filter zod defaults so missing keys do NOT reset the stored values.
139
+ seedAgent('agent-partial-update', {
140
+ name: 'Original',
141
+ tools: ['memory'],
142
+ delegationEnabled: true,
143
+ delegationTargetMode: 'selected',
144
+ delegationTargetAgentIds: ['other-agent'],
145
+ heartbeatEnabled: false,
146
+ proactiveMemory: false,
147
+ })
148
+
149
+ const response = await putAgent(new Request('http://local/api/agents/agent-partial-update', {
150
+ method: 'PUT',
151
+ headers: { 'content-type': 'application/json' },
152
+ body: JSON.stringify({ description: 'edited' }),
153
+ }), routeParams('agent-partial-update'))
154
+
155
+ assert.equal(response.status, 200)
156
+ const body = await response.json()
157
+ assert.equal(body.description, 'edited')
158
+ assert.equal(body.name, 'Original')
159
+ assert.deepEqual(body.tools, ['memory'])
160
+ assert.equal(body.delegationEnabled, true)
161
+ assert.equal(body.delegationTargetMode, 'selected')
162
+ assert.equal(body.heartbeatEnabled, false)
163
+ assert.equal(body.proactiveMemory, false)
164
+ })
165
+
166
+ test('PUT /api/agents/:id rejects non-string name', async () => {
167
+ seedAgent('agent-bad-name', { name: 'Good' })
168
+
169
+ const response = await putAgent(new Request('http://local/api/agents/agent-bad-name', {
170
+ method: 'PUT',
171
+ headers: { 'content-type': 'application/json' },
172
+ body: JSON.stringify({ name: 12345 }),
173
+ }), routeParams('agent-bad-name'))
174
+
175
+ assert.equal(response.status, 400)
176
+ const agentAfter = loadAgents()['agent-bad-name']
177
+ assert.equal(agentAfter.name, 'Good')
178
+ })
@@ -25,7 +25,7 @@ import {
25
25
  selectChatroomRecipients,
26
26
  } from '@/lib/server/chatrooms/chatroom-routing'
27
27
  import { markProviderFailure, markProviderSuccess } from '@/lib/server/provider-health'
28
- import { applyAgentReactionsFromText } from '@/lib/server/chatrooms/chatroom-agent-signals'
28
+ import { applyAgentReactionsFromText, stripAgentReactionTokens } from '@/lib/server/chatrooms/chatroom-agent-signals'
29
29
  import { resolvePrimaryAgentRoute } from '@/lib/server/agents/agent-runtime-config'
30
30
  import { shouldSuppressHiddenControlText, stripHiddenControlTokens } from '@/lib/server/agents/assistant-control'
31
31
  import type { Chatroom, ChatroomMessage, Agent } from '@/types'
@@ -46,7 +46,9 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
46
46
  const chatroom = chatrooms[id] as Chatroom | undefined
47
47
  if (!chatroom) return notFound()
48
48
 
49
- const text = typeof body.text === 'string' ? body.text : ''
49
+ const text = typeof body.text === 'string'
50
+ ? body.text
51
+ : (typeof body.message === 'string' ? body.message : '')
50
52
  const senderId = typeof body.senderId === 'string' ? body.senderId : 'user'
51
53
  const imagePath = typeof body.imagePath === 'string' ? body.imagePath : undefined
52
54
  const attachedFiles = Array.isArray(body.attachedFiles)
@@ -260,7 +262,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
260
262
  })
261
263
 
262
264
  const rawResponseText = result.finalResponse || result.fullText || fullText
263
- const responseText = stripHiddenControlTokens(rawResponseText)
265
+ const responseText = stripAgentReactionTokens(stripHiddenControlTokens(rawResponseText))
264
266
 
265
267
  // Don't persist empty or error-only messages — they pollute chat history
266
268
  if (!responseText.trim() && agentError) {
@@ -9,6 +9,7 @@ import {
9
9
  ensureChatroomRoutingGuidance,
10
10
  synthesizeRoutingGuidanceFromRules,
11
11
  } from '@/lib/server/chatrooms/chatroom-routing'
12
+ import { ChatroomUpdateSchema, formatZodError } from '@/lib/validation/schemas'
12
13
 
13
14
  export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
14
15
  const { id } = await params
@@ -25,14 +26,23 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
25
26
 
26
27
  export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
27
28
  const { id } = await params
28
- const { data: body, error } = await safeParseBody<Record<string, unknown>>(req)
29
+ const { data: raw, error } = await safeParseBody<Record<string, unknown>>(req)
29
30
  if (error) return error
31
+ const parsed = ChatroomUpdateSchema.safeParse(raw)
32
+ if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
33
+
34
+ const rawKeys = new Set(Object.keys(raw ?? {}))
35
+ const body: Record<string, unknown> = {}
36
+ for (const [key, value] of Object.entries(parsed.data)) {
37
+ if (rawKeys.has(key)) body[key] = value
38
+ }
39
+
30
40
  const chatrooms = loadChatrooms()
31
41
  const chatroom = chatrooms[id]
32
42
  if (!chatroom) return notFound()
33
43
 
34
- if (body.name !== undefined) chatroom.name = body.name
35
- if (body.description !== undefined) chatroom.description = body.description
44
+ if (body.name !== undefined) chatroom.name = body.name as string
45
+ if (body.description !== undefined) chatroom.description = body.description as string
36
46
  if (body.chatMode !== undefined) {
37
47
  chatroom.chatMode = body.chatMode === 'parallel' ? 'parallel' : 'sequential'
38
48
  }
@@ -41,6 +41,9 @@ export async function GET(req: Request) {
41
41
  export async function POST(req: Request) {
42
42
  const { data: raw, error } = await safeParseBody<Record<string, unknown>>(req)
43
43
  if (error) return error
44
+ if (raw && Array.isArray(raw.memberAgentIds) && !Array.isArray(raw.agentIds)) {
45
+ raw.agentIds = raw.memberAgentIds
46
+ }
44
47
  const parsed = ChatroomCreateSchema.safeParse(raw)
45
48
  if (!parsed.success) {
46
49
  return NextResponse.json(formatZodError(parsed.error as z.ZodError), { status: 400 })
@@ -1,11 +1,7 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import crypto from 'crypto'
3
3
  import { loadDocuments, saveDocuments, upsertDocumentRevision } from '@/lib/server/storage'
4
-
5
- function normalizeObject(value: unknown): Record<string, unknown> {
6
- if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
7
- return value as Record<string, unknown>
8
- }
4
+ import { DocumentUpdateSchema, formatZodError } from '@/lib/validation/schemas'
9
5
 
10
6
  export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
11
7
  const { id } = await params
@@ -17,13 +13,22 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
17
13
 
18
14
  export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
19
15
  const { id } = await params
20
- const body = await req.json().catch(() => ({}))
16
+ const raw = await req.json().catch(() => null)
17
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
18
+ return NextResponse.json({ error: 'Invalid or missing request body' }, { status: 400 })
19
+ }
20
+ const parsed = DocumentUpdateSchema.safeParse(raw)
21
+ if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
22
+
23
+ const rawKeys = new Set(Object.keys(raw))
24
+ const body = parsed.data
25
+
21
26
  const docs = loadDocuments()
22
27
  const doc = docs[id]
23
28
  if (!doc) return NextResponse.json({ error: 'Document not found' }, { status: 404 })
24
29
 
25
30
  // Snapshot previous content as a revision before updating
26
- if (body.content !== undefined && body.content !== doc.content) {
31
+ if (rawKeys.has('content') && body.content !== undefined && body.content !== doc.content) {
27
32
  const prevVersion = typeof doc.currentVersion === 'number' ? doc.currentVersion : 0
28
33
  const revisionId = crypto.randomBytes(8).toString('hex')
29
34
  upsertDocumentRevision(revisionId, {
@@ -32,18 +37,18 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
32
37
  version: prevVersion,
33
38
  content: doc.content,
34
39
  createdAt: Date.now(),
35
- createdBy: typeof body.createdBy === 'string' ? body.createdBy : null,
40
+ createdBy: body.createdBy ?? null,
36
41
  })
37
42
  doc.currentVersion = prevVersion + 1
38
43
  }
39
44
 
40
- if (body.title !== undefined) doc.title = body.title
41
- if (body.fileName !== undefined) doc.fileName = body.fileName
42
- if (body.sourcePath !== undefined) doc.sourcePath = body.sourcePath
43
- if (body.content !== undefined) doc.content = body.content
44
- if (body.method !== undefined) doc.method = body.method
45
- if (body.metadata !== undefined) doc.metadata = normalizeObject(body.metadata)
46
- doc.textLength = typeof body.textLength === 'number'
45
+ if (rawKeys.has('title') && body.title !== undefined) doc.title = body.title
46
+ if (rawKeys.has('fileName')) doc.fileName = body.fileName ?? null
47
+ if (rawKeys.has('sourcePath')) doc.sourcePath = body.sourcePath ?? null
48
+ if (rawKeys.has('content') && body.content !== undefined) doc.content = body.content
49
+ if (rawKeys.has('method') && body.method !== undefined) doc.method = body.method
50
+ if (rawKeys.has('metadata') && body.metadata !== undefined) doc.metadata = body.metadata
51
+ doc.textLength = rawKeys.has('textLength') && body.textLength !== undefined
47
52
  ? body.textLength
48
53
  : String(doc.content || '').length
49
54
  doc.updatedAt = Date.now()
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
2
2
  import { loadExternalAgents, saveExternalAgents } from '@/lib/server/storage'
3
3
  import { mutateItem, notFound, type CollectionOps } from '@/lib/server/collection-helpers'
4
4
  import { notify } from '@/lib/server/ws-hub'
5
+ import { ExternalAgentUpdateSchema, formatZodError } from '@/lib/validation/schemas'
5
6
  export const dynamic = 'force-dynamic'
6
7
 
7
8
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -9,12 +10,24 @@ const ops: CollectionOps<any> = { load: loadExternalAgents, save: saveExternalAg
9
10
 
10
11
  export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
11
12
  const { id } = await params
12
- const body = await req.json().catch(() => ({}))
13
+ const raw = await req.json().catch(() => null)
14
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
15
+ return NextResponse.json({ error: 'Invalid or missing request body' }, { status: 400 })
16
+ }
17
+ const parsed = ExternalAgentUpdateSchema.safeParse(raw)
18
+ if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
19
+
20
+ const rawKeys = new Set(Object.keys(raw))
21
+ const body: Record<string, unknown> = {}
22
+ for (const [key, value] of Object.entries(parsed.data)) {
23
+ if (rawKeys.has(key)) body[key] = value
24
+ }
25
+
13
26
  const now = Date.now()
14
27
  const result = mutateItem(ops, id, (runtime) => {
15
28
  const action = typeof body.action === 'string' ? body.action : ''
16
29
  const nextMetadata = body.metadata && typeof body.metadata === 'object'
17
- ? { ...(runtime.metadata || {}), ...body.metadata }
30
+ ? { ...(runtime.metadata || {}), ...(body.metadata as Record<string, unknown>) }
18
31
  : runtime.metadata
19
32
 
20
33
  const next = {
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
2
2
  import { safeParseBody } from '@/lib/server/safe-parse-body'
3
3
  import { getGoalById, updateGoal, deleteGoal, getGoalChain } from '@/lib/server/goals/goal-service'
4
4
  import { notFound } from '@/lib/server/collection-helpers'
5
+ import { GoalUpdateSchema, formatZodError } from '@/lib/validation/schemas'
5
6
  export const dynamic = 'force-dynamic'
6
7
 
7
8
  export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
@@ -14,9 +15,18 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
14
15
 
15
16
  export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
16
17
  const { id } = await params
17
- const { data: body, error } = await safeParseBody<Record<string, unknown>>(req)
18
+ const { data: raw, error } = await safeParseBody<Record<string, unknown>>(req)
18
19
  if (error) return error
19
- const updated = updateGoal(id, body)
20
+ const parsed = GoalUpdateSchema.safeParse(raw)
21
+ if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
22
+
23
+ const rawKeys = new Set(Object.keys(raw ?? {}))
24
+ const patch: Record<string, unknown> = {}
25
+ for (const [key, value] of Object.entries(parsed.data)) {
26
+ if (rawKeys.has(key)) patch[key] = value
27
+ }
28
+
29
+ const updated = updateGoal(id, patch)
20
30
  if (!updated) return notFound()
21
31
  return NextResponse.json(updated)
22
32
  }
@@ -11,6 +11,9 @@ import {
11
11
  pauseMission,
12
12
  startMission,
13
13
  } from '@/lib/server/missions/mission-service'
14
+ import { enqueueSessionRun } from '@/lib/server/runtime/session-run-manager'
15
+ import { loadSessions } from '@/lib/server/storage'
16
+ import { log } from '@/lib/server/logger'
14
17
 
15
18
  export const dynamic = 'force-dynamic'
16
19
 
@@ -31,7 +34,25 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
31
34
  switch (parsed.data.action) {
32
35
  case 'start':
33
36
  case 'resume': {
37
+ const wasDraft = mission.status === 'draft'
34
38
  const updated = startMission(id)
39
+ if (updated && wasDraft && updated.rootSessionId && updated.goal) {
40
+ const sessions = loadSessions()
41
+ if (sessions[updated.rootSessionId]) {
42
+ try {
43
+ enqueueSessionRun({
44
+ sessionId: updated.rootSessionId,
45
+ message: `Mission goal: ${updated.goal}`,
46
+ missionId: updated.id,
47
+ source: 'mission',
48
+ internal: true,
49
+ dedupeKey: `mission:${updated.id}:kickoff`,
50
+ })
51
+ } catch (kickErr) {
52
+ log.warn('api-mission-control', `Mission kickoff enqueue failed for ${updated.id}`, kickErr)
53
+ }
54
+ }
55
+ }
35
56
  return NextResponse.json(updated)
36
57
  }
37
58
  case 'pause': {
@@ -4,6 +4,7 @@ import { loadProviderConfigs, saveProviderConfigs } from '@/lib/server/storage'
4
4
  import { mutateItem, deleteItem, notFound, badRequest, type CollectionOps } from '@/lib/server/collection-helpers'
5
5
  import { safeParseBody } from '@/lib/server/safe-parse-body'
6
6
  import { notify } from '@/lib/server/ws-hub'
7
+ import { ProviderUpdateSchema, formatZodError } from '@/lib/validation/schemas'
7
8
 
8
9
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
10
  const ops: CollectionOps<any> = { load: loadProviderConfigs, save: saveProviderConfigs, topic: 'providers' }
@@ -18,8 +19,17 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
18
19
 
19
20
  export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
20
21
  const { id } = await params
21
- const { data: body, error } = await safeParseBody<Record<string, unknown>>(req)
22
+ const { data: raw, error } = await safeParseBody<Record<string, unknown>>(req)
22
23
  if (error) return error
24
+ const parsed = ProviderUpdateSchema.safeParse(raw)
25
+ if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
26
+
27
+ const rawKeys = new Set(Object.keys(raw ?? {}))
28
+ const body: Record<string, unknown> = {}
29
+ for (const [key, value] of Object.entries(parsed.data)) {
30
+ if (rawKeys.has(key)) body[key] = value
31
+ }
32
+
23
33
  if (!ops.load()[id]) {
24
34
  const builtin = PROVIDERS[id]
25
35
  if (!builtin) return notFound()
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
2
2
  import { loadSecrets, saveSecrets } from '@/lib/server/storage'
3
3
  import { mutateItem, deleteItem, notFound, type CollectionOps } from '@/lib/server/collection-helpers'
4
4
  import { safeParseBody } from '@/lib/server/safe-parse-body'
5
+ import { SecretUpdateSchema, formatZodError } from '@/lib/validation/schemas'
5
6
 
6
7
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
8
  const ops: CollectionOps<any> = { load: loadSecrets, save: saveSecrets }
@@ -25,14 +26,19 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
25
26
 
26
27
  export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
27
28
  const { id } = await params
28
- const { data: body, error } = await safeParseBody<Record<string, unknown>>(req)
29
+ const { data: raw, error } = await safeParseBody<Record<string, unknown>>(req)
29
30
  if (error) return error
31
+ const parsed = SecretUpdateSchema.safeParse(raw)
32
+ if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
33
+ const rawKeys = new Set(Object.keys(raw ?? {}))
34
+ const body = parsed.data
35
+
30
36
  const result = mutateItem(ops, id, (secret) => {
31
- if (body.name !== undefined) secret.name = body.name
32
- if (body.service !== undefined) secret.service = body.service
33
- if (body.scope !== undefined) secret.scope = body.scope
34
- if (body.agentIds !== undefined) secret.agentIds = body.agentIds
35
- if (body.projectId !== undefined) secret.projectId = body.projectId || undefined
37
+ if (rawKeys.has('name') && body.name !== undefined) secret.name = body.name
38
+ if (rawKeys.has('service') && body.service !== undefined) secret.service = body.service
39
+ if (rawKeys.has('scope') && body.scope !== undefined) secret.scope = body.scope
40
+ if (rawKeys.has('agentIds') && body.agentIds !== undefined) secret.agentIds = body.agentIds
41
+ if (rawKeys.has('projectId')) secret.projectId = body.projectId || undefined
36
42
  secret.updatedAt = Date.now()
37
43
  return secret
38
44
  })
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
2
2
  import { genId } from '@/lib/id'
3
3
  import { loadSecrets, saveSecrets, encryptKey } from '@/lib/server/storage'
4
4
  import { safeParseBody } from '@/lib/server/safe-parse-body'
5
+ import { SecretCreateSchema, formatZodError } from '@/lib/validation/schemas'
5
6
  export const dynamic = 'force-dynamic'
6
7
 
7
8
 
@@ -18,16 +19,20 @@ export async function GET(_req: Request) {
18
19
  }
19
20
 
20
21
  export async function POST(req: Request) {
21
- const { data: body, error } = await safeParseBody<{ value?: string; name?: string; service?: string; scope?: string; agentIds?: string[]; projectId?: string }>(req)
22
+ const { data: raw, error } = await safeParseBody<Record<string, unknown>>(req)
22
23
  if (error) return error
23
- const id = genId()
24
- const now = Date.now()
25
- const secrets = loadSecrets()
24
+ const parsed = SecretCreateSchema.safeParse(raw)
25
+ if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
26
+ const body = parsed.data
26
27
 
27
- if (!body.value?.trim()) {
28
+ if (!body.value.trim()) {
28
29
  return NextResponse.json({ error: 'value is required' }, { status: 400 })
29
30
  }
30
31
 
32
+ const id = genId()
33
+ const now = Date.now()
34
+ const secrets = loadSecrets()
35
+
31
36
  secrets[id] = {
32
37
  id,
33
38
  name: body.name || 'Unnamed Secret',
@@ -7,6 +7,7 @@ import {
7
7
  prepareTasksForListing,
8
8
  updateTaskFromRoute,
9
9
  } from '@/lib/server/tasks/task-route-service'
10
+ import { TaskUpdateSchema, formatZodError } from '@/lib/validation/schemas'
10
11
 
11
12
  export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
12
13
  const { id } = await params
@@ -17,8 +18,17 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
17
18
 
18
19
  export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
19
20
  const { id } = await params
20
- const { data: body, error } = await safeParseBody<Record<string, unknown>>(req)
21
+ const { data: raw, error } = await safeParseBody<Record<string, unknown>>(req)
21
22
  if (error) return error
23
+ const parsed = TaskUpdateSchema.safeParse(raw)
24
+ if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
25
+
26
+ const rawKeys = new Set(Object.keys(raw ?? {}))
27
+ const body: Record<string, unknown> = {}
28
+ for (const [key, value] of Object.entries(parsed.data)) {
29
+ if (rawKeys.has(key)) body[key] = value
30
+ }
31
+
22
32
  const result = updateTaskFromRoute(id, body)
23
33
  if (!result.ok && result.status === 404) return notFound()
24
34
  return result.ok
@@ -0,0 +1,81 @@
1
+ import assert from 'node:assert/strict'
2
+ import test, { afterEach } from 'node:test'
3
+
4
+ process.env.SWARMCLAW_DAEMON_AUTOSTART = '0'
5
+
6
+ import { PUT as putTask } from './[id]/route'
7
+ import { loadTasks, saveTasks } from '@/lib/server/storage'
8
+ import type { BoardTask } from '@/types'
9
+
10
+ const originalTasks = loadTasks()
11
+
12
+ function routeParams(id: string) {
13
+ return { params: Promise.resolve({ id }) }
14
+ }
15
+
16
+ function seedTask(id: string, overrides: Partial<BoardTask> = {}) {
17
+ const tasks = loadTasks()
18
+ const now = Date.now()
19
+ tasks[id] = {
20
+ id,
21
+ title: 'Seed Task',
22
+ description: '',
23
+ status: 'backlog',
24
+ createdAt: now,
25
+ updatedAt: now,
26
+ ...overrides,
27
+ } as BoardTask
28
+ saveTasks(tasks)
29
+ }
30
+
31
+ afterEach(() => {
32
+ saveTasks(originalTasks)
33
+ })
34
+
35
+ test('PUT /api/tasks/:id rejects a non-string title with a 400', async () => {
36
+ seedTask('task-bad-title', { title: 'Original' })
37
+
38
+ const response = await putTask(new Request('http://local/api/tasks/task-bad-title', {
39
+ method: 'PUT',
40
+ headers: { 'content-type': 'application/json' },
41
+ body: JSON.stringify({ title: 42 }),
42
+ }), routeParams('task-bad-title'))
43
+
44
+ assert.equal(response.status, 400)
45
+ const stored = loadTasks()['task-bad-title']
46
+ assert.equal(stored.title, 'Original', 'stored title must be unchanged')
47
+ })
48
+
49
+ test('PUT /api/tasks/:id partial update does not clobber untouched fields', async () => {
50
+ seedTask('task-partial', {
51
+ title: 'Keep me',
52
+ description: 'Keep me too',
53
+ status: 'queued',
54
+ })
55
+
56
+ const response = await putTask(new Request('http://local/api/tasks/task-partial', {
57
+ method: 'PUT',
58
+ headers: { 'content-type': 'application/json' },
59
+ body: JSON.stringify({ description: 'updated' }),
60
+ }), routeParams('task-partial'))
61
+
62
+ assert.equal(response.status, 200)
63
+ const body = await response.json()
64
+ assert.equal(body.title, 'Keep me')
65
+ assert.equal(body.description, 'updated')
66
+ assert.equal(body.status, 'queued')
67
+ })
68
+
69
+ test('PUT /api/tasks/:id rejects a non-array blockedBy with a 400', async () => {
70
+ seedTask('task-bad-blocked', { title: 'T', blockedBy: ['dep-1'] })
71
+
72
+ const response = await putTask(new Request('http://local/api/tasks/task-bad-blocked', {
73
+ method: 'PUT',
74
+ headers: { 'content-type': 'application/json' },
75
+ body: JSON.stringify({ blockedBy: 'not_an_array' }),
76
+ }), routeParams('task-bad-blocked'))
77
+
78
+ assert.equal(response.status, 400)
79
+ const stored = loadTasks()['task-bad-blocked']
80
+ assert.deepEqual(stored.blockedBy, ['dep-1'], 'stored blockedBy must be unchanged')
81
+ })