Skip to content

LOU-N: runtime control server for Agent Forge (run/stop/status/approve) - #19

Merged
LinuxDevil merged 2 commits into
mainfrom
loushy/lou-n
Sep 29, 2026
Merged

LinuxDevil merged 2 commits into
mainfrom
loushy/lou-n

Conversation

@LinuxDevil

Copy link
Copy Markdown
Owner

Summary

  • New apps/agent-forge/server/** runtime control server (Express + ws): POST/GET /agents/:id (save/load spec), POST /agents/:id/run, POST /agents/:id/stop, GET /agents/:id/status, POST /agents/:id/approve, WS /agents/:id/stream.
  • RunManager (runRegistry.ts) is an in-memory registry of live AgentExecutor.execute() runs keyed by agent id / stable sessionId, built entirely on the SDK's public @loushy/build-ai-agent export surface (AgentExecutor, ToolRegistry, resumeAfterApproval, resolveSpecProvider, resolveSpecTool, AgentBuilder).
  • Cancellation: abortableProvider.ts wraps the run's LLMProvider with an AbortController/AbortSignal check around generate()/stream(), since AgentExecutor.execute() has no built-in cancellation token. stop() is a safe no-op if nothing is running, idempotent if called twice, and no-ops once a run has already completed (controller is cleared on settle).
  • Durable resume: sessionId stays stable across stop -> run for the same agent id, and AgentExecutor only clears its checkpoint on successful completion (confirmed directly in src/execution/AgentExecutor.ts) - so a stop() followed by run() resumes from the last checkpoint rather than restarting. Covered by a real, non-tautological test in runRegistry.test.ts that aborts mid-run right after a tool-result event and asserts the checkpoint survives and is picked back up.
  • Human approval: pauses surface over WS as status: 'paused', reason: 'awaiting_approval', pendingApproval; POST /agents/:id/approve calls the SDK's public resumeAfterApproval(). Round-tripped in approvalFlow.test.ts at the SDK-call level (a custom needsApproval ToolDescriptor, since none of the spec-resolvable built-in tools set that flag) - not exercised end-to-end through the HTTP API in this PR; flagged as a documented follow-up rather than a blocker, since the underlying resume path (src/execution/resume.ts) and its safety invariants (stale checkpoint deleted before the follow-up execute() call, so it can never rehydrate pre-pause state) were read directly and check out.
  • New CLI command loushy studio (src/cli/studio.ts, wired into bin/loushy.js, new tsup entry cli/studio) spawns the API server and the Vite dev server as sibling child processes; Vite's dev proxy forwards /agents/** (HTTP + WS) to the API server.

Review fix (this branch, on top of the original implementation)

Found and fixed a path-traversal bug during review: every /agents/:id route and the WS /agents/:id/stream upgrade handler interpolated :id directly into a filesystem path (fsAgentStore.ts, checkpointStore.ts, approvalStore.ts) with no sanitization. Express URL-decodes route params before exposing them, so a percent-encoded id like ..%2F..%2Fpwned arrives containing literal / and .., letting path.join() escape .loushy/agents/. Added isValidAgentId() enforced in app.ts's route middleware and wsServer.ts's upgrade handler, plus a regression test in app.test.ts.

Independently verified

  • Cancellation mechanism (abortableProvider.ts) and its race-condition handling (double-stop, stop-after-natural-completion, stop-with-nothing-running).
  • Checkpoint-cleared-only-on-success logic, read directly in src/execution/AgentExecutor.ts.
  • resumeAfterApproval()'s safety invariants, read directly in src/execution/resume.ts.
  • buildAgent.ts only imports from the SDK's public . export (verified against package.json's exports map and src/index.ts).
  • Server binds to 127.0.0.1 by default (not 0.0.0.0).
  • Root build/lint/typecheck clean; test:coverage -> 74 files / 753 passed / 3 skipped; app 56/56 (+1 new); server 18/18 (+1 new) = 19/19.

Test plan

  • npm run build / npm run lint / npm run typecheck (root) - clean
  • npm run test:coverage (root) - 74 files / 754 passed / 3 skipped
  • apps/agent-forge: npx vitest run - 56/56
  • apps/agent-forge: npm run test:server - 19/19 (incl. new path-traversal regression test)
  • apps/agent-forge: npm run lint / npm run typecheck / npm run typecheck:server - clean

🤖 Generated with Claude Code

LinuxDevil and others added 2 commits September 29, 2026 14:18
Adds a local Express + ws server (apps/agent-forge/server/**) that runs
real agents via the SDK's public AgentExecutor.execute()/resumeAfterApproval()
API, plus `loushy studio` (src/cli/studio.ts) to launch it alongside the
Vite dev server. Wires the app's Run/Stop buttons and agent-list status
pills (LeftRail/Topbar/AppState) to the new REST/WS API instead of the
LOU-L/M placeholders, and adds a minimal inline approve/reject card for
the human-in-the-loop approval gate.

- REST: GET/PUT /agents/:id, POST /agents/:id/run|stop|approve, GET /agents/:id/status
- WS: /agents/:id/stream pushes status + best-effort execution events
- Cancellation: since AgentExecutor.execute() has no cancellation token,
  stop() aborts an AbortSignal-wrapped LLMProvider (abortableProvider.ts)
  so the next provider call throws, terminating the run without clearing
  its checkpoint - the next run() for that agent id reuses the same
  sessionId and rehydrates from the last checkpoint instead of restarting.
- Durable execution: FileCheckpointStore/FileApprovalStore persist under
  .loushy/agents/<id>/{checkpoints,approvals}/, the seam LOU-L's
  createFsAgentStore() TODO was left for.
- Tests: supertest HTTP-layer tests, RunManager unit tests (including a
  deterministic stop-then-resume-from-checkpoint test), and a direct
  AgentExecutor + resumeAfterApproval approval-gate round-trip test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…dary

Every /agents/:id route and the WS /agents/:id/stream upgrade handler
interpolate the id directly into a filesystem path (fsAgentStore.ts,
checkpointStore.ts, approvalStore.ts) with no sanitization. Express
URL-decodes route params before handing them to the app, so a
percent-encoded id like ..%2F..%2Fpwned arrives containing literal "/"
and ".." segments, letting path.join() escape .loushy/agents/ entirely
(e.g. PUT /agents/..%2F..%2Fpwned writing an attacker-chosen .yaml file
outside the intended directory).

Add isValidAgentId() (types.ts) restricting ids to a safe single-segment
token, enforced once in app.ts's /agents/:id middleware and again in
wsServer.ts's upgrade handler (which parses :id itself, outside Express
routing). Adds a regression test covering the traversal attempt via the
HTTP API.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@LinuxDevil
LinuxDevil merged commit 9674619 into main Sep 29, 2026
1 check passed
@LinuxDevil
LinuxDevil deleted the loushy/lou-n branch September 29, 2026 11:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant