Compare commits
13 Commits
v1.13.16-x
...
v2.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
| ce31577d1e | |||
| 006226cce5 | |||
| 62d818af23 | |||
| 531d39ace9 | |||
| f2974d6887 | |||
| 29c7d051b6 | |||
| d27a977d59 | |||
| 5692e99a5d | |||
| f4a97808ad | |||
| 211e903620 | |||
| ad45b28250 | |||
| 1a889dcde3 | |||
| b52c5df705 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,3 +10,4 @@ secrets/
|
|||||||
data/*
|
data/*
|
||||||
!data/AGENTS.md
|
!data/AGENTS.md
|
||||||
!data/skills/
|
!data/skills/
|
||||||
|
!data/mcp.json
|
||||||
|
|||||||
@@ -28,6 +28,13 @@
|
|||||||
- Prefer codecontext (`search_symbols`, `get_symbol_info`, `get_dependencies`) over `grep` for symbol-level questions. Fall back to `grep` / `view_file` when codecontext returns degraded or empty results — that signals an unsupported language or parse failure.
|
- Prefer codecontext (`search_symbols`, `get_symbol_info`, `get_dependencies`) over `grep` for symbol-level questions. Fall back to `grep` / `view_file` when codecontext returns degraded or empty results — that signals an unsupported language or parse failure.
|
||||||
- Verify before reporting work complete: run the relevant test/build/smoke command and confirm output matches the claim. Evidence first, assertion second.
|
- Verify before reporting work complete: run the relevant test/build/smoke command and confirm output matches the claim. Evidence first, assertion second.
|
||||||
|
|
||||||
|
## Output format
|
||||||
|
|
||||||
|
- Stay in Markdown by default for every reply, short or long.
|
||||||
|
- Switch to a self-contained `<!DOCTYPE html>...</html>` artifact only when the user explicitly asks (e.g. "render this as HTML", "make me a dashboard", "build an interactive diagram"). Detection is opportunistic — the BooChat backend tags the assistant message as an HTML artifact, opens it in a sandboxed pane, and offers Download. Do not emit HTML unprompted; long Markdown is the right answer for most explanatory output.
|
||||||
|
- When asked to produce HTML, avoid generic AI aesthetics: no excessive centered layouts, no purple gradients, no uniform rounded corners, no Inter font. Prefer interactive controls (sliders / knobs / SVG / side-by-side diffs) over passive prose-in-HTML. Pattern reference: claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html (Thariq Shihipar, May 2026).
|
||||||
|
- The HTML artifact is rendered in a sandboxed iframe with `connect-src 'none'` — `fetch()`, WebSockets, and tracking pixels do not work. All logic must be client-side.
|
||||||
|
|
||||||
## Convention: rules vs recipes
|
## Convention: rules vs recipes
|
||||||
|
|
||||||
Always-true rules (process discipline, refusals, behavior contracts) live here in `BOOCHAT.md` — and in `BOOCODER.md` / `CLAUDE.md` per their scopes — where they are 100% present in every turn. On-demand recipes (specific procedures, scaffolds, checklists) live in `/data/skills/` and invoke roughly 6% of the time in clean multi-turn flow (Codeminer42 measurement, 2026). Don't file workflow rules as skills — they silently misfire. See Anthropic agent-skills best-practices (platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices) for the canonical conventions.
|
Always-true rules (process discipline, refusals, behavior contracts) live here in `BOOCHAT.md` — and in `BOOCODER.md` / `CLAUDE.md` per their scopes — where they are 100% present in every turn. On-demand recipes (specific procedures, scaffolds, checklists) live in `/data/skills/` and invoke roughly 6% of the time in clean multi-turn flow (Codeminer42 measurement, 2026). Don't file workflow rules as skills — they silently misfire. See Anthropic agent-skills best-practices (platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices) for the canonical conventions.
|
||||||
|
|||||||
41
BOOCODER.md
41
BOOCODER.md
@@ -1,27 +1,32 @@
|
|||||||
# BooCoder
|
# BooCoder — Container Guidance
|
||||||
|
|
||||||
> (Stub. v2.0 implementation pending. This file documents the intended contract.)
|
You are BooCoder, a write-capable coding agent. You can read AND modify files within the project scope.
|
||||||
|
|
||||||
## Capabilities
|
## You can
|
||||||
|
|
||||||
- Everything in `BOOCHAT.md`
|
- Read files (view_file, list_dir, grep, find_files)
|
||||||
- Write tools (pending): `write_file`, `edit_file`, `delete_file` (all gated through pending-changes sandbox)
|
- Edit files (edit_file, create_file, delete_file) — all changes queue in pending_changes
|
||||||
- Shell (pending): `run_command` (Docker-isolated per-session)
|
- Apply pending changes to disk (apply_pending)
|
||||||
|
- Revert applied changes (rewind)
|
||||||
|
- Dispatch tasks to external agents (dispatch_external_agent)
|
||||||
|
- Use MCP tools from configured servers
|
||||||
|
|
||||||
## Constraints
|
## You cannot
|
||||||
|
|
||||||
- All writes land in a pending-changes virtual layer; nothing touches the real filesystem until `/apply`
|
- Write outside the project root (path-guard enforced)
|
||||||
- `run_command` executes inside the session sandbox, not the host
|
- Write to secret files (.env, *.pem, id_rsa*, credentials.json)
|
||||||
- No git commits, pushes, or pulls — Sam owns those
|
- Apply changes without explicit user approval (unless auto-apply is enabled per task)
|
||||||
- Stop and ask before destructive operations (delete, overwrite, recreate)
|
- Push to git remotes
|
||||||
|
- Access the internet except via configured MCP servers
|
||||||
|
|
||||||
|
## Pending changes discipline
|
||||||
|
|
||||||
|
Every file modification queues in `pending_changes` before touching disk. The user sees a diff preview and approves/rejects each change. Never bypass this queue — it is the safety boundary between inference and the filesystem.
|
||||||
|
|
||||||
## Behavior
|
## Behavior
|
||||||
|
|
||||||
- Show a diff preview before any write
|
- Show diffs clearly. Explain what you're changing and why.
|
||||||
- Group related edits into a single `/apply` batch
|
- For multi-file changes, organize as a logical unit (one task = one coherent change set).
|
||||||
- If a tool fails, surface the error verbatim — don't paper over it
|
- If uncertain about scope, use smaller edits and verify between steps.
|
||||||
|
- Cite file paths + line numbers for context.
|
||||||
- Verify before reporting work complete: run the relevant test/build/smoke and confirm output matches the claim. Evidence first, assertion second.
|
- Verify before reporting work complete: run the relevant test/build/smoke and confirm output matches the claim. Evidence first, assertion second.
|
||||||
|
|
||||||
## Convention: rules vs recipes
|
|
||||||
|
|
||||||
Always-true rules live here, in `BOOCHAT.md`, and in `CLAUDE.md` (100% present each turn). On-demand recipes live in `/data/skills/` (roughly 6% invoke rate in multi-turn per Codeminer42, 2026). Don't file workflow rules as skills — they misfire. See Anthropic agent-skills best-practices (platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices).
|
|
||||||
|
|||||||
32
CHANGELOG.md
32
CHANGELOG.md
@@ -2,6 +2,38 @@
|
|||||||
|
|
||||||
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
||||||
|
|
||||||
|
## v1.16.0-codesight-merge — 2026-05-24
|
||||||
|
|
||||||
|
Ports codesight's highest-value analysis capabilities into the codecontext sidecar as 4 new MCP tools. Tier 1 (graph queries on existing edges, no re-parsing): `get_blast_radius` (BFS reverse-edge traversal — "what breaks if I change this file?", with depth tracking) and `get_hot_files` (most-imported files ranked by incoming edge count — change-risk indicators). Tier 2 (tree-sitter AST re-parsing on demand): `get_routes` (Fastify/Express HTTP route extraction with method, path, file, line, inferred tags for db/auth/cache) and `get_middleware` (middleware registration detection via import-name heuristics and app.register/addHook/setErrorHandler patterns, classifying as auth/cors/rate-limit/security/error-handler/logging/validation). All 4 tools use `defer s.graphMu.RUnlock()` for consistent mutex discipline (reviewer caught that the initial implementation released the lock early on the Tier 2 tools). Route object-property extraction delegates to `extractStringValue` for template-literal handling (reviewer catch). codecontext sidecar rebuilt from `/opt/forks/codecontext` commit `b19e646`, tagged `v1.16.0-codesight-merge`. BooCode wrapper tools follow the existing codecontext pattern — 4 new files in `apps/server/src/services/tools/codecontext/`, registered in ALL_TOOLS. 29 new Go tests + 363/363 BooCode server tests passing. No schema changes, no frontend changes.
|
||||||
|
|
||||||
|
## v1.15.0-mcp-multi — 2026-05-24
|
||||||
|
|
||||||
|
Multi-server MCP client with stdio + Streamable HTTP transports, JSON config file, and per-agent tool glob patterns. Generalizes the v1.14.1 single-server Context7 PoC into a registry of named MCP servers with per-server graceful degradation. JSON config at `/data/mcp.json` (bind-mounted alongside `AGENTS.md`) matches opencode's `mcpServers` schema shape so server entries are copy-pasteable. Config file missing = no MCP (opt-in by file presence). Stdio transport spawns a persistent subprocess via the SDK's `StdioClientTransport` with NDJSON framing; Streamable HTTP reuses the v1.14.1 pattern via `StreamableHTTPClientTransport`. Tool prefix generalized from `context7_<name>` to `<serverName>_<toolName>` with a reverse `toolToServer` map for dispatch routing. Per-agent AGENTS.md `tools:` field now supports glob patterns (`context7_*`, `!web_*`) via `matchToolGlob` (last-match-wins, `!` prefix denies); replaces the exact-match `.includes()` in `stream-phase.ts`. Glob patterns bypass `ALL_TOOL_NAMES` validation in the parser since MCP tool names aren't known at parse time. `refreshToolNames()` in `agents.ts` rebuilds the `DEFAULT_TOOLS` snapshot after `appendMcpTools` so agents without explicit `tools:` lists see MCP tools — reviewer caught that the module-load-time snapshot would permanently exclude late-registered tools. Read-only invariant preserved: all MCP tools with `readOnlyHint: false` rejected at discovery. Result size capped at 5MB. Shutdown hook closes all transports. v1.14.1 env vars (`MCP_CONTEXT7_URL`, `MCP_CONTEXT7_API_KEY`) removed — superseded by the config file. Default `data/mcp.json` ships with Context7 disabled; flip `"enabled": true` to activate. 363/363 server tests passing (27 new: multi-server wrapping, glob matching, routing, degradation). No schema changes, no frontend changes.
|
||||||
|
|
||||||
|
## v1.14.1-mcp-poc — 2026-05-23
|
||||||
|
|
||||||
|
Single-server MCP client PoC against Context7. New `apps/server/src/services/mcp-client.ts` (~200 lines) wraps `@modelcontextprotocol/sdk` v1.29.0 with Streamable HTTP transport. On startup (when `MCP_CONTEXT7_URL` is set), connects to Context7, discovers tools via `tools/list`, wraps each as a `ToolDef` prefixed `context7_<name>`, and appends to `ALL_TOOLS` (alpha-sorted for prompt-cache stability). `appendMcpTools()` in `tools.ts` handles the late-registration; `ALL_TOOLS` changed from `ReadonlyArray` to mutable to support it. Read-only invariant guard rejects any MCP tool with `readOnlyHint: false` (MCP SDK v1.29.0 uses `readOnlyHint`, not `readOnly`). Tool dispatch is transparent — `executeToolCall` routes MCP tool calls through the `ToolDef.execute` wrapper, which strips the `context7_` prefix before calling the MCP server. Graceful degradation: MCP server down at startup → zero tools, warn log; MCP server down mid-session → error-shaped result, model self-corrects. Result size capped at 5MB with truncation (matches native `view_file`'s `MAX_FILE_BYTES`). Adversarial review caught that the Zod `.default('https://...')` on the URL config made MCP effectively always-on instead of opt-in — fixed by removing the default. 348/348 server tests passing (16 new mcp-client tests covering tool wrapping, read-only guard, name prefixing, content extraction). No schema changes, no frontend changes. Proves the MCP tool-discovery → tool-call → result-render loop end-to-end before the full v1.15 port.
|
||||||
|
|
||||||
|
## v1.14.0-outer-loop — 2026-05-23
|
||||||
|
|
||||||
|
Converts the inference engine's ad-hoc `executeToolPhase → runAssistantTurn` recursion into an explicit `while` loop with a configurable step cap. A step is one stream-and-tool-execute iteration; the loop terminates on non-tool finish, step-cap hit, doom-loop, budget exhaustion, abort, or synthesis success. `MAX_STEPS = 200` is the hard ceiling (4x the old effective limit from budget); per-agent `steps:` field in AGENTS.md frontmatter sets tighter caps (Refactorer: 5, Architect: 20, others: unset = bounded only by MAX_STEPS). `executeToolPhase` no longer recurses — returns a `ToolPhaseResult` struct (`action: 'continue' | 'paused' | 'synthesis_done'`) so the caller (the while loop) decides whether to continue or break. `steps: 0` is handled as "no tool calls allowed" — one text-only stream phase, tool calls ignored with a warn log. Step-cap hits produce a sentinel summary (reuses `cap_hit` kind so `CapHitSentinel.tsx` renders it without frontend changes; text distinguishes "Step limit reached" from "Tool budget exhausted"). Doom-loop check migrated from pre-recursion position to top of loop body — same predicate (`detectDoomLoop`), same threshold (3 identical calls), `break` instead of `return`. `step_start` parts are in the schema CHECK but not emitted as message_parts in v1.14 — writing to the assistant message before the stream phase creates a sequence-0 collision with `partsFromAssistantMessage`; a structured log line is emitted instead. Adversarial review caught the collision pre-deploy. 332/332 server tests passing; no frontend changes. Pairs with `v1.13.20-drop-legacy-cols` (parts is now the sole source of truth, and this batch's loop operates entirely through parts).
|
||||||
|
|
||||||
|
## v1.13.20-drop-legacy-cols — 2026-05-23
|
||||||
|
|
||||||
|
Final phase of the v1.13.0 strangler-fig migration. Removes the dual-write into `messages.tool_calls` / `messages.tool_results` JSON columns and drops the columns themselves; `message_parts` is now the only source of truth for tool-call and tool-result data. 10 dual-write sites stripped (5 in `tool-phase.ts`, 2 in `routes/skills.ts`, 2 in `routes/messages.ts`, 1 in `routes/chats.ts` fork-clone) — recon's grep-driven inventory caught 2 sites beyond the original v1.13.2 roadmap count. `messages_with_parts` view simplified to parts-only subselects (COALESCE fallbacks gone) and rewritten via `CREATE OR REPLACE VIEW` BEFORE the column DROP since Postgres rejects column-drop on view-referenced cols. Adversarial review caught a runtime bug the green test suite missed: `chats.ts:/api/chats/:id/discard_stale` had a `RETURNING ... tool_calls, tool_results, ...` clause referencing the dropped columns; would have crashed on every 60s-no-token-activity recovery in production. Fixed by switching to two-step UPDATE-then-SELECT-from-view so the response keeps the parts-synthesized fields. `Message` API type retains `tool_calls?` / `tool_results?` fields (override on the original v1.13.2 plan) — the view continues to populate them from parts, so the wire shape is unchanged and the frontend needs no updates. v1.12.1 cleanup block (`DROP CONSTRAINT messages_status_check`/`messages_role_check`) removed — those one-shots have done their work. `tool_cost_stats.test.ts` had a direct `INSERT INTO messages` touching the legacy columns that wasn't in the roadmap's inventory; rewritten to parts-table inserts and confirmed semantically faithful. 339/339 server tests passing including the 7 DB-integration tests (live-DB applied the schema migration and ran the parts-only view end-to-end). Pairs with `v1.13.0-ai-sdk-v6` (which introduced the dual-write) and `v1.13.1-B` (which moved the read path to `messages_with_parts`); umbrella `v1.13` tag ships on the same commit.
|
||||||
|
|
||||||
|
## v1.13.19-html-artifact-panes — 2026-05-23
|
||||||
|
|
||||||
|
Pane-based artifact viewer with on-request HTML support. Every assistant message gets an "Open in pane" icon button (`PanelRightOpen`, mobile 44px tap-target) in `MessageBubble`'s ActionRow; click opens the message in the workspace splitter as either a Markdown pane (Copy raw source + Download `.md`) or an HTML pane (Download `.html` only, no Copy). The HTML path triggers when the model emits a self-contained `<!DOCTYPE html>` or fenced ` ```html` artifact (opt-in only — `BOOCHAT.md` rule says Markdown is default at every length; HTML only on explicit user request like "render this as HTML"). Backend detection in `finalizeCompletion` (`error-handler.ts`) writes a new `message_parts.kind='html_artifact'` row with payload `{html_content, char_count, title}` (`<title>` → first `<h1>` → first 80 chars of inner text). Schema CHECK extended via the v1.13.13 drop-and-re-add pattern. 1MB cap is graceful — over-cap artifacts skip the part write and plain content lands; decision factored into a pure `decideHtmlArtifactWrite` helper so the warn-and-skip branch is unit-testable without mocking the full InferenceContext. Pane state is reference-only (`{chat_id, message_id, title}`) — content is fetched on mount, keeping `sessions.workspace_panes` jsonb small and avoiding 1MB blobs riding the `session_workspace_updated` WS frame. New `services/artifacts.ts` ships slug derivation (Markdown: first `#` heading → first 6 words; HTML: `<title>` → `<h1>` → inner text) and write helpers that realpath the artifacts directory after `mkdir` to close a symlink-escape gap (`assertArtifactsDirSafe`). `routes/artifacts.ts` exposes POST `/api/chats/:id/messages/:msg_id/artifacts/download?fmt=md|html` (writes to `<projectRoot>/.boocode/artifacts/<slug>-<ts>.<ext>`) plus GET `/api/projects/:project_id/artifacts/:filename` with `Content-Disposition: attachment`, `X-Content-Type-Options: nosniff`, and `Content-Security-Policy: sandbox` defense-in-depth on LLM-served HTML. iframe sandbox locks to `allow-scripts allow-clipboard-write allow-downloads` with no `allow-same-origin` and uses `srcDoc` (not `src`) for opaque-origin isolation. Frontend extracts `MarkdownRenderer.tsx` from `MessageBubble`'s inline `MarkdownBody` for reuse; `MarkdownArtifactPane.tsx` / `HtmlArtifactPane.tsx` render with loading + error states. 404-vs-real-error discrimination in `openInPane`: a real network/500 failure toasts and bails instead of silently masquerading as a Markdown pane. 31 new server unit tests (slug derivation, detection positive/negative, write helpers, symlink-escape, 1MB cap, real-symlink filesystem test); 332/332 server tests passing; `tsc -p apps/web/tsconfig.app.json --noEmit` clean; `pnpm -C apps/web build` green. Smoke deferred to first deploy.
|
||||||
|
|
||||||
|
## v1.13.18-codecontext-file-path — 2026-05-22
|
||||||
|
|
||||||
|
Fix: four codecontext wrappers (`get_file_analysis`, `get_symbol_info`, `get_dependencies`, `get_semantic_neighborhoods`) forwarded `file_path` to the sidecar unchanged, but the sidecar's index is keyed on absolute paths — every relative path from the model returned "File not found in graph" (three back-to-back failures in one chat at 17:56 UTC, ~48 s of wasted tool budget). New `resolveProjectPath` helper in `codecontext_client.ts:64-89` realpath-resolves the candidate, applies the same escape check as the existing `target_dir` resolver (matching the error template byte-for-byte except the field name), and falls through with the normalised absolute on ENOENT so the sidecar issues its own self-correctable "File not found" error. Wired into `callCodecontext` once at the args-spread site — all four wrappers benefit without per-wrapper edits. `.trim()` added to all four `file_path` Zod schemas to absorb trailing newlines from model output. Adversarial review caught a P2 escape-bypass: an absolute path with `..` (e.g. `<projectRoot>/../etc/passwd`) that ENOENTs at realpath would slip through the literal prefix-check, fixed by `resolve()`-normalising the absolute branch too. 9 new test cases in `codecontext_client.test.ts` (7 spec scenarios + symlink-out-of-root + absolute-with-`..` ENOENT) plus a 1-line update in `codecontext_tools.test.ts` asserting the new resolved-absolute contract. Pairs with `v1.13.17-cross-repo-reads` — both harden path traversal, but v1.13.18 stays inside the project root while v1.13.17 widens access outside it.
|
||||||
|
|
||||||
|
## v1.13.17-cross-repo-reads — 2026-05-22
|
||||||
|
|
||||||
|
On-demand read access to paths outside the session's primary project root. Closes the dead-end where `pathGuard` rejected every cross-repo read with no recovery path. New `request_read_access(path, reason)` tool emits an `ask_user_input`-style pause; user picks Allow/Deny via inline chips in `RequestReadAccessCard.tsx`; on Allow, the new `POST /api/chats/:id/grant_read_access` endpoint re-resolves the grant root and appends to `sessions.allowed_read_paths` (new `TEXT[]` column, default empty). Grant unit per design D1 = nearest registered `projects.path` ancestor → else nearest repo-shaped ancestor (`.git/` / `package.json` / `go.mod` / `Cargo.toml`) under `PROJECT_ROOT_WHITELIST` → else refuse without prompting. `pathGuard` extended with an optional `extraRoots` argument threaded from `session.allowed_read_paths` through `executeToolCall` to the four filesystem tools (view_file, list_dir, grep, find_files); `view_file` re-anchors the secret-guard check on `basename(real)` whenever the path resolved via a grant root so `.env` / `id_rsa*` deny still fires across grants. `grant_resolver.ts`'s ancestor walk checks the whitelist invariant on every iteration (not just final parent) so a symlinked input can't escape mid-walk. PATCH `/api/sessions/:id` exposes `allowed_read_paths` only for revocation: zod refines paths to absolute + no traversal markers, and a runtime subset guard (`findUnauthorizedAdditions`) rejects any entry not already present in the row, so a malicious `curl -X PATCH -d '{"allowed_read_paths":["/etc"]}'` 400s instead of bypassing the grant flow. Settings pane gains a per-session revoke list; archiving the session clears grants implicitly. 11 grant_resolver tests pin the symlink-escape-mid-walk guard (Sam's checkpoint-1 ask) and the nearest-project disambiguation; 8 path_guard tests cover extraRoots traversal; 8 sessions PATCH tests cover the subset guard including the `/etc` bypass attempt. Pairs with `v1.13.16-xml-parser` (model now both self-recovers from a wrong tool name AND from a refused path).
|
||||||
|
|
||||||
## v1.13.16-xml-parser — 2026-05-22
|
## v1.13.16-xml-parser — 2026-05-22
|
||||||
|
|
||||||
Two-part fix for the model-emitted XML drift the v1.13.15 investigation surfaced. **Parser extension:** `xml-parser.ts` now recognizes the Anthropic `<invoke name="…"><parameter name="…">…</parameter></invoke>` shape alongside the existing Qwen/Hermes `<tool_call><function=…>…</function></tool_call>` shape. qwen3.6-35b-a3b-mxfp4 drifts to the Anthropic format when prompted as an Architect-style agent (Claude Code documentation in its pre-training corpus). Both formats route through the same synthetic-id `xml_call_${idx}` ToolCall path. The existing Qwen parser was tightened to tolerate whitespace around `=` (`<function = name>` shape) so a stray space doesn't get absorbed into the function name. **Unknown-tool recovery hint:** new `tool-suggestions.ts` exports `levenshtein()` + `suggestToolName()` + `formatUnknownToolError()`. When the dispatcher (`tool-phase.ts:executeToolCall`) receives an unknown tool name, the error returned to the model includes a "Did you mean: X?" hint based on Levenshtein distance ≤3 or substring match against `Object.keys(TOOLS_BY_NAME)`. Targets the qwen3.6 drift to `read_file` → suggest `view_file`. Test coverage in `xml-parser.test.ts` (46 tests, all green) covers both parsers, the partial-opener detector for both flavors, the unified extraction helper, and the new error formatter.
|
Two-part fix for the model-emitted XML drift the v1.13.15 investigation surfaced. **Parser extension:** `xml-parser.ts` now recognizes the Anthropic `<invoke name="…"><parameter name="…">…</parameter></invoke>` shape alongside the existing Qwen/Hermes `<tool_call><function=…>…</function></tool_call>` shape. qwen3.6-35b-a3b-mxfp4 drifts to the Anthropic format when prompted as an Architect-style agent (Claude Code documentation in its pre-training corpus). Both formats route through the same synthetic-id `xml_call_${idx}` ToolCall path. The existing Qwen parser was tightened to tolerate whitespace around `=` (`<function = name>` shape) so a stray space doesn't get absorbed into the function name. **Unknown-tool recovery hint:** new `tool-suggestions.ts` exports `levenshtein()` + `suggestToolName()` + `formatUnknownToolError()`. When the dispatcher (`tool-phase.ts:executeToolCall`) receives an unknown tool name, the error returned to the model includes a "Did you mean: X?" hint based on Levenshtein distance ≤3 or substring match against `Object.keys(TOOLS_BY_NAME)`. Targets the qwen3.6 drift to `read_file` → suggest `view_file`. Test coverage in `xml-parser.test.ts` (46 tests, all green) covers both parsers, the partial-opener detector for both flavors, the unified extraction helper, and the new error formatter.
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ Tests: `pnpm -C apps/server test` runs the vitest suite. No test harness on `app
|
|||||||
- **Zod** for request validation and config parsing.
|
- **Zod** for request validation and config parsing.
|
||||||
|
|
||||||
Key services:
|
Key services:
|
||||||
- **`services/inference/`** — Public surface re-exported via `inference/index.ts`; callers import from `./services/inference/index.js` explicitly (NodeNext doesn't honor directory-index resolution). Layout: `turn.ts` (runAssistantTurn / runInference / createInferenceRunner; exports `InferenceFrame`, `InferenceContext`, `TurnArgs`, `StreamResult`), `stream-phase.ts` (streamCompletion as a v1.13.1-A AI SDK adapter + executeStreamPhase), `provider.ts` (`upstreamModel(baseURL, modelId)` wrapping `createOpenAICompatible` against llama-swap), `tool-phase.ts` (executeToolPhase; value back-edges into turn.ts for the runAssistantTurn recursion — cycle safe because deref at call time, not module top-level), `sentinel-summaries.ts` (runCapHitSummary + runDoomLoopSummary + their sentinel inserters), `error-handler.ts` (handleAbortOrError, finalizeCompletion), `payload.ts` (buildMessagesPayload, loadContext, maybeFlagForCompaction, `OpenAiMessage`), `sentinels.ts` (`detectDoomLoop`, `DOOM_LOOP_THRESHOLD`, sentinel predicates), `budget.ts` (resolveToolBudget), `xml-parser.ts` (qwen3.6 XML tool-call fallback — KEEP, AI SDK doesn't handle inline-XML tool calls), `parts.ts` (v1.13.0 dual-write helpers: `partsFromAssistantMessage`, `partsFromToolMessage`, `insertParts`), `prune.ts` (v1.13.4 two-tier compaction; `selectPruneTargets` is the pure decision helper), `types.ts` (`StreamPhaseState`, `DB_FLUSH_INTERVAL_MS`). **`TurnArgs`** is the per-turn state envelope threaded through the `executeToolPhase → runAssistantTurn` recursion; reset in `runInference` at user-message boundary. Add new per-turn state to `TurnArgs`, not module-level closures.
|
- **`services/inference/`** — Public surface re-exported via `inference/index.ts`; callers import from `./services/inference/index.js` explicitly (NodeNext doesn't honor directory-index resolution). Layout: `turn.ts` (runAssistantTurn / runInference / createInferenceRunner; exports `InferenceFrame`, `InferenceContext`, `TurnArgs`, `StreamResult`, `MAX_STEPS`), `stream-phase.ts` (streamCompletion as a v1.13.1-A AI SDK adapter + executeStreamPhase), `provider.ts` (`upstreamModel(baseURL, modelId)` wrapping `createOpenAICompatible` against llama-swap), `tool-phase.ts` (executeToolPhase → returns `ToolPhaseResult`; no longer recurses into runAssistantTurn — v1.14.0 converted the recursion to an explicit while loop in turn.ts), `sentinel-summaries.ts` (runCapHitSummary + runDoomLoopSummary + runStepCapSummary + their sentinel inserters), `error-handler.ts` (handleAbortOrError, finalizeCompletion), `payload.ts` (buildMessagesPayload, loadContext, maybeFlagForCompaction, `OpenAiMessage`), `sentinels.ts` (`detectDoomLoop`, `DOOM_LOOP_THRESHOLD`, sentinel predicates), `budget.ts` (resolveToolBudget), `xml-parser.ts` (qwen3.6 XML tool-call fallback — KEEP, AI SDK doesn't handle inline-XML tool calls), `parts.ts` (parts-table write helpers: `partsFromAssistantMessage`, `partsFromToolMessage`, `insertParts` — v1.13.20 made parts the sole source of truth), `prune.ts` (v1.13.4 two-tier compaction; `selectPruneTargets` is the pure decision helper), `types.ts` (`StreamPhaseState`, `DB_FLUSH_INTERVAL_MS`). **`TurnArgs`** is the per-turn state envelope populated from loop locals each iteration; reset in `runInference` at user-message boundary. The outer loop in `runAssistantTurn` (v1.14.0) runs `while (stepNumber < effectiveCap)` where `effectiveCap = Math.min(agent.steps ?? Infinity, MAX_STEPS=200)`. Per-agent `steps:` field in AGENTS.md frontmatter. `steps: 0` means text-only (no tool execution). Step-cap hit writes a `cap_hit` sentinel so `CapHitSentinel.tsx` renders it.
|
||||||
- **AI SDK v6 streamCompletion adapter** (v1.13.1-A; `services/inference/stream-phase.ts`). `streamText` is the underlying call; the BooCode layer above (executeStreamPhase, finalize, dual-write) is shape-preserved via an adapter. Five gotchas the LSP/test suite won't catch:
|
- **AI SDK v6 streamCompletion adapter** (v1.13.1-A; `services/inference/stream-phase.ts`). `streamText` is the underlying call; the BooCode layer above (executeStreamPhase, finalize, dual-write) is shape-preserved via an adapter. Five gotchas the LSP/test suite won't catch:
|
||||||
- **Abort signals are swallowed.** `streamText`'s `fullStream` iterator exits cleanly when `abortSignal` fires — no throw. Post-iteration `if (signal?.aborted) throw <AbortError>` is required; without it the row finalizes as `complete` instead of `cancelled`. Comment in stream-phase.ts pins this; don't refactor it away.
|
- **Abort signals are swallowed.** `streamText`'s `fullStream` iterator exits cleanly when `abortSignal` fires — no throw. Post-iteration `if (signal?.aborted) throw <AbortError>` is required; without it the row finalizes as `complete` instead of `cancelled`. Comment in stream-phase.ts pins this; don't refactor it away.
|
||||||
- **Usage lands only at stream end** via `await result.usage` (`inputTokens` / `outputTokens` v6 names → mapped to `promptTokens` / `completionTokens` for the existing onUsage callback). Mid-stream live tok/s is gone vs v1.12.2; ChatThroughput shows a single value at stream end.
|
- **Usage lands only at stream end** via `await result.usage` (`inputTokens` / `outputTokens` v6 names → mapped to `promptTokens` / `completionTokens` for the existing onUsage callback). Mid-stream live tok/s is gone vs v1.12.2; ChatThroughput shows a single value at stream end.
|
||||||
@@ -63,7 +63,7 @@ Key services:
|
|||||||
- **`services/compaction.ts`** + **`services/model-context.ts`** — v1.11.0 anchored rolling summary (single `summary=true` assistant row per chat, supersedes itself on each compaction). Triggered when `chats.needs_compaction` is set after an inference turn exceeds `usable(ctx_max) = floor(0.85 × ctx_max)` (v1.13.9 opencode-pattern early trigger; was `ctx_max - 20k` pre-v1.13.9, which gave only 7.6% headroom at 262k and 0 budget for ≤20k contexts). **`ctx_max` comes from `model-context.getModelContext()` which fetches `${LLAMA_SWAP_URL}/upstream/<model>/props`** — NOT from `parsed.timings.n_ctx` (the stream completion's `timings` doesn't carry n_ctx; that read was dead code until v1.11.3 ripped it out). First inferences after a boocode boot may have `ctx_max=NULL` if llama-swap hasn't loaded the model yet; negative cache TTL is 60s, recovers on next turn. v1.13.6: `buildHeadPayload` embeds `reasoning_parts` as a `<reasoning>...</reasoning>` prose prefix on the assistant `content` (OpenAI wire shape has no structured reasoning field; the summarizer reads text). Standalone tag when content is empty (tool-call-only turn). `buildHeadPayload` + `OpenAiMessage` exported for test access — keep them exported.
|
- **`services/compaction.ts`** + **`services/model-context.ts`** — v1.11.0 anchored rolling summary (single `summary=true` assistant row per chat, supersedes itself on each compaction). Triggered when `chats.needs_compaction` is set after an inference turn exceeds `usable(ctx_max) = floor(0.85 × ctx_max)` (v1.13.9 opencode-pattern early trigger; was `ctx_max - 20k` pre-v1.13.9, which gave only 7.6% headroom at 262k and 0 budget for ≤20k contexts). **`ctx_max` comes from `model-context.getModelContext()` which fetches `${LLAMA_SWAP_URL}/upstream/<model>/props`** — NOT from `parsed.timings.n_ctx` (the stream completion's `timings` doesn't carry n_ctx; that read was dead code until v1.11.3 ripped it out). First inferences after a boocode boot may have `ctx_max=NULL` if llama-swap hasn't loaded the model yet; negative cache TTL is 60s, recovers on next turn. v1.13.6: `buildHeadPayload` embeds `reasoning_parts` as a `<reasoning>...</reasoning>` prose prefix on the assistant `content` (OpenAI wire shape has no structured reasoning field; the summarizer reads text). Standalone tag when content is empty (tool-call-only turn). `buildHeadPayload` + `OpenAiMessage` exported for test access — keep them exported.
|
||||||
- **`services/system-prompt.ts`** — `buildSystemPrompt` is the string-returning shim; `buildSystemPromptWithFingerprint` is the canonical impl returning `{prompt, fingerprint, drift}`. v1.13.8 instrumentation: SHA-256 of the assembled prefix is logged per `buildMessagesPayload` call (msg `prefix-fingerprint`, level=info); a `Map<sessionId, lastHash>` observer fires `prefix-drift` (level=warn) on hash change with a field-level `changed_inputs` diff. Smoke proved the prefix is byte-stable across turns in steady-state — the originally-planned `system_prompt_cache` DB table was dropped as redundant against the v1.12.0 input-layer mtime caches (BOOCHAT.md here + AGENTS.md global+per-project in `agents.ts:safeStat`).
|
- **`services/system-prompt.ts`** — `buildSystemPrompt` is the string-returning shim; `buildSystemPromptWithFingerprint` is the canonical impl returning `{prompt, fingerprint, drift}`. v1.13.8 instrumentation: SHA-256 of the assembled prefix is logged per `buildMessagesPayload` call (msg `prefix-fingerprint`, level=info); a `Map<sessionId, lastHash>` observer fires `prefix-drift` (level=warn) on hash change with a field-level `changed_inputs` diff. Smoke proved the prefix is byte-stable across turns in steady-state — the originally-planned `system_prompt_cache` DB table was dropped as redundant against the v1.12.0 input-layer mtime caches (BOOCHAT.md here + AGENTS.md global+per-project in `agents.ts:safeStat`).
|
||||||
- **`services/inference/budget.ts`** — tool-call budgets: `BUDGET_READ_ONLY = 30`, `BUDGET_NON_READ_ONLY = 10` (forward-looking; no write tools yet), `BUDGET_NO_AGENT = 30` (v1.13.7; was 15 — every tool in `ALL_TOOLS` is read-only today, so no-agent mode shares the read-only-agent cap). Per-agent `max_tool_calls` from AGENTS.md frontmatter overrides.
|
- **`services/inference/budget.ts`** — tool-call budgets: `BUDGET_READ_ONLY = 30`, `BUDGET_NON_READ_ONLY = 10` (forward-looking; no write tools yet), `BUDGET_NO_AGENT = 30` (v1.13.7; was 15 — every tool in `ALL_TOOLS` is read-only today, so no-agent mode shares the read-only-agent cap). Per-agent `max_tool_calls` from AGENTS.md frontmatter overrides.
|
||||||
- **`messages_with_parts` view** (v1.13.1-B; `schema.sql`). Read sites that need `tool_calls` / `tool_results` / `reasoning_parts` SELECT from this view, NOT `messages` directly. `COALESCE`s parts-table rows over the legacy JSON columns, so pre-v1.13.0 history still resolves. Writes still target `messages`; the v1.13.0 dual-write into `message_parts` keeps both halves in sync. New payload-assembly code must use the view — calling `messages.tool_calls` directly will miss anything written post-v1.13.1-B if the JSON column ever drifts (and dual-write makes that easy to miss). Shapes: `tool_calls jsonb[]`, `tool_results jsonb` single object, `reasoning_parts jsonb[]` of `{text}`.
|
- **`messages_with_parts` view** (v1.13.1-B; `schema.sql`). Read sites that need `tool_calls` / `tool_results` / `reasoning_parts` SELECT from this view, NOT `messages` directly. v1.13.20 dropped the legacy `messages.tool_calls` / `messages.tool_results` JSON columns; the view now reads parts-only subselects. Writes target `message_parts` exclusively via `insertParts` (or via the helpers `partsFromAssistantMessage` / `partsFromToolMessage`). The `Message` wire type still carries `tool_calls?` / `tool_results?` because the view synthesizes them from parts — frontend reads are unchanged. Shapes: `tool_calls jsonb[]`, `tool_results jsonb` single object, `reasoning_parts jsonb[]` of `{text}`. If you ever need to UPDATE a message and return its full Message shape, do a two-step UPDATE returning `id` followed by SELECT from the view — RETURNING off the bare `messages` table no longer carries the tool fields.
|
||||||
- **`services/file_ops.ts`** — Shared file operation implementations used by both inference tools and HTTP routes.
|
- **`services/file_ops.ts`** — Shared file operation implementations used by both inference tools and HTTP routes.
|
||||||
- **`services/auto_name.ts`** — Non-streaming LLM call to generate 4-word session titles after first assistant reply.
|
- **`services/auto_name.ts`** — Non-streaming LLM call to generate 4-word session titles after first assistant reply.
|
||||||
|
|
||||||
@@ -118,6 +118,8 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
|
|||||||
|
|
||||||
- Sam reviews all diffs and commits manually. Do not commit unless explicitly asked.
|
- Sam reviews all diffs and commits manually. Do not commit unless explicitly asked.
|
||||||
- Per-batch docs live under `openspec/changes/<slug>/{proposal,tasks,design}.md`. Already-shipped batches are snapshots in `openspec/changes/archived/`. New batches follow the proposal+tasks shape; see `openspec/README.md` for the convention.
|
- Per-batch docs live under `openspec/changes/<slug>/{proposal,tasks,design}.md`. Already-shipped batches are snapshots in `openspec/changes/archived/`. New batches follow the proposal+tasks shape; see `openspec/README.md` for the convention.
|
||||||
|
- Tag naming: `vMAJOR.MINOR.PATCH-slug` (e.g. `v1.13.13-ws-publish`). Monotonic per minor — the slug describes the batch's content so the tag name alone is enough to recall what shipped. No letter suffixes (`-a`/`-b`), no pseudo-ranges (`v1.11.x`), no slug-only sub-versions sharing a number (`v1.13.15-tools` + `-openspec` + `-agentlint` — split into sequential patches instead).
|
||||||
|
- `CHANGELOG.md` is the per-tag release log, most-recent on top. When a new tag is created, add a `## <tag> — <YYYY-MM-DD>` section with a 3–6 sentence paragraph summarizing what shipped, drawn from the commit body. Cross-reference other tags by name when the batch builds on, fixes, or pairs with prior work (e.g. "pairs with `v1.13.12-ws-schemas`", "fixed in `v1.13.5-stability-bundle`"). No nested bullets — one paragraph.
|
||||||
- Deploy: `cd /opt/boocode && docker compose up --build -d` (or `docker compose build --no-cache boocode && docker compose up -d` if you suspect a layer-cache issue).
|
- Deploy: `cd /opt/boocode && docker compose up --build -d` (or `docker compose build --no-cache boocode && docker compose up -d` if you suspect a layer-cache issue).
|
||||||
- Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin <branch>`. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. Transient `Connection reset by peer` retries cleanly after `sleep 5`.
|
- Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin <branch>`. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. Transient `Connection reset by peer` retries cleanly after `sleep 5`.
|
||||||
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
|
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
|
||||||
|
|||||||
32
apps/coder/Dockerfile
Normal file
32
apps/coder/Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
RUN corepack enable
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./
|
||||||
|
COPY apps/server/package.json ./apps/server/
|
||||||
|
COPY apps/coder/package.json ./apps/coder/
|
||||||
|
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Build server first (coder depends on it via workspace dep for types + inference)
|
||||||
|
COPY apps/server ./apps/server
|
||||||
|
RUN pnpm -C apps/server build
|
||||||
|
|
||||||
|
COPY apps/coder ./apps/coder
|
||||||
|
RUN pnpm -C apps/coder build
|
||||||
|
|
||||||
|
RUN pnpm deploy --filter=@boocode/coder --prod --legacy /out/coder
|
||||||
|
|
||||||
|
|
||||||
|
FROM node:20-bookworm-slim AS runtime
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends ripgrep git && rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /out/coder ./
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
28
apps/coder/package.json
Normal file
28
apps/coder/package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "@boocode/coder",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@boocode/server": "workspace:*",
|
||||||
|
"@fastify/static": "^7.0.4",
|
||||||
|
"@fastify/websocket": "^10.0.1",
|
||||||
|
"fastify": "^4.28.1",
|
||||||
|
"postgres": "^3.4.4",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.14.10",
|
||||||
|
"tsx": "^4.16.2",
|
||||||
|
"typescript": "^5.5.0",
|
||||||
|
"vitest": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
42
apps/coder/src/config.ts
Normal file
42
apps/coder/src/config.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// BooCoder's config is a superset of the server's Config type so it can be
|
||||||
|
// passed directly into the inference runner's InferenceContext. Fields the
|
||||||
|
// inference loop reads: LLAMA_SWAP_URL, PROJECT_ROOT_WHITELIST. The rest
|
||||||
|
// default to values that satisfy the server's Zod schema without BooCoder
|
||||||
|
// needing to supply them in its environment.
|
||||||
|
const ConfigSchema = z.object({
|
||||||
|
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||||
|
PORT: z.coerce.number().int().positive().default(3000),
|
||||||
|
HOST: z.string().default('0.0.0.0'),
|
||||||
|
DATABASE_URL: z.string().url(),
|
||||||
|
LLAMA_SWAP_URL: z.string().url(),
|
||||||
|
PROJECT_ROOT_WHITELIST: z.string().default('/opt'),
|
||||||
|
BOOTSTRAP_ROOT: z.string().default('/opt/projects'),
|
||||||
|
DEFAULT_MODEL: z.string().default('qwen3.6-35b-a3b-mxfp4'),
|
||||||
|
LOG_LEVEL: z.string().default('info'),
|
||||||
|
CONTAINER_GUIDANCE_FILE: z.string().optional(),
|
||||||
|
// Fields needed to satisfy the server's Config type but unused by BooCoder:
|
||||||
|
SEARXNG_URL: z.string().url().default('http://100.114.205.53:8888'),
|
||||||
|
GITEA_BASE_URL: z.string().url().default('https://git.indifferentketchup.com'),
|
||||||
|
GITEA_USER: z.string().default('indifferentketchup'),
|
||||||
|
GITEA_TOKEN: z.string().optional(),
|
||||||
|
GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'),
|
||||||
|
MCP_CONFIG_PATH: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Config = z.infer<typeof ConfigSchema>;
|
||||||
|
|
||||||
|
let cached: Config | null = null;
|
||||||
|
|
||||||
|
export function loadConfig(): Config {
|
||||||
|
if (cached) return cached;
|
||||||
|
const parsed = ConfigSchema.safeParse(process.env);
|
||||||
|
if (!parsed.success) {
|
||||||
|
console.error('Invalid environment configuration:');
|
||||||
|
console.error(parsed.error.flatten().fieldErrors);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
cached = parsed.data;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
45
apps/coder/src/db.ts
Normal file
45
apps/coder/src/db.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import postgres from 'postgres';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import type { Config } from './config.js';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
export type Sql = ReturnType<typeof postgres>;
|
||||||
|
|
||||||
|
let sqlInstance: Sql | null = null;
|
||||||
|
|
||||||
|
export function getSql(config: Config): Sql {
|
||||||
|
if (sqlInstance) return sqlInstance;
|
||||||
|
sqlInstance = postgres(config.DATABASE_URL, {
|
||||||
|
max: 10,
|
||||||
|
idle_timeout: 30,
|
||||||
|
connect_timeout: 10,
|
||||||
|
onnotice: () => {},
|
||||||
|
});
|
||||||
|
return sqlInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applySchema(sql: Sql): Promise<void> {
|
||||||
|
const schemaPath = resolve(__dirname, 'schema.sql');
|
||||||
|
const ddl = await readFile(schemaPath, 'utf8');
|
||||||
|
await sql.unsafe(ddl);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pingDb(sql: Sql): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await sql`SELECT 1`;
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeDb(): Promise<void> {
|
||||||
|
if (sqlInstance) {
|
||||||
|
await sqlInstance.end({ timeout: 5 });
|
||||||
|
sqlInstance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
131
apps/coder/src/index.ts
Normal file
131
apps/coder/src/index.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import Fastify from 'fastify';
|
||||||
|
import fastifyWebsocket from '@fastify/websocket';
|
||||||
|
import { loadConfig } from './config.js';
|
||||||
|
import { getSql, applySchema, pingDb, closeDb } from './db.js';
|
||||||
|
// v2.0.0 Phase 2B: workspace dependency on @boocode/server — reuse the
|
||||||
|
// inference loop, broker, and tool registry without duplication.
|
||||||
|
import { createInferenceRunner } from '@boocode/server/inference';
|
||||||
|
import { createBroker } from '@boocode/server/broker';
|
||||||
|
import { appendMcpTools, ALL_TOOLS } from '@boocode/server/tools';
|
||||||
|
import type { Config as ServerConfig } from '@boocode/server/config';
|
||||||
|
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||||
|
// v2.0.0 Phase 2C: write tools + adapter for BooChat ToolDef compatibility.
|
||||||
|
import { WRITE_TOOLS } from './services/tools/index.js';
|
||||||
|
import { adaptWriteTool } from './services/tools/adapter.js';
|
||||||
|
import { setInferenceContext, clearInferenceContext } from './services/tools/inference_context.js';
|
||||||
|
// Routes
|
||||||
|
import { registerMessageRoutes } from './routes/messages.js';
|
||||||
|
import { registerPendingRoutes } from './routes/pending.js';
|
||||||
|
import { registerWebSocket } from './routes/ws.js';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
const app = Fastify({
|
||||||
|
logger: { level: config.LOG_LEVEL },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allow empty JSON bodies (same pattern as apps/server).
|
||||||
|
app.removeContentTypeParser(['application/json']);
|
||||||
|
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
|
||||||
|
const str = (body as string) ?? '';
|
||||||
|
if (str.trim().length === 0) {
|
||||||
|
done(null, {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
done(null, JSON.parse(str));
|
||||||
|
} catch (err) {
|
||||||
|
done(err as Error, undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sql = getSql(config);
|
||||||
|
await applySchema(sql);
|
||||||
|
app.log.info('database schema applied');
|
||||||
|
|
||||||
|
// Broker: in-memory pub/sub for session + user channel streaming.
|
||||||
|
const broker = createBroker(app.log);
|
||||||
|
|
||||||
|
// --- Tool registry extension ---
|
||||||
|
// Append BooCoder write tools (adapted to BooChat's ToolDef interface) to
|
||||||
|
// the shared ALL_TOOLS registry. appendMcpTools re-sorts and rebuilds
|
||||||
|
// TOOLS_BY_NAME so tool-phase.ts dispatch sees the full set.
|
||||||
|
const adaptedWriteTools = WRITE_TOOLS.map((t) => adaptWriteTool(t));
|
||||||
|
appendMcpTools(adaptedWriteTools);
|
||||||
|
app.log.info(`tool registry: ${ALL_TOOLS.length} tools loaded (${WRITE_TOOLS.length} write tools)`);
|
||||||
|
|
||||||
|
// Inference runner: same engine as BooChat, uses ALL_TOOLS (which includes
|
||||||
|
// the appended write tools) for tool dispatch.
|
||||||
|
const inference = createInferenceRunner(
|
||||||
|
{
|
||||||
|
sql,
|
||||||
|
config: config as unknown as ServerConfig,
|
||||||
|
log: app.log,
|
||||||
|
publish: (sessionId, frame) => {
|
||||||
|
broker.publishFrame(sessionId, frame as unknown as WsFrame);
|
||||||
|
},
|
||||||
|
broker,
|
||||||
|
},
|
||||||
|
(user, frame) => {
|
||||||
|
broker.publishUserFrame(user, frame as unknown as WsFrame);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wrap the inference runner to set/clear the write-tool context around each run.
|
||||||
|
// The inference runner calls enqueue() which fires asynchronously — we hook
|
||||||
|
// into the enqueue to set context before the run starts.
|
||||||
|
const inferenceApi = {
|
||||||
|
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => {
|
||||||
|
// Set the inference context so write tools can access sql + sessionId.
|
||||||
|
// The context persists for the duration of the inference run. Since
|
||||||
|
// BooCoder is single-user and runs one inference at a time per session,
|
||||||
|
// this module-level state is safe.
|
||||||
|
setInferenceContext({ sql, sessionId, taskId: null });
|
||||||
|
inference.enqueue(sessionId, chatId, assistantId, user);
|
||||||
|
},
|
||||||
|
cancel: async (sessionId: string, chatId: string) => {
|
||||||
|
const result = await inference.cancel(sessionId, chatId);
|
||||||
|
clearInferenceContext();
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
hasActive: (chatId: string) => inference.hasActive(chatId),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register WebSocket support
|
||||||
|
await app.register(fastifyWebsocket);
|
||||||
|
|
||||||
|
// Health endpoint
|
||||||
|
app.get('/api/health', async (_req, reply) => {
|
||||||
|
const dbOk = await pingDb(sql);
|
||||||
|
const status = dbOk ? 200 : 503;
|
||||||
|
return reply.status(status).send({
|
||||||
|
ok: dbOk,
|
||||||
|
db: dbOk,
|
||||||
|
tools: ALL_TOOLS.length,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register routes
|
||||||
|
registerMessageRoutes(app, sql, broker, inferenceApi);
|
||||||
|
registerPendingRoutes(app, sql);
|
||||||
|
registerWebSocket(app, sql, broker);
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
const shutdown = async () => {
|
||||||
|
app.log.info('shutting down');
|
||||||
|
await app.close();
|
||||||
|
await closeDb();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
|
||||||
|
await app.listen({ port: config.PORT, host: config.HOST });
|
||||||
|
app.log.info(`BooCoder listening on ${config.HOST}:${config.PORT}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('fatal:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
126
apps/coder/src/routes/messages.ts
Normal file
126
apps/coder/src/routes/messages.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
import type { Broker } from '@boocode/server/broker';
|
||||||
|
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||||
|
|
||||||
|
const SendBody = z.object({
|
||||||
|
content: z.string().min(1).max(64_000),
|
||||||
|
chat_id: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface InferenceApi {
|
||||||
|
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
|
||||||
|
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
|
||||||
|
hasActive: (chatId: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerMessageRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
sql: Sql,
|
||||||
|
broker: Broker,
|
||||||
|
inference: InferenceApi,
|
||||||
|
): void {
|
||||||
|
// POST /api/sessions/:sessionId/messages — send a user message + kick off inference
|
||||||
|
app.post<{ Params: { sessionId: string } }>(
|
||||||
|
'/api/sessions/:sessionId/messages',
|
||||||
|
async (req, reply) => {
|
||||||
|
const parsed = SendBody.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = req.params.sessionId;
|
||||||
|
const { content, chat_id: chatId } = parsed.data;
|
||||||
|
|
||||||
|
// Validate session exists
|
||||||
|
const sessionRows = await sql<{ id: string }[]>`
|
||||||
|
SELECT id FROM sessions WHERE id = ${sessionId}
|
||||||
|
`;
|
||||||
|
if (sessionRows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'session not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate chat belongs to session and is open
|
||||||
|
const chatRows = await sql<{ id: string; session_id: string }[]>`
|
||||||
|
SELECT id, session_id FROM chats WHERE id = ${chatId} AND session_id = ${sessionId} AND status = 'open'
|
||||||
|
`;
|
||||||
|
if (chatRows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'chat not found or not open in this session' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject if inference is already running on this chat
|
||||||
|
if (inference.hasActive(chatId)) {
|
||||||
|
reply.code(409);
|
||||||
|
return { error: 'inference already running on this chat' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user message + streaming assistant row in a transaction
|
||||||
|
const result = await sql.begin(async (tx) => {
|
||||||
|
const [userMsg] = await tx<{ id: string }[]>`
|
||||||
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
|
VALUES (${sessionId}, ${chatId}, 'user', ${content}, 'complete', clock_timestamp())
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
const [assistantMsg] = await tx<{ id: string }[]>`
|
||||||
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
|
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
|
||||||
|
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
|
||||||
|
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Publish user message frames so WS subscribers see it immediately
|
||||||
|
broker.publishFrame(sessionId, {
|
||||||
|
type: 'message_started',
|
||||||
|
message_id: result.user_message_id,
|
||||||
|
chat_id: chatId,
|
||||||
|
role: 'user',
|
||||||
|
} as unknown as WsFrame);
|
||||||
|
broker.publishFrame(sessionId, {
|
||||||
|
type: 'delta',
|
||||||
|
message_id: result.user_message_id,
|
||||||
|
chat_id: chatId,
|
||||||
|
content,
|
||||||
|
} as unknown as WsFrame);
|
||||||
|
broker.publishFrame(sessionId, {
|
||||||
|
type: 'message_complete',
|
||||||
|
message_id: result.user_message_id,
|
||||||
|
chat_id: chatId,
|
||||||
|
} as unknown as WsFrame);
|
||||||
|
|
||||||
|
// Enqueue inference — the runner will stream assistant deltas via broker
|
||||||
|
inference.enqueue(sessionId, chatId, result.assistant_message_id, 'default');
|
||||||
|
|
||||||
|
reply.code(202);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/sessions/:sessionId/stop — cancel active inference
|
||||||
|
app.post<{ Params: { sessionId: string } }>(
|
||||||
|
'/api/sessions/:sessionId/stop',
|
||||||
|
async (req, reply) => {
|
||||||
|
const sessionId = req.params.sessionId;
|
||||||
|
|
||||||
|
// Find active chats in this session
|
||||||
|
const chats = await sql<{ id: string }[]>`
|
||||||
|
SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open'
|
||||||
|
`;
|
||||||
|
let cancelled = false;
|
||||||
|
for (const chat of chats) {
|
||||||
|
if (inference.hasActive(chat.id)) {
|
||||||
|
cancelled = await inference.cancel(sessionId, chat.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cancelled };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
121
apps/coder/src/routes/pending.ts
Normal file
121
apps/coder/src/routes/pending.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
import {
|
||||||
|
listPending,
|
||||||
|
applyOne,
|
||||||
|
applyAll,
|
||||||
|
rejectOne,
|
||||||
|
rewindOne,
|
||||||
|
} from '../services/pending_changes.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve project root from a session's project path.
|
||||||
|
*/
|
||||||
|
async function resolveProjectRoot(sql: Sql, sessionId: string): Promise<string | null> {
|
||||||
|
const rows = await sql<{ path: string }[]>`
|
||||||
|
SELECT p.path FROM sessions s
|
||||||
|
JOIN projects p ON s.project_id = p.id
|
||||||
|
WHERE s.id = ${sessionId}
|
||||||
|
`;
|
||||||
|
return rows.length > 0 ? rows[0]!.path : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve project root from a pending change's session.
|
||||||
|
*/
|
||||||
|
async function resolveProjectRootForChange(sql: Sql, changeId: string): Promise<string | null> {
|
||||||
|
const rows = await sql<{ path: string }[]>`
|
||||||
|
SELECT p.path FROM pending_changes pc
|
||||||
|
JOIN sessions s ON pc.session_id = s.id
|
||||||
|
JOIN projects p ON s.project_id = p.id
|
||||||
|
WHERE pc.id = ${changeId}
|
||||||
|
`;
|
||||||
|
return rows.length > 0 ? rows[0]!.path : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerPendingRoutes(app: FastifyInstance, sql: Sql): void {
|
||||||
|
// GET /api/sessions/:sessionId/pending — list pending changes for a session
|
||||||
|
app.get<{ Params: { sessionId: string } }>(
|
||||||
|
'/api/sessions/:sessionId/pending',
|
||||||
|
async (req, reply) => {
|
||||||
|
const sessionId = req.params.sessionId;
|
||||||
|
|
||||||
|
const session = await sql<{ id: string }[]>`SELECT id FROM sessions WHERE id = ${sessionId}`;
|
||||||
|
if (session.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'session not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = await listPending(sql, sessionId);
|
||||||
|
return pending;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/sessions/:sessionId/pending/apply — apply all pending changes
|
||||||
|
app.post<{ Params: { sessionId: string } }>(
|
||||||
|
'/api/sessions/:sessionId/pending/apply',
|
||||||
|
async (req, reply) => {
|
||||||
|
const sessionId = req.params.sessionId;
|
||||||
|
|
||||||
|
const projectRoot = await resolveProjectRoot(sql, sessionId);
|
||||||
|
if (!projectRoot) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'session or project not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await applyAll(sql, sessionId, projectRoot);
|
||||||
|
return { results };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/pending/:id/apply — apply a single pending change
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/api/pending/:id/apply',
|
||||||
|
async (req, reply) => {
|
||||||
|
const changeId = req.params.id;
|
||||||
|
|
||||||
|
const projectRoot = await resolveProjectRootForChange(sql, changeId);
|
||||||
|
if (!projectRoot) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'pending change or project not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await applyOne(sql, changeId, projectRoot);
|
||||||
|
if (!result.success) {
|
||||||
|
reply.code(422);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/pending/:id/reject — reject a single pending change
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/api/pending/:id/reject',
|
||||||
|
async (req, reply) => {
|
||||||
|
const changeId = req.params.id;
|
||||||
|
|
||||||
|
await rejectOne(sql, changeId);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/pending/:id/rewind — rewind (undo) an applied change
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/api/pending/:id/rewind',
|
||||||
|
async (req, reply) => {
|
||||||
|
const changeId = req.params.id;
|
||||||
|
|
||||||
|
const projectRoot = await resolveProjectRootForChange(sql, changeId);
|
||||||
|
if (!projectRoot) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'pending change or project not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await rewindOne(sql, changeId, projectRoot);
|
||||||
|
if (!result.success) {
|
||||||
|
reply.code(422);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
51
apps/coder/src/routes/ws.ts
Normal file
51
apps/coder/src/routes/ws.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
import type { Broker } from '@boocode/server/broker';
|
||||||
|
|
||||||
|
export function registerWebSocket(
|
||||||
|
app: FastifyInstance,
|
||||||
|
sql: Sql,
|
||||||
|
broker: Broker,
|
||||||
|
): void {
|
||||||
|
// Per-session streaming WebSocket. Clients connect here to receive live
|
||||||
|
// inference frames (deltas, tool_calls, tool_results, message_complete).
|
||||||
|
app.get<{ Params: { sessionId: string } }>(
|
||||||
|
'/api/ws/sessions/:sessionId',
|
||||||
|
{ websocket: true },
|
||||||
|
async (socket, req) => {
|
||||||
|
const sessionId = req.params.sessionId;
|
||||||
|
|
||||||
|
// Validate session exists
|
||||||
|
const session = await sql<{ id: string }[]>`SELECT id FROM sessions WHERE id = ${sessionId}`;
|
||||||
|
if (session.length === 0) {
|
||||||
|
socket.send(JSON.stringify({ type: 'error', error: 'session not found' }));
|
||||||
|
socket.close(1008, 'session not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send snapshot of existing messages so client can hydrate
|
||||||
|
const messages = await sql<Record<string, unknown>[]>`
|
||||||
|
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
|
||||||
|
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata,
|
||||||
|
summary, tail_start_id, compacted_at
|
||||||
|
FROM messages_with_parts
|
||||||
|
WHERE session_id = ${sessionId}
|
||||||
|
ORDER BY created_at ASC, id ASC
|
||||||
|
`;
|
||||||
|
socket.send(JSON.stringify({ type: 'snapshot', messages }));
|
||||||
|
|
||||||
|
// Subscribe to broker for live frames
|
||||||
|
const unsubscribe = broker.subscribe(sessionId, (frame) => {
|
||||||
|
if (socket.readyState !== socket.OPEN) return;
|
||||||
|
try {
|
||||||
|
socket.send(JSON.stringify(frame));
|
||||||
|
} catch (err) {
|
||||||
|
app.log.warn({ err, sessionId }, 'ws send failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('close', () => unsubscribe());
|
||||||
|
socket.on('error', () => unsubscribe());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
48
apps/coder/src/schema.sql
Normal file
48
apps/coder/src/schema.sql
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
-- v2.0.0: BooCoder schema — pending changes, tasks, agent registry.
|
||||||
|
-- Applied on startup by apps/coder/src/db.ts:applySchema().
|
||||||
|
-- Lives in the same 'boochat' database as BooChat's tables.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pending_changes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
session_id UUID NOT NULL,
|
||||||
|
task_id UUID,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
operation TEXT NOT NULL,
|
||||||
|
diff TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||||
|
CONSTRAINT pending_changes_operation_chk CHECK (operation IN ('create', 'edit', 'delete')),
|
||||||
|
CONSTRAINT pending_changes_status_chk CHECK (status IN ('pending', 'applied', 'rejected', 'reverted'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID NOT NULL,
|
||||||
|
parent_task_id UUID REFERENCES tasks(id),
|
||||||
|
state TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
input TEXT NOT NULL,
|
||||||
|
output_summary TEXT,
|
||||||
|
agent TEXT,
|
||||||
|
model TEXT,
|
||||||
|
execution_path TEXT,
|
||||||
|
worktree_path TEXT,
|
||||||
|
cost_tokens INTEGER,
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
ended_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||||
|
CONSTRAINT tasks_state_chk CHECK (state IN ('pending', 'running', 'completed', 'failed', 'blocked', 'cancelled')),
|
||||||
|
CONSTRAINT tasks_execution_path_chk CHECK (execution_path IS NULL OR execution_path IN ('native', 'acp', 'pty'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS available_agents (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
install_path TEXT,
|
||||||
|
version TEXT,
|
||||||
|
supports_acp BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
supports_mcp_client BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
last_probed_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Human inbox: tasks needing attention
|
||||||
|
CREATE OR REPLACE VIEW human_inbox AS
|
||||||
|
SELECT * FROM tasks WHERE state IN ('blocked', 'failed');
|
||||||
115
apps/coder/src/services/__tests__/write_guard.test.ts
Normal file
115
apps/coder/src/services/__tests__/write_guard.test.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { resolveWritePath, isSecretPath, WriteGuardError } from '../write_guard.js';
|
||||||
|
|
||||||
|
const PROJECT_ROOT = '/opt/projects/my-app';
|
||||||
|
|
||||||
|
describe('resolveWritePath', () => {
|
||||||
|
it('resolves a relative path correctly', () => {
|
||||||
|
const result = resolveWritePath(PROJECT_ROOT, 'src/index.ts');
|
||||||
|
expect(result).toBe('/opt/projects/my-app/src/index.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves nested relative path', () => {
|
||||||
|
const result = resolveWritePath(PROJECT_ROOT, 'src/lib/utils.ts');
|
||||||
|
expect(result).toBe('/opt/projects/my-app/src/lib/utils.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on ../ escape', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '../../../etc/passwd')).toThrow(WriteGuardError);
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '../../../etc/passwd')).toThrow('path escapes project root');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on absolute path outside project root', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '/etc/shadow')).toThrow(WriteGuardError);
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '/tmp/exploit')).toThrow('path escapes project root');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows absolute path inside project root', () => {
|
||||||
|
const result = resolveWritePath(PROJECT_ROOT, '/opt/projects/my-app/src/new.ts');
|
||||||
|
expect(result).toBe('/opt/projects/my-app/src/new.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies .env files', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '.env')).toThrow(WriteGuardError);
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '.env')).toThrow('cannot write to secret file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies .env.local', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '.env.local')).toThrow(WriteGuardError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies .env.production', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '.env.production')).toThrow(WriteGuardError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies *.pem files', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, 'certs/server.pem')).toThrow(WriteGuardError);
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, 'certs/server.pem')).toThrow('cannot write to secret file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies *.key files', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, 'ssl/private.key')).toThrow(WriteGuardError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies id_rsa', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '.ssh/id_rsa')).toThrow(WriteGuardError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies id_ed25519', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '.ssh/id_ed25519')).toThrow(WriteGuardError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies credentials.json', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, 'credentials.json')).toThrow(WriteGuardError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes a normal file inside project', () => {
|
||||||
|
const result = resolveWritePath(PROJECT_ROOT, 'src/components/Button.tsx');
|
||||||
|
expect(result).toBe('/opt/projects/my-app/src/components/Button.tsx');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes a non-existent nested file (no realpath)', () => {
|
||||||
|
// This is the key difference from BooChat's pathGuard: no realpath means
|
||||||
|
// files that don't exist yet still pass validation
|
||||||
|
const result = resolveWritePath(PROJECT_ROOT, 'src/new-dir/new-file.ts');
|
||||||
|
expect(result).toBe('/opt/projects/my-app/src/new-dir/new-file.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on null/empty path', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '')).toThrow(WriteGuardError);
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '')).toThrow('file path is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes ../ within project root and still allows', () => {
|
||||||
|
const result = resolveWritePath(PROJECT_ROOT, 'src/../lib/utils.ts');
|
||||||
|
expect(result).toBe('/opt/projects/my-app/lib/utils.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects path that looks inside root but normalizes outside', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, 'src/../../other-project/hack.ts')).toThrow(WriteGuardError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isSecretPath', () => {
|
||||||
|
it('detects .env', () => {
|
||||||
|
expect(isSecretPath('.env')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects nested .env', () => {
|
||||||
|
expect(isSecretPath('config/.env')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects *.pfx', () => {
|
||||||
|
expect(isSecretPath('certs/client.pfx')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not flag normal source files', () => {
|
||||||
|
expect(isSecretPath('src/index.ts')).toBe(false);
|
||||||
|
expect(isSecretPath('README.md')).toBe(false);
|
||||||
|
expect(isSecretPath('package.json')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for empty string', () => {
|
||||||
|
expect(isSecretPath('')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
224
apps/coder/src/services/pending_changes.ts
Normal file
224
apps/coder/src/services/pending_changes.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
|
||||||
|
import { dirname } from 'node:path';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
import { resolveWritePath } from './write_guard.js';
|
||||||
|
|
||||||
|
// --- Types -------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface PendingChange {
|
||||||
|
id: string;
|
||||||
|
session_id: string;
|
||||||
|
task_id: string | null;
|
||||||
|
file_path: string;
|
||||||
|
operation: 'create' | 'edit' | 'delete';
|
||||||
|
diff: string;
|
||||||
|
status: 'pending' | 'applied' | 'rejected' | 'reverted';
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplyResult {
|
||||||
|
id: string;
|
||||||
|
file_path: string;
|
||||||
|
operation: string;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Queue functions ---------------------------------------------------------
|
||||||
|
|
||||||
|
export async function queueEdit(
|
||||||
|
sql: Sql,
|
||||||
|
sessionId: string,
|
||||||
|
taskId: string | null,
|
||||||
|
filePath: string,
|
||||||
|
oldString: string,
|
||||||
|
newString: string,
|
||||||
|
projectRoot: string,
|
||||||
|
): Promise<PendingChange> {
|
||||||
|
const resolved = resolveWritePath(projectRoot, filePath);
|
||||||
|
const diff = JSON.stringify({ old: oldString, new: newString });
|
||||||
|
|
||||||
|
const [row] = await sql<PendingChange[]>`
|
||||||
|
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff)
|
||||||
|
VALUES (${sessionId}, ${taskId}, ${resolved}, 'edit', ${diff})
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
return row!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queueCreate(
|
||||||
|
sql: Sql,
|
||||||
|
sessionId: string,
|
||||||
|
taskId: string | null,
|
||||||
|
filePath: string,
|
||||||
|
content: string,
|
||||||
|
projectRoot: string,
|
||||||
|
): Promise<PendingChange> {
|
||||||
|
const resolved = resolveWritePath(projectRoot, filePath);
|
||||||
|
|
||||||
|
const [row] = await sql<PendingChange[]>`
|
||||||
|
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff)
|
||||||
|
VALUES (${sessionId}, ${taskId}, ${resolved}, 'create', ${content})
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
return row!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queueDelete(
|
||||||
|
sql: Sql,
|
||||||
|
sessionId: string,
|
||||||
|
taskId: string | null,
|
||||||
|
filePath: string,
|
||||||
|
projectRoot: string,
|
||||||
|
): Promise<PendingChange> {
|
||||||
|
const resolved = resolveWritePath(projectRoot, filePath);
|
||||||
|
|
||||||
|
const [row] = await sql<PendingChange[]>`
|
||||||
|
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff)
|
||||||
|
VALUES (${sessionId}, ${taskId}, ${resolved}, 'delete', '')
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
return row!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Apply functions ---------------------------------------------------------
|
||||||
|
|
||||||
|
export async function applyOne(
|
||||||
|
sql: Sql,
|
||||||
|
changeId: string,
|
||||||
|
projectRoot: string,
|
||||||
|
): Promise<ApplyResult> {
|
||||||
|
const [change] = await sql<PendingChange[]>`
|
||||||
|
SELECT * FROM pending_changes WHERE id = ${changeId} AND status = 'pending'
|
||||||
|
`;
|
||||||
|
if (!change) {
|
||||||
|
return { id: changeId, file_path: '', operation: '', success: false, error: 'change not found or not pending' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Re-validate path in case projectRoot has shifted
|
||||||
|
resolveWritePath(projectRoot, change.file_path);
|
||||||
|
|
||||||
|
switch (change.operation) {
|
||||||
|
case 'create': {
|
||||||
|
await mkdir(dirname(change.file_path), { recursive: true });
|
||||||
|
await writeFile(change.file_path, change.diff, 'utf8');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'edit': {
|
||||||
|
const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
|
||||||
|
const content = await readFile(change.file_path, 'utf8');
|
||||||
|
if (!content.includes(oldStr)) {
|
||||||
|
throw new Error('old_string not found in file — file may have changed since the edit was queued');
|
||||||
|
}
|
||||||
|
const updated = content.replace(oldStr, newStr);
|
||||||
|
await writeFile(change.file_path, updated, 'utf8');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'delete': {
|
||||||
|
// Stash current content in diff for potential rewind
|
||||||
|
try {
|
||||||
|
const existing = await readFile(change.file_path, 'utf8');
|
||||||
|
await sql`UPDATE pending_changes SET diff = ${existing} WHERE id = ${changeId}`;
|
||||||
|
} catch {
|
||||||
|
// File may already be gone — proceed with status update
|
||||||
|
}
|
||||||
|
await unlink(change.file_path);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`UPDATE pending_changes SET status = 'applied' WHERE id = ${changeId}`;
|
||||||
|
return { id: change.id, file_path: change.file_path, operation: change.operation, success: true };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return { id: change.id, file_path: change.file_path, operation: change.operation, success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyAll(
|
||||||
|
sql: Sql,
|
||||||
|
sessionId: string,
|
||||||
|
projectRoot: string,
|
||||||
|
): Promise<ApplyResult[]> {
|
||||||
|
const pending = await sql<PendingChange[]>`
|
||||||
|
SELECT * FROM pending_changes
|
||||||
|
WHERE session_id = ${sessionId} AND status = 'pending'
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
`;
|
||||||
|
const results: ApplyResult[] = [];
|
||||||
|
for (const change of pending) {
|
||||||
|
results.push(await applyOne(sql, change.id, projectRoot));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reject functions --------------------------------------------------------
|
||||||
|
|
||||||
|
export async function rejectOne(sql: Sql, changeId: string): Promise<void> {
|
||||||
|
await sql`UPDATE pending_changes SET status = 'rejected' WHERE id = ${changeId} AND status = 'pending'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectAll(sql: Sql, sessionId: string): Promise<void> {
|
||||||
|
await sql`UPDATE pending_changes SET status = 'rejected' WHERE session_id = ${sessionId} AND status = 'pending'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rewind functions --------------------------------------------------------
|
||||||
|
|
||||||
|
export async function rewindOne(
|
||||||
|
sql: Sql,
|
||||||
|
changeId: string,
|
||||||
|
projectRoot: string,
|
||||||
|
): Promise<ApplyResult> {
|
||||||
|
const [change] = await sql<PendingChange[]>`
|
||||||
|
SELECT * FROM pending_changes WHERE id = ${changeId} AND status = 'applied'
|
||||||
|
`;
|
||||||
|
if (!change) {
|
||||||
|
return { id: changeId, file_path: '', operation: '', success: false, error: 'change not found or not applied' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
resolveWritePath(projectRoot, change.file_path);
|
||||||
|
|
||||||
|
switch (change.operation) {
|
||||||
|
case 'create': {
|
||||||
|
// Reverse a create: delete the file
|
||||||
|
await unlink(change.file_path);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'edit': {
|
||||||
|
// Reverse an edit: swap old and new
|
||||||
|
const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
|
||||||
|
const content = await readFile(change.file_path, 'utf8');
|
||||||
|
if (!content.includes(newStr)) {
|
||||||
|
throw new Error('new_string not found in file — cannot rewind; file may have been modified since apply');
|
||||||
|
}
|
||||||
|
const reverted = content.replace(newStr, oldStr);
|
||||||
|
await writeFile(change.file_path, reverted, 'utf8');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'delete': {
|
||||||
|
// Reverse a delete: recreate the file (diff holds the original content stashed at apply time)
|
||||||
|
await mkdir(dirname(change.file_path), { recursive: true });
|
||||||
|
await writeFile(change.file_path, change.diff, 'utf8');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`UPDATE pending_changes SET status = 'reverted' WHERE id = ${changeId}`;
|
||||||
|
return { id: change.id, file_path: change.file_path, operation: change.operation, success: true };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return { id: change.id, file_path: change.file_path, operation: change.operation, success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Query functions ---------------------------------------------------------
|
||||||
|
|
||||||
|
export async function listPending(sql: Sql, sessionId: string): Promise<PendingChange[]> {
|
||||||
|
return sql<PendingChange[]>`
|
||||||
|
SELECT * FROM pending_changes
|
||||||
|
WHERE session_id = ${sessionId} AND status = 'pending'
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
`;
|
||||||
|
}
|
||||||
30
apps/coder/src/services/tools/adapter.ts
Normal file
30
apps/coder/src/services/tools/adapter.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Adapts BooCoder write tools (which take ToolContext) into BooChat's ToolDef
|
||||||
|
* interface (which takes `projectRoot, extraRoots?`).
|
||||||
|
*
|
||||||
|
* The adapter reads the module-level inference context at execute time, so the
|
||||||
|
* wrapping happens at boot (static) — no per-inference re-wrap needed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ToolDef as ServerToolDef } from '@boocode/server/tools';
|
||||||
|
import type { ToolDef, ToolContext } from './types.js';
|
||||||
|
import { getInferenceContext } from './inference_context.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a BooCoder write tool (execute takes ToolContext) into a BooChat
|
||||||
|
* ToolDef (execute takes projectRoot + optional extraRoots). The adapter
|
||||||
|
* builds the ToolContext from the module-level inference context at call time.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export function adaptWriteTool(tool: ToolDef<any>): ServerToolDef<any> {
|
||||||
|
return {
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
inputSchema: tool.inputSchema,
|
||||||
|
jsonSchema: tool.jsonSchema,
|
||||||
|
async execute(input: unknown, projectRoot: string, _extraRoots?: readonly string[]): Promise<unknown> {
|
||||||
|
const ctx: ToolContext = getInferenceContext();
|
||||||
|
return tool.execute(input, projectRoot, ctx);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
44
apps/coder/src/services/tools/apply_pending.ts
Normal file
44
apps/coder/src/services/tools/apply_pending.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef, ToolContext } from './types.js';
|
||||||
|
import { applyAll } from '../pending_changes.js';
|
||||||
|
|
||||||
|
const ApplyPendingInput = z.object({});
|
||||||
|
type ApplyPendingInputT = z.infer<typeof ApplyPendingInput>;
|
||||||
|
|
||||||
|
export const applyPendingTool: ToolDef<ApplyPendingInputT> = {
|
||||||
|
name: 'apply_pending',
|
||||||
|
description:
|
||||||
|
'Apply all pending changes for the current session to disk. ' +
|
||||||
|
'Each queued create/edit/delete is executed in order.',
|
||||||
|
inputSchema: ApplyPendingInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'apply_pending',
|
||||||
|
description:
|
||||||
|
'Apply all pending changes for the current session to disk. ' +
|
||||||
|
'Each queued create/edit/delete is executed in order.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
required: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(_input: ApplyPendingInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||||
|
const results = await applyAll(context.sql, context.sessionId, projectRoot);
|
||||||
|
const succeeded = results.filter((r) => r.success).length;
|
||||||
|
const failed = results.filter((r) => !r.success).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: results.length,
|
||||||
|
succeeded,
|
||||||
|
failed,
|
||||||
|
results,
|
||||||
|
message:
|
||||||
|
results.length === 0
|
||||||
|
? 'No pending changes to apply.'
|
||||||
|
: `Applied ${succeeded}/${results.length} changes.${failed > 0 ? ` ${failed} failed.` : ''}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
51
apps/coder/src/services/tools/create_file.ts
Normal file
51
apps/coder/src/services/tools/create_file.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef, ToolContext } from './types.js';
|
||||||
|
import { queueCreate } from '../pending_changes.js';
|
||||||
|
|
||||||
|
const CreateFileInput = z.object({
|
||||||
|
file_path: z.string().min(1),
|
||||||
|
content: z.string(),
|
||||||
|
});
|
||||||
|
type CreateFileInputT = z.infer<typeof CreateFileInput>;
|
||||||
|
|
||||||
|
export const createFileTool: ToolDef<CreateFileInputT> = {
|
||||||
|
name: 'create_file',
|
||||||
|
description:
|
||||||
|
'Queue creation of a new file with the given content. ' +
|
||||||
|
'The change is staged in pending_changes and must be applied explicitly.',
|
||||||
|
inputSchema: CreateFileInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'create_file',
|
||||||
|
description:
|
||||||
|
'Queue creation of a new file with the given content. ' +
|
||||||
|
'The change is staged in pending_changes and must be applied explicitly.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
file_path: { type: 'string', description: 'Path for the new file (relative to project root or absolute)' },
|
||||||
|
content: { type: 'string', description: 'Full content of the file to create' },
|
||||||
|
},
|
||||||
|
required: ['file_path', 'content'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input: CreateFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||||
|
const change = await queueCreate(
|
||||||
|
context.sql,
|
||||||
|
context.sessionId,
|
||||||
|
context.taskId,
|
||||||
|
input.file_path,
|
||||||
|
input.content,
|
||||||
|
projectRoot,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: 'queued',
|
||||||
|
change_id: change.id,
|
||||||
|
file_path: change.file_path,
|
||||||
|
operation: 'create',
|
||||||
|
message: `File creation queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
48
apps/coder/src/services/tools/delete_file.ts
Normal file
48
apps/coder/src/services/tools/delete_file.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef, ToolContext } from './types.js';
|
||||||
|
import { queueDelete } from '../pending_changes.js';
|
||||||
|
|
||||||
|
const DeleteFileInput = z.object({
|
||||||
|
file_path: z.string().min(1),
|
||||||
|
});
|
||||||
|
type DeleteFileInputT = z.infer<typeof DeleteFileInput>;
|
||||||
|
|
||||||
|
export const deleteFileTool: ToolDef<DeleteFileInputT> = {
|
||||||
|
name: 'delete_file',
|
||||||
|
description:
|
||||||
|
'Queue deletion of a file. ' +
|
||||||
|
'The change is staged in pending_changes and must be applied explicitly.',
|
||||||
|
inputSchema: DeleteFileInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'delete_file',
|
||||||
|
description:
|
||||||
|
'Queue deletion of a file. ' +
|
||||||
|
'The change is staged in pending_changes and must be applied explicitly.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
file_path: { type: 'string', description: 'Path to the file to delete (relative to project root or absolute)' },
|
||||||
|
},
|
||||||
|
required: ['file_path'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input: DeleteFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||||
|
const change = await queueDelete(
|
||||||
|
context.sql,
|
||||||
|
context.sessionId,
|
||||||
|
context.taskId,
|
||||||
|
input.file_path,
|
||||||
|
projectRoot,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: 'queued',
|
||||||
|
change_id: change.id,
|
||||||
|
file_path: change.file_path,
|
||||||
|
operation: 'delete',
|
||||||
|
message: `File deletion queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
54
apps/coder/src/services/tools/edit_file.ts
Normal file
54
apps/coder/src/services/tools/edit_file.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef, ToolContext } from './types.js';
|
||||||
|
import { queueEdit } from '../pending_changes.js';
|
||||||
|
|
||||||
|
const EditFileInput = z.object({
|
||||||
|
file_path: z.string().min(1),
|
||||||
|
old_string: z.string().min(1),
|
||||||
|
new_string: z.string(),
|
||||||
|
});
|
||||||
|
type EditFileInputT = z.infer<typeof EditFileInput>;
|
||||||
|
|
||||||
|
export const editFileTool: ToolDef<EditFileInputT> = {
|
||||||
|
name: 'edit_file',
|
||||||
|
description:
|
||||||
|
'Queue an edit to a file. The edit replaces old_string with new_string. ' +
|
||||||
|
'The change is staged in pending_changes and must be applied explicitly.',
|
||||||
|
inputSchema: EditFileInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'edit_file',
|
||||||
|
description:
|
||||||
|
'Queue an edit to a file. The edit replaces old_string with new_string. ' +
|
||||||
|
'The change is staged in pending_changes and must be applied explicitly.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
file_path: { type: 'string', description: 'Path to the file to edit (relative to project root or absolute)' },
|
||||||
|
old_string: { type: 'string', description: 'The exact string to find and replace (must appear in the file)' },
|
||||||
|
new_string: { type: 'string', description: 'The replacement string' },
|
||||||
|
},
|
||||||
|
required: ['file_path', 'old_string', 'new_string'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input: EditFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||||
|
const change = await queueEdit(
|
||||||
|
context.sql,
|
||||||
|
context.sessionId,
|
||||||
|
context.taskId,
|
||||||
|
input.file_path,
|
||||||
|
input.old_string,
|
||||||
|
input.new_string,
|
||||||
|
projectRoot,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: 'queued',
|
||||||
|
change_id: change.id,
|
||||||
|
file_path: change.file_path,
|
||||||
|
operation: 'edit',
|
||||||
|
message: `Edit queued for ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
26
apps/coder/src/services/tools/index.ts
Normal file
26
apps/coder/src/services/tools/index.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ToolDef } from './types.js';
|
||||||
|
import { editFileTool } from './edit_file.js';
|
||||||
|
import { createFileTool } from './create_file.js';
|
||||||
|
import { deleteFileTool } from './delete_file.js';
|
||||||
|
import { applyPendingTool } from './apply_pending.js';
|
||||||
|
import { rewindTool } from './rewind.js';
|
||||||
|
|
||||||
|
export type { ToolDef, ToolContext, ToolJsonSchema } from './types.js';
|
||||||
|
|
||||||
|
// All BooCoder write tools. The inference loop (Phase 2B) will combine these
|
||||||
|
// with BooChat's read-only tools to form the full tool set available to agents.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export const WRITE_TOOLS: readonly ToolDef<any>[] = [
|
||||||
|
applyPendingTool,
|
||||||
|
createFileTool,
|
||||||
|
deleteFileTool,
|
||||||
|
editFileTool,
|
||||||
|
rewindTool,
|
||||||
|
];
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export const WRITE_TOOLS_BY_NAME: ReadonlyMap<string, ToolDef<any>> = new Map(
|
||||||
|
WRITE_TOOLS.map((t) => [t.name, t]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export { editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool };
|
||||||
36
apps/coder/src/services/tools/inference_context.ts
Normal file
36
apps/coder/src/services/tools/inference_context.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Sql } from '../../db.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module-level inference context for write tools.
|
||||||
|
*
|
||||||
|
* Set via `setInferenceContext()` before each inference run starts.
|
||||||
|
* Write tools read it via `getInferenceContext()` during execute.
|
||||||
|
* Same pattern as BooChat's `loadConfig()` singleton — tools need
|
||||||
|
* ambient state that can't be threaded through the tool-phase execute
|
||||||
|
* signature (which is `execute(input, projectRoot, extraRoots?)`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface InferenceContext {
|
||||||
|
sql: Sql;
|
||||||
|
sessionId: string;
|
||||||
|
taskId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current: InferenceContext | null = null;
|
||||||
|
|
||||||
|
export function setInferenceContext(ctx: InferenceContext): void {
|
||||||
|
current = ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearInferenceContext(): void {
|
||||||
|
current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInferenceContext(): InferenceContext {
|
||||||
|
if (!current) {
|
||||||
|
throw new Error(
|
||||||
|
'Write tool called outside inference context — setInferenceContext() was not called before this run',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
71
apps/coder/src/services/tools/rewind.ts
Normal file
71
apps/coder/src/services/tools/rewind.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef, ToolContext } from './types.js';
|
||||||
|
import { rewindOne } from '../pending_changes.js';
|
||||||
|
|
||||||
|
const RewindInput = z.object({
|
||||||
|
change_id: z.string().uuid().optional(),
|
||||||
|
all: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
type RewindInputT = z.infer<typeof RewindInput>;
|
||||||
|
|
||||||
|
export const rewindTool: ToolDef<RewindInputT> = {
|
||||||
|
name: 'rewind',
|
||||||
|
description:
|
||||||
|
'Revert applied changes. Provide change_id to revert a specific change, ' +
|
||||||
|
'or set all=true to revert all applied changes for the session (in reverse order).',
|
||||||
|
inputSchema: RewindInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'rewind',
|
||||||
|
description:
|
||||||
|
'Revert applied changes. Provide change_id to revert a specific change, ' +
|
||||||
|
'or set all=true to revert all applied changes for the session (in reverse order).',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
change_id: { type: 'string', format: 'uuid', description: 'ID of a specific change to revert' },
|
||||||
|
all: { type: 'boolean', description: 'If true, revert all applied changes for this session' },
|
||||||
|
},
|
||||||
|
required: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input: RewindInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||||
|
if (input.change_id) {
|
||||||
|
const result = await rewindOne(context.sql, input.change_id, projectRoot);
|
||||||
|
return {
|
||||||
|
results: [result],
|
||||||
|
message: result.success
|
||||||
|
? `Reverted change ${input.change_id} (${result.operation} on ${result.file_path}).`
|
||||||
|
: `Failed to revert: ${result.error}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.all) {
|
||||||
|
// Rewind all applied changes for this session in reverse order
|
||||||
|
const applied = await context.sql<{ id: string }[]>`
|
||||||
|
SELECT id FROM pending_changes
|
||||||
|
WHERE session_id = ${context.sessionId} AND status = 'applied'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`;
|
||||||
|
const results = [];
|
||||||
|
for (const row of applied) {
|
||||||
|
results.push(await rewindOne(context.sql, row.id, projectRoot));
|
||||||
|
}
|
||||||
|
const succeeded = results.filter((r) => r.success).length;
|
||||||
|
return {
|
||||||
|
total: results.length,
|
||||||
|
succeeded,
|
||||||
|
failed: results.length - succeeded,
|
||||||
|
results,
|
||||||
|
message:
|
||||||
|
results.length === 0
|
||||||
|
? 'No applied changes to revert.'
|
||||||
|
: `Reverted ${succeeded}/${results.length} changes.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { error: 'Provide either change_id or all=true.' };
|
||||||
|
},
|
||||||
|
};
|
||||||
32
apps/coder/src/services/tools/types.ts
Normal file
32
apps/coder/src/services/tools/types.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { z } from 'zod';
|
||||||
|
import type { Sql } from '../../db.js';
|
||||||
|
|
||||||
|
export interface ToolJsonSchema {
|
||||||
|
type: 'function';
|
||||||
|
function: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
parameters: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context passed to BooCoder tool execute functions.
|
||||||
|
*
|
||||||
|
* Unlike BooChat's tools (which only need projectRoot), BooCoder's write tools
|
||||||
|
* interact with the database (pending_changes table) and need session/task
|
||||||
|
* context for proper attribution.
|
||||||
|
*/
|
||||||
|
export interface ToolContext {
|
||||||
|
sql: Sql;
|
||||||
|
sessionId: string;
|
||||||
|
taskId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolDef<TInput> {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
inputSchema: z.ZodType<TInput>;
|
||||||
|
jsonSchema: ToolJsonSchema;
|
||||||
|
execute(input: TInput, projectRoot: string, context: ToolContext): Promise<unknown>;
|
||||||
|
}
|
||||||
73
apps/coder/src/services/write_guard.ts
Normal file
73
apps/coder/src/services/write_guard.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { resolve, sep } from 'node:path';
|
||||||
|
|
||||||
|
export class WriteGuardError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'WriteGuardError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deny list: files that should never be written regardless of path-guard.
|
||||||
|
// Subset of BooChat's secret_guard.ts — covers the most dangerous patterns.
|
||||||
|
// Full parity with BooChat's deny list is not needed for write-guard because
|
||||||
|
// the write tools are intentional (model chose to create/edit); we block only
|
||||||
|
// files that are unambiguously secrets.
|
||||||
|
const SECRET_PATTERNS: readonly string[] = [
|
||||||
|
'.env',
|
||||||
|
'.env.local',
|
||||||
|
'.env.production',
|
||||||
|
'.env.development',
|
||||||
|
'.env.staging',
|
||||||
|
'id_rsa',
|
||||||
|
'id_dsa',
|
||||||
|
'id_ecdsa',
|
||||||
|
'id_ed25519',
|
||||||
|
'*.pem',
|
||||||
|
'*.key',
|
||||||
|
'*.p12',
|
||||||
|
'*.pfx',
|
||||||
|
'*.crt',
|
||||||
|
'credentials.json',
|
||||||
|
'*.kdbx',
|
||||||
|
'.netrc',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isSecretPath(filePath: string): boolean {
|
||||||
|
const normalized = filePath.replace(/\\/g, '/');
|
||||||
|
const segments = normalized.split('/').filter((s) => s.length > 0);
|
||||||
|
if (segments.length === 0) return false;
|
||||||
|
const basename = segments[segments.length - 1]!;
|
||||||
|
|
||||||
|
return SECRET_PATTERNS.some((pattern) => {
|
||||||
|
if (pattern.startsWith('*')) {
|
||||||
|
return basename.endsWith(pattern.slice(1));
|
||||||
|
}
|
||||||
|
return basename === pattern;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve and validate a write target path.
|
||||||
|
*
|
||||||
|
* Key difference from BooChat's pathGuard: no realpath() — the file may not
|
||||||
|
* exist yet (creates). Uses resolve() to normalize ../ segments and then
|
||||||
|
* checks the result stays within projectRoot.
|
||||||
|
*/
|
||||||
|
export function resolveWritePath(projectRoot: string, filePath: string): string {
|
||||||
|
if (!filePath || filePath.length === 0) {
|
||||||
|
throw new WriteGuardError('file path is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = filePath.startsWith('/') ? filePath : resolve(projectRoot, filePath);
|
||||||
|
const normalized = resolve(candidate); // normalizes ../ segments
|
||||||
|
|
||||||
|
if (!normalized.startsWith(projectRoot + sep) && normalized !== projectRoot) {
|
||||||
|
throw new WriteGuardError(`path escapes project root: ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSecretPath(normalized)) {
|
||||||
|
throw new WriteGuardError(`cannot write to secret file: ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
15
apps/coder/tsconfig.json
Normal file
15
apps/coder/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["node"],
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["src/**/__tests__/**", "**/*.test.ts"]
|
||||||
|
}
|
||||||
9
apps/coder/vitest.config.ts
Normal file
9
apps/coder/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
globals: false,
|
||||||
|
include: ['src/**/__tests__/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -4,6 +4,23 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
"exports": {
|
||||||
|
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
||||||
|
"./inference": { "types": "./dist/services/inference/index.d.ts", "default": "./dist/services/inference/index.js" },
|
||||||
|
"./tools": { "types": "./dist/services/tools.d.ts", "default": "./dist/services/tools.js" },
|
||||||
|
"./broker": { "types": "./dist/services/broker.d.ts", "default": "./dist/services/broker.js" },
|
||||||
|
"./compaction": { "types": "./dist/services/compaction.d.ts", "default": "./dist/services/compaction.js" },
|
||||||
|
"./model-context": { "types": "./dist/services/model-context.d.ts", "default": "./dist/services/model-context.js" },
|
||||||
|
"./system-prompt": { "types": "./dist/services/system-prompt.d.ts", "default": "./dist/services/system-prompt.js" },
|
||||||
|
"./agents": { "types": "./dist/services/agents.d.ts", "default": "./dist/services/agents.js" },
|
||||||
|
"./truncate": { "types": "./dist/services/truncate.d.ts", "default": "./dist/services/truncate.js" },
|
||||||
|
"./path-guard": { "types": "./dist/services/path_guard.d.ts", "default": "./dist/services/path_guard.js" },
|
||||||
|
"./file-ops": { "types": "./dist/services/file_ops.d.ts", "default": "./dist/services/file_ops.js" },
|
||||||
|
"./types": { "types": "./dist/types/api.d.ts", "default": "./dist/types/api.js" },
|
||||||
|
"./ws-frames": { "types": "./dist/types/ws-frames.d.ts", "default": "./dist/types/ws-frames.js" },
|
||||||
|
"./db": { "types": "./dist/db.d.ts", "default": "./dist/db.js" },
|
||||||
|
"./config": { "types": "./dist/config.d.ts", "default": "./dist/config.js" }
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"",
|
"build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"",
|
||||||
@@ -14,6 +31,7 @@
|
|||||||
"@ai-sdk/openai-compatible": "^2.0.47",
|
"@ai-sdk/openai-compatible": "^2.0.47",
|
||||||
"@fastify/static": "^7.0.4",
|
"@fastify/static": "^7.0.4",
|
||||||
"@fastify/websocket": "^10.0.1",
|
"@fastify/websocket": "^10.0.1",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"ai": "^6.0.190",
|
"ai": "^6.0.190",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"postgres": "^3.4.4",
|
"postgres": "^3.4.4",
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ const ConfigSchema = z.object({
|
|||||||
GITEA_USER: z.string().default('indifferentketchup'),
|
GITEA_USER: z.string().default('indifferentketchup'),
|
||||||
GITEA_TOKEN: z.string().optional(),
|
GITEA_TOKEN: z.string().optional(),
|
||||||
GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'),
|
GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'),
|
||||||
|
// v1.15.0-mcp-multi: path to the MCP config JSON file. Default /data/mcp.json
|
||||||
|
// (bind-mounted alongside AGENTS.md). File missing = no MCP (opt-in).
|
||||||
|
MCP_CONFIG_PATH: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Config = z.infer<typeof ConfigSchema>;
|
export type Config = z.infer<typeof ConfigSchema>;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { registerProjectRoutes } from './routes/projects.js';
|
|||||||
import { registerSessionRoutes } from './routes/sessions.js';
|
import { registerSessionRoutes } from './routes/sessions.js';
|
||||||
import { registerSettingsRoutes } from './routes/settings.js';
|
import { registerSettingsRoutes } from './routes/settings.js';
|
||||||
import { registerMessageRoutes } from './routes/messages.js';
|
import { registerMessageRoutes } from './routes/messages.js';
|
||||||
|
import { registerArtifactRoutes } from './routes/artifacts.js';
|
||||||
import { registerChatRoutes } from './routes/chats.js';
|
import { registerChatRoutes } from './routes/chats.js';
|
||||||
import { registerSidebarRoutes } from './routes/sidebar.js';
|
import { registerSidebarRoutes } from './routes/sidebar.js';
|
||||||
import { registerWebSocket } from './routes/ws.js';
|
import { registerWebSocket } from './routes/ws.js';
|
||||||
@@ -23,6 +24,10 @@ import { listSkills } from './services/skills.js';
|
|||||||
import * as compaction from './services/compaction.js';
|
import * as compaction from './services/compaction.js';
|
||||||
import { configureModelContext } from './services/model-context.js';
|
import { configureModelContext } from './services/model-context.js';
|
||||||
import { cleanupTruncations } from './services/truncate.js';
|
import { cleanupTruncations } from './services/truncate.js';
|
||||||
|
import { loadMcpConfig } from './services/mcp-config.js';
|
||||||
|
import { initialize as initMcp, getTools as getMcpTools, shutdown as shutdownMcp } from './services/mcp-client.js';
|
||||||
|
import { appendMcpTools } from './services/tools.js';
|
||||||
|
import { refreshToolNames } from './services/agents.js';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
@@ -68,6 +73,23 @@ async function main() {
|
|||||||
// default_generation_settings.n_ctx — the value persisted as messages.ctx_max.
|
// default_generation_settings.n_ctx — the value persisted as messages.ctx_max.
|
||||||
configureModelContext({ llamaSwapUrl: config.LLAMA_SWAP_URL });
|
configureModelContext({ llamaSwapUrl: config.LLAMA_SWAP_URL });
|
||||||
|
|
||||||
|
// v1.15.0-mcp-multi: read MCP config file and connect to all enabled servers.
|
||||||
|
// Runs before route registration so the tool list is complete when the first
|
||||||
|
// inference request arrives. Per-server graceful degradation: one failing
|
||||||
|
// server doesn't block others.
|
||||||
|
const mcpConfigPath = config.MCP_CONFIG_PATH ?? '/data/mcp.json';
|
||||||
|
const mcpServers = loadMcpConfig(mcpConfigPath, app.log);
|
||||||
|
if (mcpServers.length > 0) {
|
||||||
|
await initMcp(mcpServers, app.log);
|
||||||
|
const mcpTools = getMcpTools();
|
||||||
|
if (mcpTools.length > 0) {
|
||||||
|
appendMcpTools(mcpTools);
|
||||||
|
refreshToolNames();
|
||||||
|
app.log.info({ servers: mcpServers.length, tools: mcpTools.length }, 'mcp: registered');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.addHook('onClose', async () => { await shutdownMcp(); });
|
||||||
|
|
||||||
await app.register(fastifyWebsocket);
|
await app.register(fastifyWebsocket);
|
||||||
|
|
||||||
app.get('/api/health', async () => {
|
app.get('/api/health', async () => {
|
||||||
@@ -115,7 +137,7 @@ async function main() {
|
|||||||
broker.publishUserFrame(user, frame as unknown as import('./types/ws-frames.js').WsFrame);
|
broker.publishUserFrame(user, frame as unknown as import('./types/ws-frames.js').WsFrame);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
registerMessageRoutes(app, sql, {
|
registerMessageRoutes(app, sql, config, broker, {
|
||||||
enqueueInference: (sessionId, chatId, assistantId, user) => {
|
enqueueInference: (sessionId, chatId, assistantId, user) => {
|
||||||
inference.enqueue(sessionId, chatId, assistantId, user);
|
inference.enqueue(sessionId, chatId, assistantId, user);
|
||||||
},
|
},
|
||||||
@@ -160,6 +182,7 @@ async function main() {
|
|||||||
broker.publishFrame(sessionId, frame as import('./types/ws-frames.js').WsFrame);
|
broker.publishFrame(sessionId, frame as import('./types/ws-frames.js').WsFrame);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
registerArtifactRoutes(app, sql);
|
||||||
registerSkillsRoutes(app, sql, {
|
registerSkillsRoutes(app, sql, {
|
||||||
enqueueInference: (sessionId, chatId, assistantId, user) => {
|
enqueueInference: (sessionId, chatId, assistantId, user) => {
|
||||||
inference.enqueue(sessionId, chatId, assistantId, user);
|
inference.enqueue(sessionId, chatId, assistantId, user);
|
||||||
|
|||||||
70
apps/server/src/routes/__tests__/sessions.test.ts
Normal file
70
apps/server/src/routes/__tests__/sessions.test.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// v1.13.17-cross-repo-reads: PATCH /api/sessions/:id allowed_read_paths
|
||||||
|
// subset enforcement. Sam flagged in the compliance review that without a
|
||||||
|
// runtime subset check, a malicious client could POST
|
||||||
|
// {"allowed_read_paths":["/etc"]}
|
||||||
|
// and bypass the user-consent grant flow entirely. The findUnauthorizedAdditions
|
||||||
|
// helper is the guard; tests pin its behavior so a regression in the helper
|
||||||
|
// or its callsite (PATCH handler in sessions.ts) trips CI before prod.
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { findUnauthorizedAdditions } from '../sessions.js';
|
||||||
|
|
||||||
|
describe('findUnauthorizedAdditions — PATCH allowed_read_paths subset guard', () => {
|
||||||
|
it('returns no extras when requested is empty (full revoke)', () => {
|
||||||
|
expect(findUnauthorizedAdditions(['/opt/forks/foo'], [])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no extras when requested is a strict subset (single revoke)', () => {
|
||||||
|
expect(
|
||||||
|
findUnauthorizedAdditions(['/opt/forks/foo', '/opt/forks/bar'], ['/opt/forks/foo']),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no extras when requested equals prior (no-op PATCH)', () => {
|
||||||
|
expect(
|
||||||
|
findUnauthorizedAdditions(['/opt/forks/foo', '/opt/forks/bar'], [
|
||||||
|
'/opt/forks/foo',
|
||||||
|
'/opt/forks/bar',
|
||||||
|
]),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags an unauthorized addition when prior is empty', () => {
|
||||||
|
// The /etc bypass attempt — Sam's specific concern from the compliance
|
||||||
|
// review. Without this guard, the PATCH would have written /etc directly.
|
||||||
|
expect(findUnauthorizedAdditions([], ['/etc'])).toEqual(['/etc']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags a single unauthorized addition mixed in with valid revokes', () => {
|
||||||
|
// The attacker still tries to be sneaky: keep one legit entry, drop
|
||||||
|
// another, slip in a new one. The guard catches the addition regardless
|
||||||
|
// of how the rest of the array shrinks.
|
||||||
|
expect(
|
||||||
|
findUnauthorizedAdditions(['/opt/forks/foo', '/opt/forks/bar'], [
|
||||||
|
'/opt/forks/foo',
|
||||||
|
'/var/secrets',
|
||||||
|
]),
|
||||||
|
).toEqual(['/var/secrets']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags every unauthorized addition when there are multiple', () => {
|
||||||
|
expect(
|
||||||
|
findUnauthorizedAdditions(['/opt/forks/foo'], ['/opt/forks/foo', '/etc', '/root']),
|
||||||
|
).toEqual(['/etc', '/root']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats requested duplicates correctly (each occurrence checked)', () => {
|
||||||
|
// If the requested array has duplicates of an unauthorized entry, the
|
||||||
|
// guard surfaces each one. (A frontend would never send duplicates, but
|
||||||
|
// the guard's contract shouldn't assume that.)
|
||||||
|
expect(findUnauthorizedAdditions([], ['/etc', '/etc'])).toEqual(['/etc', '/etc']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not flag entries present in prior even if requested has duplicates', () => {
|
||||||
|
// Duplicate of an authorized entry passes — the membership check is by
|
||||||
|
// value, not by index. Settled by Set.has semantics.
|
||||||
|
expect(
|
||||||
|
findUnauthorizedAdditions(['/opt/forks/foo'], ['/opt/forks/foo', '/opt/forks/foo']),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
231
apps/server/src/routes/artifacts.ts
Normal file
231
apps/server/src/routes/artifacts.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
// v1.14.x-html-artifact-panes: artifact download routes.
|
||||||
|
//
|
||||||
|
// Two endpoints:
|
||||||
|
// POST /api/chats/:id/messages/:msg_id/artifacts/download?fmt=md|html
|
||||||
|
// Materialises a file under <projectRoot>/.boocode/artifacts/ and
|
||||||
|
// returns {path, url}. fmt=html requires an existing html_artifact part
|
||||||
|
// on the message (404 otherwise). fmt=md works on any assistant
|
||||||
|
// message with non-empty content.
|
||||||
|
//
|
||||||
|
// GET /api/projects/:project_id/artifacts/:filename
|
||||||
|
// Streams a previously-written artifact back with
|
||||||
|
// Content-Disposition: attachment. Path-guarded to the project's
|
||||||
|
// artifacts dir; rejects traversal attempts.
|
||||||
|
|
||||||
|
import { createReadStream } from 'node:fs';
|
||||||
|
import { realpath, stat } from 'node:fs/promises';
|
||||||
|
import { resolve, sep, basename } from 'node:path';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
import {
|
||||||
|
writeHtmlArtifact,
|
||||||
|
writeMarkdownArtifact,
|
||||||
|
type HtmlArtifactPayload,
|
||||||
|
} from '../services/artifacts.js';
|
||||||
|
|
||||||
|
const DownloadQuery = z.object({
|
||||||
|
fmt: z.enum(['md', 'html']),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filename safety: alnum, dash, dot, underscore only. Blocks `..`, slashes,
|
||||||
|
// nul bytes, etc. before we even touch the filesystem.
|
||||||
|
const FilenameRe = /^[A-Za-z0-9._-]+$/;
|
||||||
|
|
||||||
|
interface ChatRow {
|
||||||
|
id: string;
|
||||||
|
session_id: string;
|
||||||
|
project_id: string;
|
||||||
|
project_path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageRow {
|
||||||
|
id: string;
|
||||||
|
chat_id: string;
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerArtifactRoutes(app: FastifyInstance, sql: Sql): void {
|
||||||
|
app.post<{
|
||||||
|
Params: { id: string; msg_id: string };
|
||||||
|
Querystring: { fmt?: string };
|
||||||
|
}>(
|
||||||
|
'/api/chats/:id/messages/:msg_id/artifacts/download',
|
||||||
|
async (req, reply) => {
|
||||||
|
const parsed = DownloadQuery.safeParse(req.query);
|
||||||
|
if (!parsed.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'invalid query', details: parsed.error.flatten() };
|
||||||
|
}
|
||||||
|
const { fmt } = parsed.data;
|
||||||
|
const { id: chatId, msg_id: messageId } = req.params;
|
||||||
|
|
||||||
|
const chatRows = await sql<ChatRow[]>`
|
||||||
|
SELECT c.id, c.session_id, s.project_id, p.path AS project_path
|
||||||
|
FROM chats c
|
||||||
|
JOIN sessions s ON s.id = c.session_id
|
||||||
|
JOIN projects p ON p.id = s.project_id
|
||||||
|
WHERE c.id = ${chatId}
|
||||||
|
`;
|
||||||
|
if (chatRows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'chat not found' };
|
||||||
|
}
|
||||||
|
const chat = chatRows[0]!;
|
||||||
|
|
||||||
|
const msgRows = await sql<MessageRow[]>`
|
||||||
|
SELECT id, chat_id, role, content
|
||||||
|
FROM messages
|
||||||
|
WHERE id = ${messageId} AND chat_id = ${chatId}
|
||||||
|
`;
|
||||||
|
if (msgRows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'message not found' };
|
||||||
|
}
|
||||||
|
const msg = msgRows[0]!;
|
||||||
|
if (msg.role !== 'assistant') {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'only assistant messages produce artifacts' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = { projectId: chat.project_id, projectRoot: chat.project_path };
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fmt === 'md') {
|
||||||
|
if (!msg.content || msg.content.trim().length === 0) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'message has no content to export' };
|
||||||
|
}
|
||||||
|
const result = await writeMarkdownArtifact(
|
||||||
|
{ content: msg.content },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
// fmt === 'html': require an html_artifact part on the message.
|
||||||
|
const partRows = await sql<{ payload: HtmlArtifactPayload }[]>`
|
||||||
|
SELECT payload
|
||||||
|
FROM message_parts
|
||||||
|
WHERE message_id = ${messageId} AND kind = 'html_artifact'
|
||||||
|
ORDER BY sequence ASC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
if (partRows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'no html_artifact part on this message' };
|
||||||
|
}
|
||||||
|
const result = await writeHtmlArtifact(partRows[0]!.payload, ctx);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
req.log.error({ err, messageId, fmt }, 'artifact write failed');
|
||||||
|
reply.code(500);
|
||||||
|
return {
|
||||||
|
error: err instanceof Error ? err.message : 'artifact write failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// v1.14.x-html-artifact-panes: HtmlArtifactPane needs the payload on click
|
||||||
|
// to render its iframe. Returns 404 when the message has no html_artifact
|
||||||
|
// sibling part — frontend uses that signal to open the markdown_artifact
|
||||||
|
// pane variant instead. Payload shape matches HtmlArtifactPayload in
|
||||||
|
// services/artifacts.ts.
|
||||||
|
app.get<{ Params: { id: string; msg_id: string } }>(
|
||||||
|
'/api/chats/:id/messages/:msg_id/html_artifact',
|
||||||
|
async (req, reply) => {
|
||||||
|
const { id: chatId, msg_id: messageId } = req.params;
|
||||||
|
const partRows = await sql<{ payload: HtmlArtifactPayload }[]>`
|
||||||
|
SELECT payload
|
||||||
|
FROM message_parts mp
|
||||||
|
JOIN messages m ON m.id = mp.message_id
|
||||||
|
WHERE mp.message_id = ${messageId}
|
||||||
|
AND m.chat_id = ${chatId}
|
||||||
|
AND mp.kind = 'html_artifact'
|
||||||
|
ORDER BY mp.sequence ASC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
if (partRows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'no html_artifact part on this message' };
|
||||||
|
}
|
||||||
|
return partRows[0]!.payload;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get<{ Params: { project_id: string; filename: string } }>(
|
||||||
|
'/api/projects/:project_id/artifacts/:filename',
|
||||||
|
async (req, reply) => {
|
||||||
|
const { project_id: projectId, filename } = req.params;
|
||||||
|
// Strip directory components defensively; only the basename is allowed.
|
||||||
|
const base = basename(filename);
|
||||||
|
if (base !== filename || !FilenameRe.test(base)) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'invalid filename' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectRows = await sql<{ id: string; path: string }[]>`
|
||||||
|
SELECT id, path FROM projects WHERE id = ${projectId}
|
||||||
|
`;
|
||||||
|
if (projectRows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'project not found' };
|
||||||
|
}
|
||||||
|
const project = projectRows[0]!;
|
||||||
|
|
||||||
|
let resolvedRoot: string;
|
||||||
|
try {
|
||||||
|
resolvedRoot = await realpath(project.path);
|
||||||
|
} catch {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'project path missing' };
|
||||||
|
}
|
||||||
|
const artifactsDir = resolve(resolvedRoot, '.boocode/artifacts');
|
||||||
|
const absPath = resolve(artifactsDir, base);
|
||||||
|
if (!absPath.startsWith(artifactsDir + sep)) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'path traversal rejected' };
|
||||||
|
}
|
||||||
|
// Close the symlink-escape gap: if `.boocode/artifacts` (or an
|
||||||
|
// ancestor) is a symlink pointing outside resolvedRoot, the lexical
|
||||||
|
// prefix check above passes but the actual read lands outside the
|
||||||
|
// sandbox. Realpath the artifacts dir and re-verify.
|
||||||
|
try {
|
||||||
|
const realArtifactsDir = await realpath(artifactsDir);
|
||||||
|
if (
|
||||||
|
realArtifactsDir !== resolvedRoot &&
|
||||||
|
!realArtifactsDir.startsWith(resolvedRoot + sep)
|
||||||
|
) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'path traversal rejected' };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'artifact not found' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await stat(absPath);
|
||||||
|
} catch {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'artifact not found' };
|
||||||
|
}
|
||||||
|
const ext = base.toLowerCase().endsWith('.html')
|
||||||
|
? 'text/html; charset=utf-8'
|
||||||
|
: base.toLowerCase().endsWith('.md')
|
||||||
|
? 'text/markdown; charset=utf-8'
|
||||||
|
: 'application/octet-stream';
|
||||||
|
reply.header('Content-Type', ext);
|
||||||
|
// Defense-in-depth on LLM-generated HTML served through this route.
|
||||||
|
// Authelia gates the proxy; these headers limit blast radius if a
|
||||||
|
// payload tries to escape that boundary in-browser.
|
||||||
|
reply.header('X-Content-Type-Options', 'nosniff');
|
||||||
|
reply.header('Content-Security-Policy', 'sandbox');
|
||||||
|
reply.header(
|
||||||
|
'Content-Disposition',
|
||||||
|
`attachment; filename="${base.replace(/"/g, '')}"`,
|
||||||
|
);
|
||||||
|
return reply.send(createReadStream(absPath));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -296,13 +296,13 @@ export function registerChatRoutes(
|
|||||||
`;
|
`;
|
||||||
await tx`
|
await tx`
|
||||||
INSERT INTO messages (
|
INSERT INTO messages (
|
||||||
session_id, chat_id, role, content, kind, tool_calls, tool_results,
|
session_id, chat_id, role, content, kind,
|
||||||
status, tokens_used, ctx_used, ctx_max, started_at, finished_at,
|
status, tokens_used, ctx_used, ctx_max, started_at, finished_at,
|
||||||
created_at, metadata
|
created_at, metadata
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
${source.session_id}, ${chat!.id}, role, content, kind,
|
${source.session_id}, ${chat!.id}, role, content, kind,
|
||||||
tool_calls, tool_results, status,
|
status,
|
||||||
tokens_used, ctx_used, ctx_max, started_at, finished_at,
|
tokens_used, ctx_used, ctx_max, started_at, finished_at,
|
||||||
clock_timestamp() + (
|
clock_timestamp() + (
|
||||||
ROW_NUMBER() OVER (ORDER BY created_at ASC, id ASC) * INTERVAL '1 microsecond'
|
ROW_NUMBER() OVER (ORDER BY created_at ASC, id ASC) * INTERVAL '1 microsecond'
|
||||||
@@ -385,21 +385,25 @@ export function registerChatRoutes(
|
|||||||
reply.code(409);
|
reply.code(409);
|
||||||
return { error: 'message is not stale yet', age_seconds: msg.age_seconds };
|
return { error: 'message is not stale yet', age_seconds: msg.age_seconds };
|
||||||
}
|
}
|
||||||
const updated = await sql<Message[]>`
|
const updated = await sql<{ id: string }[]>`
|
||||||
UPDATE messages
|
UPDATE messages
|
||||||
SET status = 'failed',
|
SET status = 'failed',
|
||||||
content = COALESCE(content, ''),
|
content = COALESCE(content, ''),
|
||||||
finished_at = clock_timestamp()
|
finished_at = clock_timestamp()
|
||||||
WHERE id = ${msg.id} AND status = 'streaming'
|
WHERE id = ${msg.id} AND status = 'streaming'
|
||||||
RETURNING id, session_id, chat_id, role, content, kind, tool_calls, tool_results,
|
RETURNING id
|
||||||
status, last_seq, tokens_used, ctx_used, ctx_max, started_at, finished_at,
|
|
||||||
created_at, metadata, summary, tail_start_id, compacted_at
|
|
||||||
`;
|
`;
|
||||||
if (updated.length === 0) {
|
if (updated.length === 0) {
|
||||||
// Race: the row flipped out of 'streaming' between our SELECT and UPDATE.
|
// Race: the row flipped out of 'streaming' between our SELECT and UPDATE.
|
||||||
reply.code(409);
|
reply.code(409);
|
||||||
return { error: 'message status changed mid-request' };
|
return { error: 'message status changed mid-request' };
|
||||||
}
|
}
|
||||||
|
// v1.13.20: re-fetch via messages_with_parts so the returned shape
|
||||||
|
// carries parts-synthesized tool_calls / tool_results. The dropped
|
||||||
|
// legacy columns can no longer be selected directly.
|
||||||
|
const refreshed = await sql<Message[]>`
|
||||||
|
SELECT * FROM messages_with_parts WHERE id = ${msg.id}
|
||||||
|
`;
|
||||||
broker.publishUserFrame('default', {
|
broker.publishUserFrame('default', {
|
||||||
type: 'chat_status',
|
type: 'chat_status',
|
||||||
chat_id: msg.chat_id,
|
chat_id: msg.chat_id,
|
||||||
@@ -411,7 +415,7 @@ export function registerChatRoutes(
|
|||||||
message_id: msg.id,
|
message_id: msg.id,
|
||||||
chat_id: msg.chat_id,
|
chat_id: msg.chat_id,
|
||||||
});
|
});
|
||||||
return updated[0];
|
return refreshed[0];
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { Sql } from '../db.js';
|
import type { Sql } from '../db.js';
|
||||||
|
import type { Config } from '../config.js';
|
||||||
|
import type { Broker } from '../services/broker.js';
|
||||||
import type { Chat, Message, Session, ToolCall } from '../types/api.js';
|
import type { Chat, Message, Session, ToolCall } from '../types/api.js';
|
||||||
|
// v1.13.17-cross-repo-reads: grant_read_access resolves the grant root at
|
||||||
|
// decision time (not at request time) so concurrent project changes don't
|
||||||
|
// stale-bind the resolution.
|
||||||
|
import { resolveGrantRoot } from '../services/grant_resolver.js';
|
||||||
|
|
||||||
const SendBody = z.object({
|
const SendBody = z.object({
|
||||||
content: z.string().min(1).max(64_000),
|
content: z.string().min(1).max(64_000),
|
||||||
@@ -47,6 +53,21 @@ const AskUserInputArgs = z.object({
|
|||||||
.max(3),
|
.max(3),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v1.13.17-cross-repo-reads: grant decision body. tool_call_id is the
|
||||||
|
// model-emitted id (e.g. "call_abc123"), not a UUID. decision is binary.
|
||||||
|
const GrantReadAccessBody = z.object({
|
||||||
|
tool_call_id: z.string().min(1),
|
||||||
|
decision: z.enum(['allow', 'deny']),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Same shape as services/request_read_access.ts RequestReadAccessInput.
|
||||||
|
// Re-derived to avoid the services/tools.ts import (matches the
|
||||||
|
// AskUserInputArgs pattern above).
|
||||||
|
const RequestReadAccessArgs = z.object({
|
||||||
|
path: z.string().min(1),
|
||||||
|
reason: z.string().min(1).max(500),
|
||||||
|
});
|
||||||
|
|
||||||
interface MessageHandlers {
|
interface MessageHandlers {
|
||||||
enqueueInference: (sessionId: string, chatId: string, assistantMessageId: string, user: string) => void;
|
enqueueInference: (sessionId: string, chatId: string, assistantMessageId: string, user: string) => void;
|
||||||
// v1.11: returns a promise that resolves after compaction.process finishes
|
// v1.11: returns a promise that resolves after compaction.process finishes
|
||||||
@@ -76,6 +97,8 @@ interface MessageHandlers {
|
|||||||
export function registerMessageRoutes(
|
export function registerMessageRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
sql: Sql,
|
sql: Sql,
|
||||||
|
config: Config,
|
||||||
|
broker: Broker,
|
||||||
handlers: MessageHandlers
|
handlers: MessageHandlers
|
||||||
): void {
|
): void {
|
||||||
app.get<{ Params: { id: string } }>(
|
app.get<{ Params: { id: string } }>(
|
||||||
@@ -582,15 +605,11 @@ export function registerMessageRoutes(
|
|||||||
|
|
||||||
const toolMessageId = toolRow.message_id;
|
const toolMessageId = toolRow.message_id;
|
||||||
const result = await sql.begin(async (tx) => {
|
const result = await sql.begin(async (tx) => {
|
||||||
await tx`
|
// v1.13.20: parts-only. Replace the pending tool_result part inserted
|
||||||
UPDATE messages
|
// at message creation (tool-phase.ts) with the answered one. Delete-
|
||||||
SET tool_results = ${tx.json(newToolResults as never)}
|
// then-insert is simpler than UPDATE because parts are append-style
|
||||||
WHERE id = ${toolMessageId}
|
// elsewhere; the UNIQUE (message_id, sequence) constraint blocks
|
||||||
`;
|
// plain insert.
|
||||||
// v1.13.0: replace the pending tool_result part inserted at message
|
|
||||||
// creation (tool-phase.ts) with the answered one. Delete-then-insert
|
|
||||||
// is simpler than UPDATE because parts are append-style elsewhere;
|
|
||||||
// the UNIQUE (message_id, sequence) constraint blocks plain insert.
|
|
||||||
await tx`DELETE FROM message_parts WHERE message_id = ${toolMessageId} AND kind = 'tool_result'`;
|
await tx`DELETE FROM message_parts WHERE message_id = ${toolMessageId} AND kind = 'tool_result'`;
|
||||||
await tx`
|
await tx`
|
||||||
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||||
@@ -626,4 +645,230 @@ export function registerMessageRoutes(
|
|||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// v1.13.17-cross-repo-reads: resume an awaiting-grant pause. Mirror shape
|
||||||
|
// of /answer_user_input (validate, look up via message_parts, UPDATE,
|
||||||
|
// publish, enqueue). Differences vs /answer_user_input:
|
||||||
|
// - On 'allow', re-resolves the grant root via grant_resolver (state
|
||||||
|
// may have changed since the prompt fired — concurrent project add,
|
||||||
|
// etc.). Resolution failure auto-falls to a denial with reason text
|
||||||
|
// rather than 500ing.
|
||||||
|
// - On 'allow' with a valid root, appends to sessions.allowed_read_paths
|
||||||
|
// (deduplicated) inside the same transaction.
|
||||||
|
// - On success, also publishes session_updated so an open SettingsPane
|
||||||
|
// refetches the new grant list.
|
||||||
|
// Error codes match /answer:
|
||||||
|
// 400 invalid_body / mismatched_answer_shape (bad args on the tool_call)
|
||||||
|
// 404 chat_not_found / unknown_tool_call_id
|
||||||
|
// 409 tool_call_already_answered
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/api/chats/:id/grant_read_access',
|
||||||
|
async (req, reply) => {
|
||||||
|
const parsed = GrantReadAccessBody.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'invalid_body', details: parsed.error.flatten() };
|
||||||
|
}
|
||||||
|
const { tool_call_id, decision } = parsed.data;
|
||||||
|
|
||||||
|
const chatRows = await sql<Chat[]>`
|
||||||
|
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
|
||||||
|
`;
|
||||||
|
if (chatRows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'chat_not_found' };
|
||||||
|
}
|
||||||
|
const chat = chatRows[0]!;
|
||||||
|
const sessionId = chat.session_id;
|
||||||
|
|
||||||
|
// Mirror the /answer lookup: assistant tool_call by id via message_parts.
|
||||||
|
const callerRows = await sql<{
|
||||||
|
message_id: string;
|
||||||
|
payload: { id: string; name: string; args: Record<string, unknown> };
|
||||||
|
}[]>`
|
||||||
|
SELECT p.message_id, p.payload
|
||||||
|
FROM message_parts p
|
||||||
|
JOIN messages m ON m.id = p.message_id
|
||||||
|
WHERE m.chat_id = ${chat.id}
|
||||||
|
AND m.role = 'assistant'
|
||||||
|
AND p.kind = 'tool_call'
|
||||||
|
AND p.payload->>'id' = ${tool_call_id}
|
||||||
|
ORDER BY m.created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
const callerRow = callerRows[0];
|
||||||
|
if (!callerRow) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'unknown_tool_call_id' };
|
||||||
|
}
|
||||||
|
const foundCall: ToolCall = {
|
||||||
|
id: callerRow.payload.id,
|
||||||
|
name: callerRow.payload.name,
|
||||||
|
args: callerRow.payload.args,
|
||||||
|
};
|
||||||
|
if (foundCall.name !== 'request_read_access') {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'tool_call_not_request_read_access' };
|
||||||
|
}
|
||||||
|
const argsParsed = RequestReadAccessArgs.safeParse(foundCall.args);
|
||||||
|
if (!argsParsed.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'mismatched_answer_shape', detail: 'tool_call args invalid' };
|
||||||
|
}
|
||||||
|
const requestedPath = argsParsed.data.path;
|
||||||
|
|
||||||
|
// Find the pending tool row.
|
||||||
|
const toolRows = await sql<{
|
||||||
|
message_id: string;
|
||||||
|
payload: { tool_call_id: string; output: unknown };
|
||||||
|
}[]>`
|
||||||
|
SELECT p.message_id, p.payload
|
||||||
|
FROM message_parts p
|
||||||
|
JOIN messages m ON m.id = p.message_id
|
||||||
|
WHERE m.chat_id = ${chat.id}
|
||||||
|
AND m.role = 'tool'
|
||||||
|
AND p.kind = 'tool_result'
|
||||||
|
AND p.payload->>'tool_call_id' = ${tool_call_id}
|
||||||
|
ORDER BY m.created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
const toolRow = toolRows[0];
|
||||||
|
if (!toolRow) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'unknown_tool_call_id', detail: 'tool message not found' };
|
||||||
|
}
|
||||||
|
if (toolRow.payload && toolRow.payload.output !== null) {
|
||||||
|
reply.code(409);
|
||||||
|
return { error: 'tool_call_already_answered' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up session + project so we can re-resolve the grant root and
|
||||||
|
// append to allowed_read_paths atomically. We don't need agent or
|
||||||
|
// history here — just the project path for the resolver.
|
||||||
|
const sessionRows = await sql<{
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
allowed_read_paths: string[];
|
||||||
|
project_path: string;
|
||||||
|
}[]>`
|
||||||
|
SELECT s.id, s.project_id, s.allowed_read_paths, p.path AS project_path
|
||||||
|
FROM sessions s
|
||||||
|
JOIN projects p ON p.id = s.project_id
|
||||||
|
WHERE s.id = ${sessionId}
|
||||||
|
`;
|
||||||
|
const sessionRow = sessionRows[0];
|
||||||
|
if (!sessionRow) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'session_not_found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decision branch. 'deny' is the easy path: nothing to resolve or
|
||||||
|
// persist. 'allow' resolves the grant root; if resolution fails (e.g.
|
||||||
|
// path was deleted, project removed since prompt) the tool gets a
|
||||||
|
// denial with the resolver's reason text instead of a 500.
|
||||||
|
let resultOutput: string;
|
||||||
|
let grantRoot: string | null = null;
|
||||||
|
if (decision === 'allow') {
|
||||||
|
const resolution = await resolveGrantRoot(
|
||||||
|
sql,
|
||||||
|
requestedPath,
|
||||||
|
sessionRow.project_path,
|
||||||
|
config.PROJECT_ROOT_WHITELIST,
|
||||||
|
);
|
||||||
|
if (!resolution.ok) {
|
||||||
|
resultOutput = `denied: ${resolution.reason}`;
|
||||||
|
} else {
|
||||||
|
grantRoot = resolution.root;
|
||||||
|
resultOutput = `granted: ${grantRoot}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resultOutput = 'denied';
|
||||||
|
}
|
||||||
|
|
||||||
|
const newToolResults = {
|
||||||
|
tool_call_id,
|
||||||
|
output: resultOutput,
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
const toolMessageId = toolRow.message_id;
|
||||||
|
const dbResult = await sql.begin(async (tx) => {
|
||||||
|
// v1.13.20: parts-only. Same delete+insert dance as /answer —
|
||||||
|
// UNIQUE (message_id, sequence) blocks plain UPDATE on append-style
|
||||||
|
// parts.
|
||||||
|
await tx`DELETE FROM message_parts WHERE message_id = ${toolMessageId} AND kind = 'tool_result'`;
|
||||||
|
await tx`
|
||||||
|
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||||
|
VALUES (${toolMessageId}, 0, 'tool_result', ${tx.json(newToolResults as never)})
|
||||||
|
`;
|
||||||
|
// Persist the grant if we have one. ARRAY-level dedup — append only
|
||||||
|
// when the root isn't already present. The session row gets
|
||||||
|
// touched (updated_at) so the post-update publish below has a
|
||||||
|
// fresh timestamp.
|
||||||
|
let allowedRootsAfter = sessionRow.allowed_read_paths;
|
||||||
|
if (grantRoot !== null) {
|
||||||
|
if (!sessionRow.allowed_read_paths.includes(grantRoot)) {
|
||||||
|
const updated = await tx<{ allowed_read_paths: string[] }[]>`
|
||||||
|
UPDATE sessions
|
||||||
|
SET allowed_read_paths = array_append(allowed_read_paths, ${grantRoot}),
|
||||||
|
updated_at = clock_timestamp()
|
||||||
|
WHERE id = ${sessionId}
|
||||||
|
RETURNING allowed_read_paths
|
||||||
|
`;
|
||||||
|
allowedRootsAfter = updated[0]?.allowed_read_paths ?? sessionRow.allowed_read_paths;
|
||||||
|
} else {
|
||||||
|
// Already present — touch updated_at so any open settings
|
||||||
|
// panel still picks up the no-op via session_updated.
|
||||||
|
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [assistantMsg] = await tx<{ id: string }[]>`
|
||||||
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
|
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
|
||||||
|
return {
|
||||||
|
tool_message_id: toolMessageId,
|
||||||
|
assistant_message_id: assistantMsg!.id,
|
||||||
|
allowed_roots_after: allowedRootsAfter,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Publish the deferred tool_result frame so the pending card flips to
|
||||||
|
// its answered view without a refetch.
|
||||||
|
handlers.publishSessionFrame(sessionId, {
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_message_id: dbResult.tool_message_id,
|
||||||
|
tool_call_id,
|
||||||
|
chat_id: chat.id,
|
||||||
|
output: resultOutput,
|
||||||
|
truncated: false,
|
||||||
|
});
|
||||||
|
// session_updated nudge so any open SettingsPane refetches and sees
|
||||||
|
// the new allowed_read_paths. We publish on the user channel to match
|
||||||
|
// the existing PATCH /api/sessions/:id behavior — frontend refetches
|
||||||
|
// via api.sessions.get on receipt.
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
broker.publishUserFrame('default', {
|
||||||
|
type: 'session_updated',
|
||||||
|
session_id: sessionId,
|
||||||
|
project_id: sessionRow.project_id,
|
||||||
|
// session name doesn't change on grant; we look it up fresh to
|
||||||
|
// avoid carrying stale state if a rename raced us.
|
||||||
|
name:
|
||||||
|
(
|
||||||
|
await sql<{ name: string }[]>`SELECT name FROM sessions WHERE id = ${sessionId}`
|
||||||
|
)[0]?.name ?? '',
|
||||||
|
updated_at: nowIso,
|
||||||
|
});
|
||||||
|
handlers.enqueueInference(sessionId, chat.id, dbResult.assistant_message_id, 'default');
|
||||||
|
|
||||||
|
reply.code(202);
|
||||||
|
return {
|
||||||
|
tool_message_id: dbResult.tool_message_id,
|
||||||
|
assistant_message_id: dbResult.assistant_message_id,
|
||||||
|
allowed_read_paths: dbResult.allowed_roots_after,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,37 @@ const CreateBody = z.object({
|
|||||||
agent_id: z.string().min(1).max(200).nullable().optional(),
|
agent_id: z.string().min(1).max(200).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v1.14.x-html-artifact-panes: 'markdown_artifact' + 'html_artifact' added
|
||||||
|
// as pane kinds. Pane state is a reference only (chat_id + message_id +
|
||||||
|
// title) — the actual artifact body is fetched from the message row or
|
||||||
|
// message_parts.payload by the pane component on mount.
|
||||||
|
const MarkdownArtifactStateZ = z.object({
|
||||||
|
chat_id: z.string().min(1).max(200),
|
||||||
|
message_id: z.string().min(1).max(200),
|
||||||
|
title: z.string().max(500),
|
||||||
|
});
|
||||||
|
const HtmlArtifactStateZ = z.object({
|
||||||
|
chat_id: z.string().min(1).max(200),
|
||||||
|
message_id: z.string().min(1).max(200),
|
||||||
|
title: z.string().max(500),
|
||||||
|
});
|
||||||
|
|
||||||
const WorkspacePaneZ = z.object({
|
const WorkspacePaneZ = z.object({
|
||||||
id: z.string().min(1).max(200),
|
id: z.string().min(1).max(200),
|
||||||
kind: z.enum(['chat', 'terminal', 'agent', 'empty', 'settings']),
|
kind: z.enum([
|
||||||
|
'chat',
|
||||||
|
'terminal',
|
||||||
|
'agent',
|
||||||
|
'empty',
|
||||||
|
'settings',
|
||||||
|
'markdown_artifact',
|
||||||
|
'html_artifact',
|
||||||
|
]),
|
||||||
chatId: z.string().min(1).max(200).optional(),
|
chatId: z.string().min(1).max(200).optional(),
|
||||||
chatIds: z.array(z.string().min(1).max(200)).max(50),
|
chatIds: z.array(z.string().min(1).max(200)).max(50),
|
||||||
activeChatIdx: z.number().int(),
|
activeChatIdx: z.number().int(),
|
||||||
|
markdown_artifact_state: MarkdownArtifactStateZ.optional(),
|
||||||
|
html_artifact_state: HtmlArtifactStateZ.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const WorkspacePanesBody = z.object({
|
const WorkspacePanesBody = z.object({
|
||||||
@@ -32,6 +57,29 @@ const PatchBody = z.object({
|
|||||||
agent_id: z.string().min(1).max(200).nullable().optional(),
|
agent_id: z.string().min(1).max(200).nullable().optional(),
|
||||||
// v1.9: null = inherit from project default; true/false = explicit override.
|
// v1.9: null = inherit from project default; true/false = explicit override.
|
||||||
web_search_enabled: z.boolean().nullable().optional(),
|
web_search_enabled: z.boolean().nullable().optional(),
|
||||||
|
// v1.13.17-cross-repo-reads: revocation pathway. PATCH with a shortened
|
||||||
|
// list deletes entries; the grant flow itself APPENDS via the separate
|
||||||
|
// grant_read_access endpoint, never via this PATCH. Frontend treats this
|
||||||
|
// as "send the new whole array". Per-entry shape validation: must be
|
||||||
|
// absolute, no NUL, no `/..` traversal segment. Server doesn't re-validate
|
||||||
|
// whitelist membership on PATCH — entries already in the array were
|
||||||
|
// placed there by the grant endpoint after a full whitelist+repo-shape
|
||||||
|
// check. THE SUBSET CHECK (every entry must already be in the current
|
||||||
|
// array) is enforced at runtime in the PATCH handler below, NOT in this
|
||||||
|
// zod refinement, because the refinement has no access to the existing
|
||||||
|
// session row.
|
||||||
|
allowed_read_paths: z
|
||||||
|
.array(
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(1024)
|
||||||
|
.refine((p) => p.startsWith('/') && !p.includes('\0') && !p.includes('/..'), {
|
||||||
|
message: 'must be an absolute path without traversal markers',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.max(64)
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
async function resolveDefaultModel(sql: Sql, config: Config): Promise<string> {
|
async function resolveDefaultModel(sql: Sql, config: Config): Promise<string> {
|
||||||
@@ -40,6 +88,19 @@ async function resolveDefaultModel(sql: Sql, config: Config): Promise<string> {
|
|||||||
return config.DEFAULT_MODEL;
|
return config.DEFAULT_MODEL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.13.17-cross-repo-reads: subset enforcement for PATCH allowed_read_paths.
|
||||||
|
// The PATCH route can only SHRINK the array; growth happens exclusively via
|
||||||
|
// POST /api/chats/:id/grant_read_access (which requires user consent).
|
||||||
|
// Returns the list of disallowed-additions; an empty list means the request
|
||||||
|
// is a valid shrink-or-no-op. Exported for the unit test.
|
||||||
|
export function findUnauthorizedAdditions(
|
||||||
|
prior: readonly string[],
|
||||||
|
requested: readonly string[],
|
||||||
|
): string[] {
|
||||||
|
const priorSet = new Set(prior);
|
||||||
|
return requested.filter((p) => !priorSet.has(p));
|
||||||
|
}
|
||||||
|
|
||||||
export function registerSessionRoutes(
|
export function registerSessionRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
sql: Sql,
|
sql: Sql,
|
||||||
@@ -56,7 +117,7 @@ export function registerSessionRoutes(
|
|||||||
}
|
}
|
||||||
const status = req.query.status === 'archived' ? 'archived' : 'open';
|
const status = req.query.status === 'archived' ? 'archived' : 'open';
|
||||||
const rows = await sql<Session[]>`
|
const rows = await sql<Session[]>`
|
||||||
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes
|
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes, allowed_read_paths
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE project_id = ${req.params.id} AND status = ${status}
|
WHERE project_id = ${req.params.id} AND status = ${status}
|
||||||
ORDER BY updated_at DESC
|
ORDER BY updated_at DESC
|
||||||
@@ -124,7 +185,7 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
app.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => {
|
app.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => {
|
||||||
const rows = await sql<Session[]>`
|
const rows = await sql<Session[]>`
|
||||||
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes
|
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes, allowed_read_paths
|
||||||
FROM sessions WHERE id = ${req.params.id}
|
FROM sessions WHERE id = ${req.params.id}
|
||||||
`;
|
`;
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
@@ -150,15 +211,53 @@ export function registerSessionRoutes(
|
|||||||
const newAgentId = parsed.data.agent_id ?? null;
|
const newAgentId = parsed.data.agent_id ?? null;
|
||||||
const wseProvided = parsed.data.web_search_enabled !== undefined;
|
const wseProvided = parsed.data.web_search_enabled !== undefined;
|
||||||
const newWse = parsed.data.web_search_enabled ?? null;
|
const newWse = parsed.data.web_search_enabled ?? null;
|
||||||
// Read the prior name so the post-update publish can skip no-op renames
|
// v1.13.17-cross-repo-reads: tri-state on the wire (undefined = no
|
||||||
// (PATCH { name: "Foo" } where the session is already "Foo"). The window
|
// change, [] = clear). Frontend currently uses this PATCH only for
|
||||||
// between SELECT and UPDATE is sub-millisecond in the same request handler;
|
// revocation (delete a single entry from the existing array, send
|
||||||
// a concurrent rename in that gap would just mean one stale publish, which
|
// shortened result). Append-style grants go through the dedicated
|
||||||
// existing clients dedup by id.
|
// grant_read_access endpoint inside the inference loop.
|
||||||
const before = await sql<{ name: string }[]>`
|
const arpProvided = parsed.data.allowed_read_paths !== undefined;
|
||||||
SELECT name FROM sessions WHERE id = ${req.params.id}
|
const newArp = parsed.data.allowed_read_paths ?? [];
|
||||||
|
// Read the prior name + grants so the post-update publish can skip no-op
|
||||||
|
// renames (PATCH { name: "Foo" } where the session is already "Foo") AND
|
||||||
|
// so the subset check below has the current grant list to compare against.
|
||||||
|
// The window between SELECT and UPDATE is sub-millisecond in the same
|
||||||
|
// request handler; a concurrent rename in that gap would just mean one
|
||||||
|
// stale publish, which existing clients dedup by id.
|
||||||
|
const before = await sql<{ name: string; allowed_read_paths: string[] }[]>`
|
||||||
|
SELECT name, allowed_read_paths FROM sessions WHERE id = ${req.params.id}
|
||||||
`;
|
`;
|
||||||
const priorName = before[0]?.name;
|
const priorName = before[0]?.name;
|
||||||
|
const priorArp = before[0]?.allowed_read_paths ?? [];
|
||||||
|
|
||||||
|
// v1.13.17-cross-repo-reads: subset enforcement. The grant flow is the
|
||||||
|
// ONLY path that can add entries to allowed_read_paths — PATCH can only
|
||||||
|
// shrink the array, never grow it. Without this guard, a malicious
|
||||||
|
// client could POST {"allowed_read_paths":["/etc"]} and bypass the
|
||||||
|
// user-consent prompt entirely. Sam flagged this in the v1.13.17
|
||||||
|
// compliance review (2026-05-22).
|
||||||
|
// Race note: a concurrent grant landing between this SELECT and the
|
||||||
|
// UPDATE below would briefly make a "shouldn't-have-been-valid" PATCH
|
||||||
|
// succeed (the newly-granted root sneaks in). Inverse race — a
|
||||||
|
// legitimate revoke happening alongside a concurrent grant — could
|
||||||
|
// briefly reject the revoke; the user retries. Both are acceptable
|
||||||
|
// given the single-user threat model + sub-millisecond window.
|
||||||
|
if (arpProvided) {
|
||||||
|
const extras = findUnauthorizedAdditions(priorArp, newArp);
|
||||||
|
if (extras.length > 0) {
|
||||||
|
reply.code(400);
|
||||||
|
return {
|
||||||
|
error: 'invalid body',
|
||||||
|
details: {
|
||||||
|
fieldErrors: {
|
||||||
|
allowed_read_paths: [
|
||||||
|
`entries must already be granted; cannot add via PATCH: ${extras.join(', ')}`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
const rows = await sql<Session[]>`
|
const rows = await sql<Session[]>`
|
||||||
UPDATE sessions
|
UPDATE sessions
|
||||||
SET
|
SET
|
||||||
@@ -167,10 +266,11 @@ export function registerSessionRoutes(
|
|||||||
system_prompt = COALESCE(${system_prompt ?? null}, system_prompt),
|
system_prompt = COALESCE(${system_prompt ?? null}, system_prompt),
|
||||||
agent_id = CASE WHEN ${agentIdProvided} THEN ${newAgentId} ELSE agent_id END,
|
agent_id = CASE WHEN ${agentIdProvided} THEN ${newAgentId} ELSE agent_id END,
|
||||||
web_search_enabled = CASE WHEN ${wseProvided} THEN ${newWse} ELSE web_search_enabled END,
|
web_search_enabled = CASE WHEN ${wseProvided} THEN ${newWse} ELSE web_search_enabled END,
|
||||||
|
allowed_read_paths = CASE WHEN ${arpProvided} THEN ${sql.array(newArp, 25)} ELSE allowed_read_paths END,
|
||||||
updated_at = clock_timestamp()
|
updated_at = clock_timestamp()
|
||||||
WHERE id = ${req.params.id}
|
WHERE id = ${req.params.id}
|
||||||
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
|
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
|
||||||
agent_id, web_search_enabled, workspace_panes
|
agent_id, web_search_enabled, workspace_panes, allowed_read_paths
|
||||||
`;
|
`;
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
reply.code(404);
|
reply.code(404);
|
||||||
@@ -213,7 +313,7 @@ export function registerSessionRoutes(
|
|||||||
updated_at = clock_timestamp()
|
updated_at = clock_timestamp()
|
||||||
WHERE id = ${req.params.id}
|
WHERE id = ${req.params.id}
|
||||||
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
|
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
|
||||||
agent_id, web_search_enabled, workspace_panes
|
agent_id, web_search_enabled, workspace_panes, allowed_read_paths
|
||||||
`;
|
`;
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
reply.code(404);
|
reply.code(404);
|
||||||
|
|||||||
@@ -86,12 +86,12 @@ export function registerSkillsRoutes(
|
|||||||
|
|
||||||
const result = await sql.begin(async (tx) => {
|
const result = await sql.begin(async (tx) => {
|
||||||
const [synthAssistant] = await tx<{ id: string }[]>`
|
const [synthAssistant] = await tx<{ id: string }[]>`
|
||||||
INSERT INTO messages (session_id, chat_id, role, content, tool_calls, status, created_at)
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
VALUES (${sessionId}, ${chat.id}, 'assistant', '', ${sql.json(toolCalls as never)}, 'complete', clock_timestamp())
|
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'complete', clock_timestamp())
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
// v1.13.0: dual-write the synthetic assistant message's tool_call.
|
// v1.13.20: parts-only write. Single skill_use tool_call, no text
|
||||||
// Single skill_use tool_call, no text content, so one part at seq 0.
|
// content, so one part at seq 0.
|
||||||
await tx`
|
await tx`
|
||||||
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||||
VALUES (${synthAssistant!.id}, 0, 'tool_call', ${tx.json({
|
VALUES (${synthAssistant!.id}, 0, 'tool_call', ${tx.json({
|
||||||
@@ -101,11 +101,11 @@ export function registerSkillsRoutes(
|
|||||||
} as never)})
|
} as never)})
|
||||||
`;
|
`;
|
||||||
const [toolMsg] = await tx<{ id: string }[]>`
|
const [toolMsg] = await tx<{ id: string }[]>`
|
||||||
INSERT INTO messages (session_id, chat_id, role, content, tool_results, status, created_at)
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
VALUES (${sessionId}, ${chat.id}, 'tool', '', ${sql.json(toolResults as never)}, 'complete', clock_timestamp())
|
VALUES (${sessionId}, ${chat.id}, 'tool', '', 'complete', clock_timestamp())
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
// v1.13.0: dual-write the synthetic tool result (the skill body).
|
// v1.13.20: parts-only write of the synthetic tool result (skill body).
|
||||||
await tx`
|
await tx`
|
||||||
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||||
VALUES (${toolMsg!.id}, 0, 'tool_result', ${tx.json(toolResults as never)})
|
VALUES (${toolMsg!.id}, 0, 'tool_result', ${tx.json(toolResults as never)})
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ CREATE TABLE IF NOT EXISTS message_parts (
|
|||||||
kind text NOT NULL,
|
kind text NOT NULL,
|
||||||
payload jsonb NOT NULL,
|
payload jsonb NOT NULL,
|
||||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||||
CONSTRAINT message_parts_kind_chk CHECK (kind IN ('text', 'tool_call', 'tool_result', 'reasoning', 'step_start', 'synthesis')),
|
CONSTRAINT message_parts_kind_chk CHECK (kind IN ('text', 'tool_call', 'tool_result', 'reasoning', 'step_start', 'synthesis', 'html_artifact')),
|
||||||
CONSTRAINT message_parts_seq_uniq UNIQUE (message_id, sequence)
|
CONSTRAINT message_parts_seq_uniq UNIQUE (message_id, sequence)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS message_parts_msg_seq_idx ON message_parts (message_id, sequence);
|
CREATE INDEX IF NOT EXISTS message_parts_msg_seq_idx ON message_parts (message_id, sequence);
|
||||||
@@ -79,6 +79,10 @@ CREATE INDEX IF NOT EXISTS message_parts_hidden_idx
|
|||||||
-- 'synthesis'; drop + re-add the constraint with the extended enum. Fresh
|
-- 'synthesis'; drop + re-add the constraint with the extended enum. Fresh
|
||||||
-- installs hit the inline constraint above (already updated) and skip this
|
-- installs hit the inline constraint above (already updated) and skip this
|
||||||
-- block via the pg_constraint guard.
|
-- block via the pg_constraint guard.
|
||||||
|
-- v1.14.x-html-artifact-panes: extend the same constraint with 'html_artifact'.
|
||||||
|
-- DROP IF EXISTS + DO $$ pg_constraint $$ guard remains idempotent across
|
||||||
|
-- both v1.13.13 and v1.14.x boots; the IN list below is the union of every
|
||||||
|
-- kind ever shipped.
|
||||||
ALTER TABLE message_parts DROP CONSTRAINT IF EXISTS message_parts_kind_chk;
|
ALTER TABLE message_parts DROP CONSTRAINT IF EXISTS message_parts_kind_chk;
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -87,55 +91,48 @@ BEGIN
|
|||||||
) THEN
|
) THEN
|
||||||
ALTER TABLE message_parts
|
ALTER TABLE message_parts
|
||||||
ADD CONSTRAINT message_parts_kind_chk
|
ADD CONSTRAINT message_parts_kind_chk
|
||||||
CHECK (kind IN ('text', 'tool_call', 'tool_result', 'reasoning', 'step_start', 'synthesis'));
|
CHECK (kind IN ('text', 'tool_call', 'tool_result', 'reasoning', 'step_start', 'synthesis', 'html_artifact'));
|
||||||
END IF;
|
END IF;
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
-- v1.13.1-B: read-path view. Read sites SELECT FROM messages_with_parts
|
-- v1.13.1-B: read-path view. Read sites SELECT FROM messages_with_parts
|
||||||
-- instead of messages so tool_calls / tool_results / reasoning_parts come
|
-- instead of messages so tool_calls / tool_results / reasoning_parts come
|
||||||
-- from the granular message_parts table. The COALESCE means pre-v1.13.0
|
-- from the granular message_parts table.
|
||||||
-- history (no parts rows) still resolves via the legacy JSON columns; the
|
-- v1.13.20: post column-drop. The legacy COALESCE fallback over
|
||||||
-- dual-write from v1.13.0 keeps both in sync for all rows written since.
|
-- messages.tool_calls / messages.tool_results was removed because those
|
||||||
-- Writes continue to target `messages` directly — the view is read-only.
|
-- columns no longer exist on the table (see the ALTER TABLE DROP COLUMN
|
||||||
-- Shapes match the in-memory ToolCall / ToolResult types: tool_calls is a
|
-- statements below). Writes continue to target `messages` directly — the
|
||||||
-- jsonb array of {id, name, args}, tool_results is a single jsonb object
|
-- view is read-only. Shapes match the in-memory ToolCall / ToolResult
|
||||||
-- {tool_call_id, output, truncated, error?}. reasoning_parts is new — only
|
-- types: tool_calls is a jsonb array of {id, name, args}, tool_results is
|
||||||
-- consumed by the inference history fetch (payload.ts) so v1.13.1-C can
|
-- a single jsonb object {tool_call_id, output, truncated, error?}.
|
||||||
-- wire reasoning into the model payload. Not surfaced in external APIs yet.
|
-- reasoning_parts is consumed by the inference history fetch (payload.ts)
|
||||||
|
-- for v1.13.1-C reasoning round-tripping. Not surfaced in external APIs.
|
||||||
CREATE OR REPLACE VIEW messages_with_parts AS
|
CREATE OR REPLACE VIEW messages_with_parts AS
|
||||||
SELECT
|
SELECT
|
||||||
m.id, m.session_id, m.chat_id, m.role, m.content, m.kind, m.status,
|
m.id, m.session_id, m.chat_id, m.role, m.content, m.kind, m.status,
|
||||||
m.last_seq, m.tokens_used, m.ctx_used, m.ctx_max,
|
m.last_seq, m.tokens_used, m.ctx_used, m.ctx_max,
|
||||||
m.started_at, m.finished_at, m.created_at, m.metadata,
|
m.started_at, m.finished_at, m.created_at, m.metadata,
|
||||||
m.summary, m.tail_start_id, m.compacted_at,
|
m.summary, m.tail_start_id, m.compacted_at,
|
||||||
-- v1.13.4: prune semantics need to distinguish "no parts row exists"
|
(SELECT jsonb_agg(p.payload ORDER BY p.sequence)
|
||||||
-- (pre-v1.13.0 fallback to legacy column) from "all parts hidden"
|
|
||||||
-- (prune intended — return null/empty so the row drops from the model
|
|
||||||
-- payload). A naive COALESCE would fall back to the legacy column when
|
|
||||||
-- every part is hidden, undoing the prune. CASE on EXISTS(any kind)
|
|
||||||
-- splits the two cases.
|
|
||||||
CASE
|
|
||||||
WHEN EXISTS (SELECT 1 FROM message_parts pp
|
|
||||||
WHERE pp.message_id = m.id AND pp.kind = 'tool_call')
|
|
||||||
THEN (SELECT jsonb_agg(p.payload ORDER BY p.sequence)
|
|
||||||
FROM message_parts p
|
FROM message_parts p
|
||||||
WHERE p.message_id = m.id AND p.kind = 'tool_call' AND p.hidden_at IS NULL)
|
WHERE p.message_id = m.id AND p.kind = 'tool_call' AND p.hidden_at IS NULL) AS tool_calls,
|
||||||
ELSE m.tool_calls
|
(SELECT p.payload
|
||||||
END AS tool_calls,
|
|
||||||
CASE
|
|
||||||
WHEN EXISTS (SELECT 1 FROM message_parts pp
|
|
||||||
WHERE pp.message_id = m.id AND pp.kind = 'tool_result')
|
|
||||||
THEN (SELECT p.payload
|
|
||||||
FROM message_parts p
|
FROM message_parts p
|
||||||
WHERE p.message_id = m.id AND p.kind = 'tool_result' AND p.hidden_at IS NULL
|
WHERE p.message_id = m.id AND p.kind = 'tool_result' AND p.hidden_at IS NULL
|
||||||
ORDER BY p.sequence LIMIT 1)
|
ORDER BY p.sequence LIMIT 1) AS tool_results,
|
||||||
ELSE m.tool_results
|
|
||||||
END AS tool_results,
|
|
||||||
(SELECT jsonb_agg(p.payload ORDER BY p.sequence)
|
(SELECT jsonb_agg(p.payload ORDER BY p.sequence)
|
||||||
FROM message_parts p
|
FROM message_parts p
|
||||||
WHERE p.message_id = m.id AND p.kind = 'reasoning' AND p.hidden_at IS NULL) AS reasoning_parts
|
WHERE p.message_id = m.id AND p.kind = 'reasoning' AND p.hidden_at IS NULL) AS reasoning_parts
|
||||||
FROM messages m;
|
FROM messages m;
|
||||||
|
|
||||||
|
-- v1.13.20: drop legacy tool_calls/tool_results columns. Reads have routed
|
||||||
|
-- through messages_with_parts since v1.13.1-B; dual-writes removed in this
|
||||||
|
-- batch. The view above was simplified to remove COALESCE fallbacks before
|
||||||
|
-- this drop (Postgres rejects column-drop on view-referenced columns).
|
||||||
|
-- Idempotent via IF EXISTS.
|
||||||
|
ALTER TABLE messages DROP COLUMN IF EXISTS tool_calls;
|
||||||
|
ALTER TABLE messages DROP COLUMN IF EXISTS tool_results;
|
||||||
|
|
||||||
-- v1.13.10: per-tool token cost rolling window. Derives from
|
-- v1.13.10: per-tool token cost rolling window. Derives from
|
||||||
-- messages_with_parts (the v1.13.1-B view that COALESCEs message_parts over
|
-- messages_with_parts (the v1.13.1-B view that COALESCEs message_parts over
|
||||||
-- the legacy JSON column) so this works whether the chat predates v1.13.0
|
-- the legacy JSON column) so this works whether the chat predates v1.13.0
|
||||||
@@ -286,19 +283,6 @@ BEGIN
|
|||||||
END IF;
|
END IF;
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
-- v1.12.1: drop stale inline CHECK constraints that were superseded by the
|
|
||||||
-- named *_chk variants above. messages_status_check missed 'cancelled' and
|
|
||||||
-- messages_role_check missed 'system' — both narrower than what's in use.
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_status_check') THEN
|
|
||||||
ALTER TABLE messages DROP CONSTRAINT messages_status_check;
|
|
||||||
END IF;
|
|
||||||
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_role_check') THEN
|
|
||||||
ALTER TABLE messages DROP CONSTRAINT messages_role_check;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- v1.2-project-ux: projects.status + projects.gitea_remote
|
-- v1.2-project-ux: projects.status + projects.gitea_remote
|
||||||
-- KEEP IN SYNC: apps/server/src/types/api.ts PROJECT_STATUSES
|
-- KEEP IN SYNC: apps/server/src/types/api.ts PROJECT_STATUSES
|
||||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';
|
||||||
@@ -330,6 +314,16 @@ END $$;
|
|||||||
-- agent_id is the slugified agent name. NULL means "use BooCode defaults".
|
-- agent_id is the slugified agent name. NULL means "use BooCode defaults".
|
||||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS agent_id TEXT;
|
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS agent_id TEXT;
|
||||||
|
|
||||||
|
-- v1.13.17-cross-repo-reads: session-scoped read grants for paths outside the
|
||||||
|
-- session's primary project root. Populated only by the request_read_access
|
||||||
|
-- tool's approve branch; revoked via PATCH /api/sessions/:id. Values are
|
||||||
|
-- absolute paths to project roots OR repo-shaped dirs under
|
||||||
|
-- PROJECT_ROOT_WHITELIST (default /opt). No CHECK constraint — validation
|
||||||
|
-- happens at write time in services/grant_resolver.ts. Cleared automatically
|
||||||
|
-- when the session row is deleted (no cascade needed; the column goes with it).
|
||||||
|
ALTER TABLE sessions
|
||||||
|
ADD COLUMN IF NOT EXISTS allowed_read_paths TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||||
|
|
||||||
-- v1.8.2: per-message metadata for sentinels (cap-hit) and structured error
|
-- v1.8.2: per-message metadata for sentinels (cap-hit) and structured error
|
||||||
-- reasons. JSONB so future kinds can extend without further schema churn.
|
-- reasons. JSONB so future kinds can extend without further schema churn.
|
||||||
-- Shape for cap_hit: { kind: 'cap_hit', used: number, limit: number,
|
-- Shape for cap_hit: { kind: 'cap_hit', used: number, limit: number,
|
||||||
|
|||||||
261
apps/server/src/services/__tests__/artifacts.test.ts
Normal file
261
apps/server/src/services/__tests__/artifacts.test.ts
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
import { mkdtemp, mkdir, readFile, rm, symlink } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
decideHtmlArtifactWrite,
|
||||||
|
deriveHtmlSlug,
|
||||||
|
deriveHtmlTitle,
|
||||||
|
deriveMarkdownSlug,
|
||||||
|
detectHtmlArtifact,
|
||||||
|
HTML_ARTIFACT_MAX_BYTES,
|
||||||
|
writeHtmlArtifact,
|
||||||
|
writeMarkdownArtifact,
|
||||||
|
} from '../artifacts.js';
|
||||||
|
import { PathScopeError } from '../path_guard.js';
|
||||||
|
|
||||||
|
describe('deriveMarkdownSlug', () => {
|
||||||
|
it('uses the first # heading when present', () => {
|
||||||
|
expect(deriveMarkdownSlug('# Hello World\n\nbody')).toBe('hello-world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to first 6 words', () => {
|
||||||
|
const s = deriveMarkdownSlug('the quick brown fox jumps over the lazy dog');
|
||||||
|
expect(s).toBe('the-quick-brown-fox-jumps-over');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "artifact" for empty input', () => {
|
||||||
|
expect(deriveMarkdownSlug('')).toBe('artifact');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps at 60 chars and lowercases', () => {
|
||||||
|
const long = '# ' + 'A'.repeat(200);
|
||||||
|
const s = deriveMarkdownSlug(long);
|
||||||
|
expect(s.length).toBeLessThanOrEqual(60);
|
||||||
|
expect(s).toMatch(/^[a-z0-9-]+$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips trailing punctuation', () => {
|
||||||
|
expect(deriveMarkdownSlug('# Hello, World!!!')).toBe('hello-world');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deriveHtmlSlug', () => {
|
||||||
|
it('prefers payload.title when set', () => {
|
||||||
|
expect(
|
||||||
|
deriveHtmlSlug({ html_content: '<html></html>', title: 'My Title' }),
|
||||||
|
).toBe('my-title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to <title> tag', () => {
|
||||||
|
expect(
|
||||||
|
deriveHtmlSlug({
|
||||||
|
html_content: '<html><head><title>Page Title</title></head></html>',
|
||||||
|
title: null,
|
||||||
|
}),
|
||||||
|
).toBe('page-title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to first <h1> when no <title>', () => {
|
||||||
|
expect(
|
||||||
|
deriveHtmlSlug({
|
||||||
|
html_content: '<html><body><h1>Heading One</h1></body></html>',
|
||||||
|
title: null,
|
||||||
|
}),
|
||||||
|
).toBe('heading-one');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to inner text words', () => {
|
||||||
|
expect(
|
||||||
|
deriveHtmlSlug({
|
||||||
|
html_content: '<div>one two three four five six seven</div>',
|
||||||
|
title: null,
|
||||||
|
}),
|
||||||
|
).toBe('one-two-three-four-five-six');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deriveHtmlTitle', () => {
|
||||||
|
it('returns <title> content', () => {
|
||||||
|
expect(deriveHtmlTitle('<html><head><title>T</title></head></html>')).toBe('T');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to <h1>', () => {
|
||||||
|
expect(deriveHtmlTitle('<body><h1>H</h1></body>')).toBe('H');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to first 80 chars of inner text', () => {
|
||||||
|
const html = '<div>' + 'x '.repeat(100) + '</div>';
|
||||||
|
const t = deriveHtmlTitle(html);
|
||||||
|
expect(t).not.toBeNull();
|
||||||
|
expect(t!.length).toBeLessThanOrEqual(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for empty html', () => {
|
||||||
|
expect(deriveHtmlTitle('')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detectHtmlArtifact', () => {
|
||||||
|
it('detects <!DOCTYPE html> prefix case-insensitively', () => {
|
||||||
|
const html = '<!doctype HTML><html><body>x</body></html>';
|
||||||
|
expect(detectHtmlArtifact(html)).toBe(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips leading/trailing whitespace before matching', () => {
|
||||||
|
const html = '\n\n<!DOCTYPE html>\n<html></html>\n';
|
||||||
|
expect(detectHtmlArtifact(html)).toBe(html.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects fenced ```html block wrapping entire message', () => {
|
||||||
|
const wrapped = '```html\n<!DOCTYPE html>\n<html></html>\n```';
|
||||||
|
expect(detectHtmlArtifact(wrapped)).toContain('<!DOCTYPE html>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects plain markdown', () => {
|
||||||
|
expect(detectHtmlArtifact('# heading\n\nsome text')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects message with prose before the doctype', () => {
|
||||||
|
expect(
|
||||||
|
detectHtmlArtifact('Here you go: <!DOCTYPE html><html></html>'),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty input', () => {
|
||||||
|
expect(detectHtmlArtifact('')).toBeNull();
|
||||||
|
expect(detectHtmlArtifact(' \n ')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects fenced block without doctype/<html>', () => {
|
||||||
|
expect(detectHtmlArtifact('```html\n<div>x</div>\n```')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts fenced block containing <html> tag (no doctype)', () => {
|
||||||
|
const r = detectHtmlArtifact('```html\n<html><body>x</body></html>\n```');
|
||||||
|
expect(r).toContain('<html>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('writeMarkdownArtifact / writeHtmlArtifact', () => {
|
||||||
|
let projectRoot: string;
|
||||||
|
beforeEach(async () => {
|
||||||
|
projectRoot = await mkdtemp(join(tmpdir(), 'artifacts-test-'));
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(projectRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes a markdown artifact under .boocode/artifacts/', async () => {
|
||||||
|
const result = await writeMarkdownArtifact(
|
||||||
|
{ content: '# Hello\n\nbody' },
|
||||||
|
{ projectId: 'pid', projectRoot },
|
||||||
|
);
|
||||||
|
expect(result.path).toMatch(/\.boocode\/artifacts\/hello-\d+\.md$/);
|
||||||
|
expect(result.url).toMatch(/^\/api\/projects\/pid\/artifacts\/hello-\d+\.md$/);
|
||||||
|
const written = await readFile(result.path, 'utf8');
|
||||||
|
expect(written).toBe('# Hello\n\nbody');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes an html artifact', async () => {
|
||||||
|
const result = await writeHtmlArtifact(
|
||||||
|
{
|
||||||
|
html_content: '<!DOCTYPE html><html><head><title>X</title></head></html>',
|
||||||
|
char_count: 56,
|
||||||
|
title: 'X',
|
||||||
|
},
|
||||||
|
{ projectId: 'pid', projectRoot },
|
||||||
|
);
|
||||||
|
expect(result.path).toMatch(/\.boocode\/artifacts\/x-\d+\.html$/);
|
||||||
|
const written = await readFile(result.path, 'utf8');
|
||||||
|
expect(written).toContain('<!DOCTYPE html>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates the artifacts directory if absent', async () => {
|
||||||
|
// Confirm the writer mkdir-recursive's the artifacts dir on first call.
|
||||||
|
const result = await writeMarkdownArtifact(
|
||||||
|
{ content: '# T' },
|
||||||
|
{ projectId: 'pid', projectRoot },
|
||||||
|
);
|
||||||
|
expect(result.path).toContain('.boocode/artifacts');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('1MB cap behavior', () => {
|
||||||
|
it('reports the correct byte threshold', () => {
|
||||||
|
expect(HTML_ARTIFACT_MAX_BYTES).toBe(1_048_576);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exceeds threshold for oversize payload', () => {
|
||||||
|
const oversize = '<!DOCTYPE html>' + 'A'.repeat(HTML_ARTIFACT_MAX_BYTES);
|
||||||
|
expect(Buffer.byteLength(oversize, 'utf8')).toBeGreaterThan(
|
||||||
|
HTML_ARTIFACT_MAX_BYTES,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detectHtmlArtifact still returns content above the cap (cap is checked by caller)', () => {
|
||||||
|
// Detection is content-shape; the cap check lives in finalizeCompletion
|
||||||
|
// (error-handler.ts). This test pins that contract: the helper does not
|
||||||
|
// silently drop oversize payloads on the floor.
|
||||||
|
const big = '<!DOCTYPE html>' + 'x'.repeat(2_000_000);
|
||||||
|
expect(detectHtmlArtifact(big)).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('decideHtmlArtifactWrite', () => {
|
||||||
|
// Pure helper extracted from finalizeCompletion's cap-skip branch. Pins
|
||||||
|
// the warn-and-skip decision without mocking the full InferenceContext.
|
||||||
|
it('returns write=true for payloads under the cap', () => {
|
||||||
|
const html = '<!DOCTYPE html><html></html>';
|
||||||
|
const decision = decideHtmlArtifactWrite(html);
|
||||||
|
expect(decision.write).toBe(true);
|
||||||
|
expect(decision.byteLen).toBe(Buffer.byteLength(html, 'utf8'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns write=false with cap_exceeded reason for oversize payloads', () => {
|
||||||
|
const big = '<!DOCTYPE html>' + 'x'.repeat(HTML_ARTIFACT_MAX_BYTES);
|
||||||
|
const decision = decideHtmlArtifactWrite(big);
|
||||||
|
expect(decision.write).toBe(false);
|
||||||
|
if (!decision.write) {
|
||||||
|
expect(decision.reason).toBe('cap_exceeded');
|
||||||
|
expect(decision.byteLen).toBeGreaterThan(HTML_ARTIFACT_MAX_BYTES);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts payload exactly at the cap (boundary)', () => {
|
||||||
|
// byteLen === cap should write; only strictly greater skips.
|
||||||
|
const exact = 'x'.repeat(HTML_ARTIFACT_MAX_BYTES);
|
||||||
|
const decision = decideHtmlArtifactWrite(exact);
|
||||||
|
expect(decision.write).toBe(true);
|
||||||
|
expect(decision.byteLen).toBe(HTML_ARTIFACT_MAX_BYTES);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('symlink escape protection', () => {
|
||||||
|
// Closes the gap where `.boocode/artifacts` is a symlink pointing
|
||||||
|
// outside the project root. The lexical prefix check on the resolved
|
||||||
|
// candidate path passes (it's under projectRoot textually), but the
|
||||||
|
// post-mkdir realpath verification must catch the escape.
|
||||||
|
let projectRoot: string;
|
||||||
|
let outside: string;
|
||||||
|
beforeEach(async () => {
|
||||||
|
projectRoot = await mkdtemp(join(tmpdir(), 'artifacts-symlink-root-'));
|
||||||
|
outside = await mkdtemp(join(tmpdir(), 'artifacts-symlink-outside-'));
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(projectRoot, { recursive: true, force: true });
|
||||||
|
await rm(outside, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws PathScopeError when .boocode/artifacts is a symlink to outside the project', async () => {
|
||||||
|
// Create .boocode dir, then make `artifacts` a symlink pointing outside.
|
||||||
|
await mkdir(join(projectRoot, '.boocode'), { recursive: true });
|
||||||
|
await symlink(outside, join(projectRoot, '.boocode', 'artifacts'));
|
||||||
|
await expect(
|
||||||
|
writeMarkdownArtifact(
|
||||||
|
{ content: '# Hello' },
|
||||||
|
{ projectId: 'pid', projectRoot },
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(PathScopeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { callCodecontext } from '../codecontext_client.js';
|
import { callCodecontext } from '../codecontext_client.js';
|
||||||
@@ -203,3 +203,197 @@ describe('callCodecontext — error paths', () => {
|
|||||||
).rejects.toThrow(/timed out after 30000ms/);
|
).rejects.toThrow(/timed out after 30000ms/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- v1.13.18: file_path resolution tests -----------------------------------
|
||||||
|
|
||||||
|
describe('callCodecontext — file_path resolution', () => {
|
||||||
|
// Case 1: relative path resolves to absolute under project root
|
||||||
|
it('resolves a relative file_path to an absolute path inside project root', async () => {
|
||||||
|
// Create a real file so realpath can canonicalise it
|
||||||
|
const fileName = 'src_module.ts';
|
||||||
|
await writeFile(join(projectDir, fileName), '// hello');
|
||||||
|
const fetcher = vi.fn().mockResolvedValue(
|
||||||
|
mockJSONResponse({ result: 'file analysis', error: null }),
|
||||||
|
);
|
||||||
|
await callCodecontext(
|
||||||
|
{
|
||||||
|
toolName: 'get_file_analysis',
|
||||||
|
args: { file_path: fileName },
|
||||||
|
projectPath: projectDir,
|
||||||
|
},
|
||||||
|
fetcher as unknown as typeof fetch,
|
||||||
|
);
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||||
|
const body = JSON.parse(fetcher.mock.calls[0]![1]!.body as string);
|
||||||
|
// Should be the resolved absolute path
|
||||||
|
expect(body.file_path).toBe(join(projectDir, fileName));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Case 2: absolute path inside project root → realpathed → forwarded
|
||||||
|
it('passes through an absolute file_path inside project root', async () => {
|
||||||
|
const fileName = 'absolute_target.ts';
|
||||||
|
const absPath = join(projectDir, fileName);
|
||||||
|
await writeFile(absPath, '// absolute');
|
||||||
|
const fetcher = vi.fn().mockResolvedValue(
|
||||||
|
mockJSONResponse({ result: 'analysis', error: null }),
|
||||||
|
);
|
||||||
|
await callCodecontext(
|
||||||
|
{
|
||||||
|
toolName: 'get_file_analysis',
|
||||||
|
args: { file_path: absPath },
|
||||||
|
projectPath: projectDir,
|
||||||
|
},
|
||||||
|
fetcher as unknown as typeof fetch,
|
||||||
|
);
|
||||||
|
const body = JSON.parse(fetcher.mock.calls[0]![1]!.body as string);
|
||||||
|
expect(body.file_path).toBe(absPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Case 3: relative escape path → rejected with same error shape as target_dir escape
|
||||||
|
it('rejects a relative file_path that escapes the project root', async () => {
|
||||||
|
const fetcher = vi.fn();
|
||||||
|
await expect(
|
||||||
|
callCodecontext(
|
||||||
|
{
|
||||||
|
toolName: 'get_file_analysis',
|
||||||
|
args: { file_path: '../../etc/passwd' },
|
||||||
|
projectPath: projectDir,
|
||||||
|
},
|
||||||
|
fetcher as unknown as typeof fetch,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/escapes project root/);
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Case 4: absolute path outside project root → rejected
|
||||||
|
it('rejects an absolute file_path outside the project root', async () => {
|
||||||
|
const fetcher = vi.fn();
|
||||||
|
await expect(
|
||||||
|
callCodecontext(
|
||||||
|
{
|
||||||
|
toolName: 'get_file_analysis',
|
||||||
|
// /etc/passwd is outside any tmpdir project root
|
||||||
|
args: { file_path: '/etc/passwd' },
|
||||||
|
projectPath: projectDir,
|
||||||
|
},
|
||||||
|
fetcher as unknown as typeof fetch,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/escapes project root/);
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Case 5: nonexistent file (ENOENT) → forwarded as un-realpath'd absolute
|
||||||
|
it('forwards a nonexistent file_path as absolute without throwing', async () => {
|
||||||
|
const missingPath = join(projectDir, 'does_not_exist.ts');
|
||||||
|
const fetcher = vi.fn().mockResolvedValue(
|
||||||
|
mockJSONResponse({ result: null, error: 'File not found in graph: ' + missingPath }),
|
||||||
|
);
|
||||||
|
// The resolver should NOT throw; the error comes back from the sidecar
|
||||||
|
await expect(
|
||||||
|
callCodecontext(
|
||||||
|
{
|
||||||
|
toolName: 'get_file_analysis',
|
||||||
|
args: { file_path: 'does_not_exist.ts' },
|
||||||
|
projectPath: projectDir,
|
||||||
|
},
|
||||||
|
fetcher as unknown as typeof fetch,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/File not found in graph/);
|
||||||
|
// Wire was still called — resolver forwarded the path
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||||
|
const body = JSON.parse(fetcher.mock.calls[0]![1]!.body as string);
|
||||||
|
// Should receive the absolute (non-realpathed) path
|
||||||
|
expect(body.file_path).toBe(missingPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Case 6: empty string → skipped by guard, reaches wire unmodified
|
||||||
|
// Note: Zod .trim().min(1) in get_file_analysis rejects empty before the
|
||||||
|
// shim is reached in production. At the shim layer, the guard
|
||||||
|
// `file_path.trim() !== ''` skips the resolver for empty strings so that
|
||||||
|
// optional-file_path wrappers treat '' as "not provided". This is a
|
||||||
|
// deliberate design; callers that require file_path validate at the Zod layer.
|
||||||
|
it('skips resolver for empty string file_path (treated as not provided)', async () => {
|
||||||
|
const fetcher = vi.fn().mockResolvedValue(
|
||||||
|
mockJSONResponse({ result: 'analysis', error: null }),
|
||||||
|
);
|
||||||
|
// Should succeed — empty string is treated as "no file_path"
|
||||||
|
await callCodecontext(
|
||||||
|
{
|
||||||
|
toolName: 'get_file_analysis',
|
||||||
|
args: { file_path: '' },
|
||||||
|
projectPath: projectDir,
|
||||||
|
},
|
||||||
|
fetcher as unknown as typeof fetch,
|
||||||
|
);
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||||
|
const body = JSON.parse(fetcher.mock.calls[0]![1]!.body as string);
|
||||||
|
// Empty string passes through unchanged (resolver not invoked)
|
||||||
|
expect(body.file_path).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Case 7: wrapper without file_path (e.g. get_codebase_overview) → resolver not invoked
|
||||||
|
it('does not invoke file_path resolver when file_path is absent from args', async () => {
|
||||||
|
const fetcher = vi.fn().mockResolvedValue(
|
||||||
|
mockJSONResponse({ result: 'overview', error: null }),
|
||||||
|
);
|
||||||
|
await callCodecontext(
|
||||||
|
{
|
||||||
|
toolName: 'get_codebase_overview',
|
||||||
|
args: { include_stats: true },
|
||||||
|
projectPath: projectDir,
|
||||||
|
},
|
||||||
|
fetcher as unknown as typeof fetch,
|
||||||
|
);
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||||
|
const body = JSON.parse(fetcher.mock.calls[0]![1]!.body as string);
|
||||||
|
// No file_path in the wire body
|
||||||
|
expect('file_path' in body).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Case 8: absolute path with `..` that resolves outside project root, even
|
||||||
|
// when the literal path is ENOENT. Without resolve() in the absolute branch
|
||||||
|
// the prefix check false-positives because the raw `<projectDir>/../etc/x`
|
||||||
|
// literal starts with `<projectDir>/`.
|
||||||
|
it('rejects absolute file_path with `..` resolving outside project root (ENOENT branch)', async () => {
|
||||||
|
const fetcher = vi.fn();
|
||||||
|
const escapingAbsolute = `${projectDir}/../etc/non_existent_passwd`;
|
||||||
|
await expect(
|
||||||
|
callCodecontext(
|
||||||
|
{
|
||||||
|
toolName: 'get_file_analysis',
|
||||||
|
args: { file_path: escapingAbsolute },
|
||||||
|
projectPath: projectDir,
|
||||||
|
},
|
||||||
|
fetcher as unknown as typeof fetch,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/escapes project root/);
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Case 9: in-project symlink targeting outside the project root. This is the
|
||||||
|
// canonical realpath defense — realpath must canonicalise the symlink and
|
||||||
|
// the escape check must reject. Without this test, a symlink-out hole could
|
||||||
|
// regress silently.
|
||||||
|
it('rejects file_path that resolves through a symlink leaving project root', async () => {
|
||||||
|
const outsideDir = await mkdtemp(join(tmpdir(), 'codecontext-outside-'));
|
||||||
|
try {
|
||||||
|
const evilTarget = join(outsideDir, 'secrets.txt');
|
||||||
|
await writeFile(evilTarget, 'top secret');
|
||||||
|
await symlink(evilTarget, join(projectDir, 'evil-link'));
|
||||||
|
const fetcher = vi.fn();
|
||||||
|
await expect(
|
||||||
|
callCodecontext(
|
||||||
|
{
|
||||||
|
toolName: 'get_file_analysis',
|
||||||
|
args: { file_path: 'evil-link' },
|
||||||
|
projectPath: projectDir,
|
||||||
|
},
|
||||||
|
fetcher as unknown as typeof fetch,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/escapes project root/);
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
await rm(outsideDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ describe('codecontext wrappers — toolName + args forwarding', () => {
|
|||||||
const { url, body } = parsePOST(fetcher);
|
const { url, body } = parsePOST(fetcher);
|
||||||
expect(url).toMatch(/\/v1\/get_file_analysis$/);
|
expect(url).toMatch(/\/v1\/get_file_analysis$/);
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
file_path: 'apps/server/src/index.ts',
|
file_path: join(projectDir, 'apps/server/src/index.ts'),
|
||||||
target_dir: projectDir,
|
target_dir: projectDir,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
199
apps/server/src/services/__tests__/grant_resolver.test.ts
Normal file
199
apps/server/src/services/__tests__/grant_resolver.test.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
// v1.13.17-cross-repo-reads: resolveGrantRoot decision tree.
|
||||||
|
//
|
||||||
|
// Sam's dispatch note (2026-05-22): "in the project-root resolver ancestor
|
||||||
|
// walk, stop the moment parent exits PROJECT_ROOT_WHITELIST or hits
|
||||||
|
// filesystem root — check on every iteration, not just final parent.
|
||||||
|
// Symlinked input must not be able to escape the whitelist during the
|
||||||
|
// walk." The symlink-escape-mid-walk test below pins that invariant —
|
||||||
|
// without the per-iteration whitelist check, this case would walk OUTSIDE
|
||||||
|
// the whitelist root and return a phantom grant.
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||||
|
import { mkdtemp, rm, mkdir, writeFile, symlink } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { realpath } from 'node:fs/promises';
|
||||||
|
import { resolveGrantRoot } from '../grant_resolver.js';
|
||||||
|
import type { Sql } from '../../db.js';
|
||||||
|
|
||||||
|
let tmp: string;
|
||||||
|
let whitelist: string;
|
||||||
|
let project: string;
|
||||||
|
let fork: string;
|
||||||
|
let outside: string;
|
||||||
|
|
||||||
|
// Fake sql tag — returns the projects rows we want without touching a real
|
||||||
|
// database. The resolver only ever does a single SELECT, so a single-shot
|
||||||
|
// mock that returns the prepared rows on every invocation is enough.
|
||||||
|
function makeSql(rows: Array<{ path: string }>): Sql {
|
||||||
|
const tag = ((..._args: unknown[]) => Promise.resolve(rows)) as unknown as Sql;
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-gr-')));
|
||||||
|
whitelist = join(tmp, 'whitelist');
|
||||||
|
project = join(whitelist, 'boocode');
|
||||||
|
fork = join(whitelist, 'forks', 'codecontext');
|
||||||
|
outside = join(tmp, 'outside');
|
||||||
|
await mkdir(project, { recursive: true });
|
||||||
|
await mkdir(fork, { recursive: true });
|
||||||
|
await mkdir(outside, { recursive: true });
|
||||||
|
// Mark project as a repo (.git directory).
|
||||||
|
await mkdir(join(project, '.git'));
|
||||||
|
await writeFile(join(project, 'README.md'), 'project readme');
|
||||||
|
// Mark fork as a repo via go.mod (matches the proposal's example).
|
||||||
|
await writeFile(join(fork, 'go.mod'), 'module example.com/foo');
|
||||||
|
await writeFile(join(fork, 'main.go'), 'package main');
|
||||||
|
await writeFile(join(outside, 'secret.txt'), 'forbidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await rm(tmp, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveGrantRoot — happy paths', () => {
|
||||||
|
it('refuses when the requested path is already under projectRoot', async () => {
|
||||||
|
const result = await resolveGrantRoot(makeSql([]), join(project, 'README.md'), project, whitelist);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) expect(result.reason).toMatch(/already accessible/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the project root when the path falls under a registered project', async () => {
|
||||||
|
// Register `fork` as a known project. Resolver should return the project
|
||||||
|
// ancestor (LONGEST match wins) rather than the repo-shape fallback.
|
||||||
|
const result = await resolveGrantRoot(
|
||||||
|
makeSql([{ path: fork }]),
|
||||||
|
join(fork, 'main.go'),
|
||||||
|
project,
|
||||||
|
whitelist,
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.root).toBe(fork);
|
||||||
|
expect(result.source).toBe('project');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the nearest repo-shaped ancestor when no project matches', async () => {
|
||||||
|
const result = await resolveGrantRoot(
|
||||||
|
makeSql([]),
|
||||||
|
join(fork, 'main.go'),
|
||||||
|
project,
|
||||||
|
whitelist,
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.root).toBe(fork);
|
||||||
|
expect(result.source).toBe('whitelist');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveGrantRoot — refusals', () => {
|
||||||
|
it('refuses paths outside PROJECT_ROOT_WHITELIST', async () => {
|
||||||
|
const result = await resolveGrantRoot(
|
||||||
|
makeSql([]),
|
||||||
|
join(outside, 'secret.txt'),
|
||||||
|
project,
|
||||||
|
whitelist,
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) expect(result.reason).toMatch(/outside permitted scope/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses non-absolute paths', async () => {
|
||||||
|
const result = await resolveGrantRoot(makeSql([]), 'relative/path', project, whitelist);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) expect(result.reason).toMatch(/absolute/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses missing paths without prompting', async () => {
|
||||||
|
const result = await resolveGrantRoot(
|
||||||
|
makeSql([]),
|
||||||
|
join(whitelist, 'nope'),
|
||||||
|
project,
|
||||||
|
whitelist,
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) expect(result.reason).toMatch(/does not exist/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses when no repo-shape marker is found before hitting the whitelist root', async () => {
|
||||||
|
// Build a directory tree under the whitelist that has NO repo markers
|
||||||
|
// all the way up to the whitelist root.
|
||||||
|
const plain = join(whitelist, 'plain-dir', 'nested');
|
||||||
|
await mkdir(plain, { recursive: true });
|
||||||
|
await writeFile(join(plain, 'just-a-file.txt'), 'x');
|
||||||
|
const result = await resolveGrantRoot(
|
||||||
|
makeSql([]),
|
||||||
|
join(plain, 'just-a-file.txt'),
|
||||||
|
project,
|
||||||
|
whitelist,
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) expect(result.reason).toMatch(/no repo-shaped ancestor/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not grant the whitelist root itself as a fallback', async () => {
|
||||||
|
// Even if .git existed at the whitelist root (it doesn't), we'd refuse.
|
||||||
|
// Easier to assert: a path directly under whitelist with no repo marker.
|
||||||
|
const direct = join(whitelist, 'lone-file.txt');
|
||||||
|
await writeFile(direct, 'x');
|
||||||
|
const result = await resolveGrantRoot(makeSql([]), direct, project, whitelist);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveGrantRoot — symlink-escape-mid-walk guard (Sam 2026-05-22)', () => {
|
||||||
|
it('refuses a symlinked input whose realpath sits outside the whitelist', async () => {
|
||||||
|
// The symlink lives nominally inside the whitelist, but its target
|
||||||
|
// (realpath) is outside. The guard's first realpath() call normalizes
|
||||||
|
// and the up-front whitelist check refuses immediately.
|
||||||
|
const link = join(whitelist, 'escape-link');
|
||||||
|
try {
|
||||||
|
await symlink(outside, link);
|
||||||
|
const result = await resolveGrantRoot(
|
||||||
|
makeSql([]),
|
||||||
|
join(link, 'secret.txt'),
|
||||||
|
project,
|
||||||
|
whitelist,
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) expect(result.reason).toMatch(/outside permitted scope/);
|
||||||
|
} finally {
|
||||||
|
await rm(link, { force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('walk loop terminates at the whitelist root, not at filesystem /', async () => {
|
||||||
|
// Construct a deep tree with NO repo markers anywhere. Without a bound,
|
||||||
|
// the walk would chase parents up to "/". The bound flips the loop into
|
||||||
|
// a refusal once the cursor equals the realpath'd whitelist root.
|
||||||
|
const deep = join(whitelist, 'a', 'b', 'c', 'd');
|
||||||
|
await mkdir(deep, { recursive: true });
|
||||||
|
await writeFile(join(deep, 'leaf.txt'), 'x');
|
||||||
|
const result = await resolveGrantRoot(makeSql([]), join(deep, 'leaf.txt'), project, whitelist);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) expect(result.reason).toMatch(/no repo-shaped ancestor/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveGrantRoot — nearest-project disambiguation', () => {
|
||||||
|
it('prefers the longest matching project path over a shorter ancestor', async () => {
|
||||||
|
const outer = whitelist;
|
||||||
|
const inner = fork; // /whitelist/forks/codecontext, deeper than outer
|
||||||
|
const result = await resolveGrantRoot(
|
||||||
|
makeSql([{ path: outer }, { path: inner }]),
|
||||||
|
join(fork, 'main.go'),
|
||||||
|
project,
|
||||||
|
whitelist,
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) expect(result.root).toBe(inner);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Belt-and-suspenders: silence a known dynamic-import warning that vitest
|
||||||
|
// occasionally emits on transient fs operations in CI but never in dev.
|
||||||
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
169
apps/server/src/services/__tests__/mcp-client.test.ts
Normal file
169
apps/server/src/services/__tests__/mcp-client.test.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* v1.15.0-mcp-multi: unit tests for the multi-server MCP client.
|
||||||
|
* Pure unit tests — no live MCP server needed. Tests tool-wrapping,
|
||||||
|
* read-only guard, name prefixing, content extraction, and error handling.
|
||||||
|
* Multi-server routing tested via wrapMcpTool's server-name prefix.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { wrapMcpTool, extractContent, isToolReadOnly } from '../mcp-client.js';
|
||||||
|
|
||||||
|
describe('mcp-client', () => {
|
||||||
|
describe('wrapMcpTool — multi-server prefixing', () => {
|
||||||
|
it('produces a ToolDef with <serverName>_ prefix', () => {
|
||||||
|
const mcpTool = {
|
||||||
|
name: 'resolve-library-id',
|
||||||
|
description: 'Resolve a library identifier',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object' as const,
|
||||||
|
properties: { query: { type: 'string' } },
|
||||||
|
required: ['query'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapped = wrapMcpTool('context7', mcpTool);
|
||||||
|
|
||||||
|
expect(wrapped.name).toBe('context7_resolve-library-id');
|
||||||
|
expect(wrapped.description).toBe('Resolve a library identifier');
|
||||||
|
expect(wrapped.jsonSchema.type).toBe('function');
|
||||||
|
expect(wrapped.jsonSchema.function.name).toBe('context7_resolve-library-id');
|
||||||
|
expect(wrapped.jsonSchema.function.parameters).toEqual(mcpTool.inputSchema);
|
||||||
|
expect(typeof wrapped.execute).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefixes tools from different servers correctly', () => {
|
||||||
|
const toolA = {
|
||||||
|
name: 'query-docs',
|
||||||
|
description: 'Query docs',
|
||||||
|
inputSchema: { type: 'object' as const, properties: {} },
|
||||||
|
};
|
||||||
|
const toolB = {
|
||||||
|
name: 'overview',
|
||||||
|
description: 'Get overview',
|
||||||
|
inputSchema: { type: 'object' as const, properties: {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrappedA = wrapMcpTool('context7', toolA);
|
||||||
|
const wrappedB = wrapMcpTool('codecontext', toolB);
|
||||||
|
|
||||||
|
expect(wrappedA.name).toBe('context7_query-docs');
|
||||||
|
expect(wrappedB.name).toBe('codecontext_overview');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multi-server: two servers with 2 tools each produce 4 prefixed tools', () => {
|
||||||
|
const serverATools = [
|
||||||
|
{ name: 'query-docs', inputSchema: { type: 'object' as const, properties: {} } },
|
||||||
|
{ name: 'resolve-library-id', inputSchema: { type: 'object' as const, properties: {} } },
|
||||||
|
];
|
||||||
|
const serverBTools = [
|
||||||
|
{ name: 'overview', inputSchema: { type: 'object' as const, properties: {} } },
|
||||||
|
{ name: 'search', inputSchema: { type: 'object' as const, properties: {} } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const allWrapped = [
|
||||||
|
...serverATools.map((t) => wrapMcpTool('context7', t)),
|
||||||
|
...serverBTools.map((t) => wrapMcpTool('codecontext', t)),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(allWrapped).toHaveLength(4);
|
||||||
|
expect(allWrapped.map((t) => t.name)).toEqual([
|
||||||
|
'context7_query-docs',
|
||||||
|
'context7_resolve-library-id',
|
||||||
|
'codecontext_overview',
|
||||||
|
'codecontext_search',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults description to empty string when absent', () => {
|
||||||
|
const mcpTool = {
|
||||||
|
name: 'no-desc',
|
||||||
|
inputSchema: { type: 'object' as const, properties: {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapped = wrapMcpTool('myserver', mcpTool);
|
||||||
|
|
||||||
|
expect(wrapped.description).toBe('');
|
||||||
|
expect(wrapped.jsonSchema.function.description).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses passthrough Zod schema (z.record)', () => {
|
||||||
|
const mcpTool = {
|
||||||
|
name: 'test',
|
||||||
|
inputSchema: { type: 'object' as const, properties: {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapped = wrapMcpTool('s', mcpTool);
|
||||||
|
|
||||||
|
const result = wrapped.inputSchema.safeParse({ foo: 'bar', baz: 123 });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isToolReadOnly', () => {
|
||||||
|
it('accepts tools with readOnlyHint: true', () => {
|
||||||
|
expect(isToolReadOnly({ readOnlyHint: true })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts tools with no annotations', () => {
|
||||||
|
expect(isToolReadOnly(undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts tools with empty annotations', () => {
|
||||||
|
expect(isToolReadOnly({})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects tools with readOnlyHint: false', () => {
|
||||||
|
expect(isToolReadOnly({ readOnlyHint: false })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts tools with only destructiveHint set', () => {
|
||||||
|
expect(isToolReadOnly({ destructiveHint: true })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractContent', () => {
|
||||||
|
it('extracts single text block', () => {
|
||||||
|
const content = [{ type: 'text', text: 'hello world' }];
|
||||||
|
expect(extractContent(content)).toBe('hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('joins multiple text blocks with newline', () => {
|
||||||
|
const content = [
|
||||||
|
{ type: 'text', text: 'line 1' },
|
||||||
|
{ type: 'text', text: 'line 2' },
|
||||||
|
];
|
||||||
|
expect(extractContent(content)).toBe('line 1\nline 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "(no output)" for empty content', () => {
|
||||||
|
expect(extractContent([])).toBe('(no output)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "(no output)" for undefined content', () => {
|
||||||
|
expect(extractContent(undefined)).toBe('(no output)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes non-text blocks as JSON', () => {
|
||||||
|
const content = [
|
||||||
|
{ type: 'resource', uri: 'file:///foo', mimeType: 'text/plain' },
|
||||||
|
];
|
||||||
|
const result = extractContent(content);
|
||||||
|
expect(result).toContain('"type":"resource"');
|
||||||
|
expect(result).toContain('"uri":"file:///foo"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error shape when isError is true', () => {
|
||||||
|
const content = [{ type: 'text', text: 'something failed' }];
|
||||||
|
const result = extractContent(content, true);
|
||||||
|
expect(result).toEqual({ error: true, output: 'something failed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error shape with joined content on isError', () => {
|
||||||
|
const content = [
|
||||||
|
{ type: 'text', text: 'error 1' },
|
||||||
|
{ type: 'text', text: 'error 2' },
|
||||||
|
];
|
||||||
|
const result = extractContent(content, true);
|
||||||
|
expect(result).toEqual({ error: true, output: 'error 1\nerror 2' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
82
apps/server/src/services/__tests__/mcp-glob.test.ts
Normal file
82
apps/server/src/services/__tests__/mcp-glob.test.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* v1.15.0-mcp-multi: unit tests for matchToolGlob.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { matchToolGlob } from '../agents.js';
|
||||||
|
|
||||||
|
describe('matchToolGlob', () => {
|
||||||
|
it('exact match: "grep" matches "grep"', () => {
|
||||||
|
expect(matchToolGlob('grep', ['grep'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exact match: "grep" does not match "grep2"', () => {
|
||||||
|
expect(matchToolGlob('grep2', ['grep'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exact match: multiple tools', () => {
|
||||||
|
expect(matchToolGlob('grep', ['grep', 'view_file'])).toBe(true);
|
||||||
|
expect(matchToolGlob('view_file', ['grep', 'view_file'])).toBe(true);
|
||||||
|
expect(matchToolGlob('find_files', ['grep', 'view_file'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wildcard: "context7_*" matches "context7_query-docs"', () => {
|
||||||
|
expect(matchToolGlob('context7_query-docs', ['context7_*'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wildcard: "context7_*" matches "context7_resolve-library-id"', () => {
|
||||||
|
expect(matchToolGlob('context7_resolve-library-id', ['context7_*'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wildcard: "context7_*" does not match "codecontext_overview"', () => {
|
||||||
|
expect(matchToolGlob('codecontext_overview', ['context7_*'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wildcard: "view_*" matches "view_file" and "view_truncated_output"', () => {
|
||||||
|
expect(matchToolGlob('view_file', ['view_*'])).toBe(true);
|
||||||
|
expect(matchToolGlob('view_truncated_output', ['view_*'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wildcard: "*" matches everything', () => {
|
||||||
|
expect(matchToolGlob('anything', ['*'])).toBe(true);
|
||||||
|
expect(matchToolGlob('context7_query-docs', ['*'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deny: "!web_*" excludes "web_search"', () => {
|
||||||
|
// With only a deny rule and no prior match, the tool is not matched
|
||||||
|
expect(matchToolGlob('web_search', ['!web_*'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('last-match-wins: ["*", "!web_*"] excludes web tools, includes others', () => {
|
||||||
|
expect(matchToolGlob('web_search', ['*', '!web_*'])).toBe(false);
|
||||||
|
expect(matchToolGlob('web_fetch', ['*', '!web_*'])).toBe(false);
|
||||||
|
expect(matchToolGlob('grep', ['*', '!web_*'])).toBe(true);
|
||||||
|
expect(matchToolGlob('context7_query-docs', ['*', '!web_*'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('last-match-wins: deny then re-allow', () => {
|
||||||
|
// ["!web_*", "web_search"] — deny all web, then re-allow web_search
|
||||||
|
expect(matchToolGlob('web_search', ['!web_*', 'web_search'])).toBe(true);
|
||||||
|
expect(matchToolGlob('web_fetch', ['!web_*', 'web_fetch'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty patterns: nothing matches', () => {
|
||||||
|
expect(matchToolGlob('grep', [])).toBe(false);
|
||||||
|
expect(matchToolGlob('anything', [])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no-glob fallback: exact-match only, same as pre-v1.15', () => {
|
||||||
|
const patterns = ['grep', 'view_file'];
|
||||||
|
expect(matchToolGlob('grep', patterns)).toBe(true);
|
||||||
|
expect(matchToolGlob('view_file', patterns)).toBe(true);
|
||||||
|
expect(matchToolGlob('find_files', patterns)).toBe(false);
|
||||||
|
expect(matchToolGlob('web_search', patterns)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixed glob and exact patterns', () => {
|
||||||
|
const patterns = ['grep', 'context7_*', '!context7_dangerous'];
|
||||||
|
expect(matchToolGlob('grep', patterns)).toBe(true);
|
||||||
|
expect(matchToolGlob('context7_query-docs', patterns)).toBe(true);
|
||||||
|
expect(matchToolGlob('context7_dangerous', patterns)).toBe(false);
|
||||||
|
expect(matchToolGlob('view_file', patterns)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
93
apps/server/src/services/__tests__/path_guard.test.ts
Normal file
93
apps/server/src/services/__tests__/path_guard.test.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
// v1.13.17-cross-repo-reads: pathGuard now accepts an optional extraRoots
|
||||||
|
// list. Validates the primary-root path stays the source of truth and that
|
||||||
|
// extra roots are consulted when (and only when) the primary rejects.
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { mkdtemp, rm, mkdir, writeFile, symlink } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { realpath } from 'node:fs/promises';
|
||||||
|
import { pathGuard, PathScopeError } from '../path_guard.js';
|
||||||
|
|
||||||
|
let tmp: string;
|
||||||
|
let projectRoot: string;
|
||||||
|
let altRoot: string;
|
||||||
|
let outsideDir: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-pg-')));
|
||||||
|
projectRoot = join(tmp, 'project');
|
||||||
|
altRoot = join(tmp, 'alt');
|
||||||
|
outsideDir = join(tmp, 'outside');
|
||||||
|
await mkdir(projectRoot, { recursive: true });
|
||||||
|
await mkdir(altRoot, { recursive: true });
|
||||||
|
await mkdir(outsideDir, { recursive: true });
|
||||||
|
await writeFile(join(projectRoot, 'inside.txt'), 'p');
|
||||||
|
await writeFile(join(altRoot, 'cross.txt'), 'a');
|
||||||
|
await writeFile(join(outsideDir, 'forbidden.txt'), 'x');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await rm(tmp, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pathGuard (v1.13.17 extraRoots)', () => {
|
||||||
|
it('accepts paths inside the primary projectRoot', async () => {
|
||||||
|
const real = await pathGuard(projectRoot, 'inside.txt');
|
||||||
|
expect(real).toBe(join(projectRoot, 'inside.txt'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects paths outside the primary root when no extra roots given', async () => {
|
||||||
|
await expect(pathGuard(projectRoot, join(outsideDir, 'forbidden.txt'))).rejects.toBeInstanceOf(
|
||||||
|
PathScopeError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts cross-root paths when the matching extra root is provided', async () => {
|
||||||
|
const real = await pathGuard(projectRoot, join(altRoot, 'cross.txt'), [altRoot]);
|
||||||
|
expect(real).toBe(join(altRoot, 'cross.txt'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects cross-root paths even with extra roots when no root matches', async () => {
|
||||||
|
await expect(
|
||||||
|
pathGuard(projectRoot, join(outsideDir, 'forbidden.txt'), [altRoot]),
|
||||||
|
).rejects.toBeInstanceOf(PathScopeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores empty-string extra roots silently', async () => {
|
||||||
|
const real = await pathGuard(projectRoot, join(altRoot, 'cross.txt'), ['', altRoot]);
|
||||||
|
expect(real).toBe(join(altRoot, 'cross.txt'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('error message contains the request_read_access hint when scope rejects', async () => {
|
||||||
|
try {
|
||||||
|
await pathGuard(projectRoot, join(outsideDir, 'forbidden.txt'));
|
||||||
|
throw new Error('should have thrown');
|
||||||
|
} catch (err) {
|
||||||
|
expect(err).toBeInstanceOf(PathScopeError);
|
||||||
|
expect((err as Error).message).toContain('request_read_access');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still resolves symlinks before the scope check', async () => {
|
||||||
|
const linkPath = join(projectRoot, 'link-to-outside');
|
||||||
|
await symlink(join(outsideDir, 'forbidden.txt'), linkPath);
|
||||||
|
// Symlink target escapes both primary and the single extra root, so
|
||||||
|
// even though the surface path "looks" inside projectRoot, the real
|
||||||
|
// path resolves outside and the guard rejects.
|
||||||
|
await expect(pathGuard(projectRoot, linkPath, [altRoot])).rejects.toBeInstanceOf(
|
||||||
|
PathScopeError,
|
||||||
|
);
|
||||||
|
// But adding outsideDir as an extra root accepts (realpath inside it).
|
||||||
|
const real = await pathGuard(projectRoot, linkPath, [altRoot, outsideDir]);
|
||||||
|
expect(real).toBe(join(outsideDir, 'forbidden.txt'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tries extra roots in order until one accepts', async () => {
|
||||||
|
const real = await pathGuard(projectRoot, join(altRoot, 'cross.txt'), [
|
||||||
|
outsideDir, // rejects
|
||||||
|
altRoot, // accepts
|
||||||
|
]);
|
||||||
|
expect(real).toBe(join(altRoot, 'cross.txt'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -78,16 +78,18 @@ describeFn('tool_cost_stats view (v1.13.10)', () => {
|
|||||||
args: {},
|
args: {},
|
||||||
}));
|
}));
|
||||||
const created = opts.createdAt ?? new Date();
|
const created = opts.createdAt ?? new Date();
|
||||||
|
// v1.13.20: parts-only. messages.tool_calls column was dropped; the
|
||||||
|
// tool_cost_stats view reads through messages_with_parts which derives
|
||||||
|
// tool_calls from message_parts rows.
|
||||||
const rows = await sql<{ id: string }[]>`
|
const rows = await sql<{ id: string }[]>`
|
||||||
INSERT INTO messages (
|
INSERT INTO messages (
|
||||||
session_id, chat_id, role, content, kind, status,
|
session_id, chat_id, role, content, kind, status,
|
||||||
tool_calls, tokens_used, ctx_used,
|
tokens_used, ctx_used,
|
||||||
metadata, created_at
|
metadata, created_at
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
${sessionId}, ${chatId}, 'assistant', '', 'message',
|
${sessionId}, ${chatId}, 'assistant', '', 'message',
|
||||||
${opts.status ?? 'complete'},
|
${opts.status ?? 'complete'},
|
||||||
${sql.json(toolCalls as never)},
|
|
||||||
${opts.tokensUsed},
|
${opts.tokensUsed},
|
||||||
${opts.ctxUsed},
|
${opts.ctxUsed},
|
||||||
${opts.metadata ? sql.json(opts.metadata as never) : null},
|
${opts.metadata ? sql.json(opts.metadata as never) : null},
|
||||||
@@ -95,7 +97,14 @@ describeFn('tool_cost_stats view (v1.13.10)', () => {
|
|||||||
)
|
)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
return rows[0]!.id;
|
const messageId = rows[0]!.id;
|
||||||
|
for (let i = 0; i < toolCalls.length; i++) {
|
||||||
|
await sql`
|
||||||
|
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||||
|
VALUES (${messageId}, ${i}, 'tool_call', ${sql.json(toolCalls[i] as never)})
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
return messageId;
|
||||||
}
|
}
|
||||||
|
|
||||||
it('returns empty when no tool calls exist for a tool name', async () => {
|
it('returns empty when no tool calls exist for a tool name', async () => {
|
||||||
@@ -197,18 +206,17 @@ describeFn('tool_cost_stats view (v1.13.10)', () => {
|
|||||||
|
|
||||||
it('reads tool_calls via messages_with_parts (parts-authoritative)', async () => {
|
it('reads tool_calls via messages_with_parts (parts-authoritative)', async () => {
|
||||||
const t = tname('parts');
|
const t = tname('parts');
|
||||||
// Insert an assistant row with messages.tool_calls=NULL but a
|
// v1.13.20: post-column-drop the only source for tool_calls is
|
||||||
// message_parts row carrying the tool_call. The view reads via
|
// message_parts. This test asserts the same path the view always took
|
||||||
// messages_with_parts, which COALESCEs the parts table over the legacy
|
// (parts-derived), now that the legacy column COALESCE fallback is gone.
|
||||||
// column — so this row should still aggregate.
|
|
||||||
const rows = await sql<{ id: string }[]>`
|
const rows = await sql<{ id: string }[]>`
|
||||||
INSERT INTO messages (
|
INSERT INTO messages (
|
||||||
session_id, chat_id, role, content, kind, status,
|
session_id, chat_id, role, content, kind, status,
|
||||||
tool_calls, tokens_used, ctx_used
|
tokens_used, ctx_used
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
${sessionId}, ${chatId}, 'assistant', '', 'message', 'complete',
|
${sessionId}, ${chatId}, 'assistant', '', 'message', 'complete',
|
||||||
NULL, 200, 5000
|
200, 5000
|
||||||
)
|
)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -16,10 +16,62 @@ const CACHE_TTL_MS = 60_000;
|
|||||||
// hand-maintained list drifted (web_search/web_fetch from v1.11.8 + the 8
|
// hand-maintained list drifted (web_search/web_fetch from v1.11.8 + the 8
|
||||||
// codecontext tools were missing), silently filtering valid tool names out
|
// codecontext tools were missing), silently filtering valid tool names out
|
||||||
// of agents that opted in. Single source of truth is tools.ts now.
|
// of agents that opted in. Single source of truth is tools.ts now.
|
||||||
const ALL_TOOL_NAMES: readonly string[] = ALL_TOOLS.map((t) => t.name);
|
let ALL_TOOL_NAMES: readonly string[] = ALL_TOOLS.map((t) => t.name);
|
||||||
const DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES];
|
let DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES];
|
||||||
|
|
||||||
|
export function refreshToolNames(): void {
|
||||||
|
ALL_TOOL_NAMES = ALL_TOOLS.map((t) => t.name);
|
||||||
|
DEFAULT_TOOLS = [...ALL_TOOL_NAMES];
|
||||||
|
}
|
||||||
const DEFAULT_TEMPERATURE = 0.7;
|
const DEFAULT_TEMPERATURE = 0.7;
|
||||||
|
|
||||||
|
// ---- Tool glob matching (v1.15.0-mcp-multi) --------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple glob match for tool names. Supports `*` as a wildcard for any
|
||||||
|
* characters. No `?` or `**` — tool names are flat (no path separators).
|
||||||
|
*/
|
||||||
|
function simpleGlobMatch(str: string, pattern: string): boolean {
|
||||||
|
if (pattern === '*') return true;
|
||||||
|
if (!pattern.includes('*')) return str === pattern;
|
||||||
|
// Escape regex metacharacters, then replace escaped \* with .*
|
||||||
|
const regex = new RegExp(
|
||||||
|
'^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$',
|
||||||
|
);
|
||||||
|
return regex.test(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a tool name matches a set of glob patterns. Last-match-wins.
|
||||||
|
* Patterns starting with `!` are deny rules.
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* - `["grep", "view_file"]` — exact-match whitelist (same as pre-v1.15)
|
||||||
|
* - `["context7_*"]` — all tools from the context7 MCP server
|
||||||
|
* - `["*", "!web_*"]` — all tools except web tools
|
||||||
|
* - `[]` — nothing matches (agent gets no tools)
|
||||||
|
*/
|
||||||
|
export function matchToolGlob(toolName: string, patterns: string[]): boolean {
|
||||||
|
let matched = false;
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const deny = pattern.startsWith('!');
|
||||||
|
const glob = deny ? pattern.slice(1) : pattern;
|
||||||
|
if (simpleGlobMatch(toolName, glob)) {
|
||||||
|
matched = !deny;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if a tools: entry is a glob pattern (contains * or starts
|
||||||
|
* with !). Glob patterns can't be validated against the current tool list
|
||||||
|
* since MCP tools are discovered at runtime.
|
||||||
|
*/
|
||||||
|
function isGlobPattern(entry: string): boolean {
|
||||||
|
return entry.includes('*') || entry.startsWith('!');
|
||||||
|
}
|
||||||
|
|
||||||
export function slugify(name: string): string {
|
export function slugify(name: string): string {
|
||||||
return name
|
return name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -37,6 +89,10 @@ interface ParsedFrontmatter {
|
|||||||
// v1.8.2: optional per-agent tool-loop budget. Absent → inference resolves
|
// v1.8.2: optional per-agent tool-loop budget. Absent → inference resolves
|
||||||
// from the agent's toolset at runtime.
|
// from the agent's toolset at runtime.
|
||||||
max_tool_calls?: number;
|
max_tool_calls?: number;
|
||||||
|
// v1.14.0: optional per-agent step cap. Absent → bounded only by MAX_STEPS
|
||||||
|
// (200) in the outer loop. Integer ≥ 0; steps: 0 means "no tool calls
|
||||||
|
// allowed" — the model responds text-only.
|
||||||
|
steps?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripQuotes(s: string): string {
|
function stripQuotes(s: string): string {
|
||||||
@@ -112,6 +168,21 @@ function parseFrontmatter(yaml: string): { data: ParsedFrontmatter; errors: stri
|
|||||||
} else {
|
} else {
|
||||||
errors.push(`max_tool_calls must be an integer 1-100 (got "${valueRaw}")`);
|
errors.push(`max_tool_calls must be an integer 1-100 (got "${valueRaw}")`);
|
||||||
}
|
}
|
||||||
|
} else if (key === 'steps') {
|
||||||
|
// v1.14.0: per-agent step cap for the outer inference loop. Integer ≥ 0.
|
||||||
|
// steps: 0 means "no tool calls allowed" — model responds text-only.
|
||||||
|
// Non-integer or negative values are warned and ignored (falls back to
|
||||||
|
// MAX_STEPS ceiling), matching the max_tool_calls pattern above.
|
||||||
|
const n = Number(valueRaw);
|
||||||
|
if (Number.isInteger(n) && n >= 0) {
|
||||||
|
data.steps = n;
|
||||||
|
} else if (Number.isInteger(n)) {
|
||||||
|
console.warn(
|
||||||
|
`agents: steps ${n} is negative, ignoring (falling back to default)`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
errors.push(`steps must be a non-negative integer (got "${valueRaw}")`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Unknown keys silently ignored — forward-compat.
|
// Unknown keys silently ignored — forward-compat.
|
||||||
}
|
}
|
||||||
@@ -188,10 +259,14 @@ function parseAgentSection(section: RawSection): Omit<Agent, 'source'> {
|
|||||||
|
|
||||||
// v1.13.15-tools: intersect with BOOCODE_TOOLS tier (ceiling, not expansion).
|
// v1.13.15-tools: intersect with BOOCODE_TOOLS tier (ceiling, not expansion).
|
||||||
// Unset → resolveToolTier returns ALL tool names → no narrowing.
|
// Unset → resolveToolTier returns ALL tool names → no narrowing.
|
||||||
|
// v1.15.0-mcp-multi: glob patterns (entries containing * or starting with !)
|
||||||
|
// pass through unvalidated — MCP tools are discovered at runtime and can't
|
||||||
|
// be checked against ALL_TOOL_NAMES at parse time.
|
||||||
const tierAllowed = new Set(resolveToolTier(process.env.BOOCODE_TOOLS));
|
const tierAllowed = new Set(resolveToolTier(process.env.BOOCODE_TOOLS));
|
||||||
const filteredTools = Array.isArray(fm.tools)
|
const filteredTools = Array.isArray(fm.tools)
|
||||||
? fm.tools.filter((t): t is string =>
|
? fm.tools.filter((t): t is string =>
|
||||||
(ALL_TOOL_NAMES as readonly string[]).includes(t) && tierAllowed.has(t),
|
isGlobPattern(t) ||
|
||||||
|
((ALL_TOOL_NAMES as readonly string[]).includes(t) && tierAllowed.has(t)),
|
||||||
)
|
)
|
||||||
: DEFAULT_TOOLS.filter((t) => tierAllowed.has(t));
|
: DEFAULT_TOOLS.filter((t) => tierAllowed.has(t));
|
||||||
|
|
||||||
@@ -204,6 +279,7 @@ function parseAgentSection(section: RawSection): Omit<Agent, 'source'> {
|
|||||||
tools: filteredTools,
|
tools: filteredTools,
|
||||||
model: typeof fm.model === 'string' && fm.model.length > 0 ? fm.model : null,
|
model: typeof fm.model === 'string' && fm.model.length > 0 ? fm.model : null,
|
||||||
max_tool_calls: typeof fm.max_tool_calls === 'number' ? fm.max_tool_calls : null,
|
max_tool_calls: typeof fm.max_tool_calls === 'number' ? fm.max_tool_calls : null,
|
||||||
|
steps: typeof fm.steps === 'number' ? fm.steps : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
255
apps/server/src/services/artifacts.ts
Normal file
255
apps/server/src/services/artifacts.ts
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
// v1.14.x-html-artifact-panes: artifact writer + slug derivation.
|
||||||
|
//
|
||||||
|
// Writes Markdown and HTML artifacts to `<projectRoot>/.boocode/artifacts/`
|
||||||
|
// as plain files. Returns `{path, url}` where:
|
||||||
|
// - path is the absolute on-disk path
|
||||||
|
// - url is a project-scoped REST URL pointing at the GET download route
|
||||||
|
// registered in routes/artifacts.ts. The route streams the file with
|
||||||
|
// Content-Disposition: attachment.
|
||||||
|
//
|
||||||
|
// Path safety: we do NOT use path_guard.ts (it realpaths and throws ENOENT
|
||||||
|
// for files that don't exist yet, which artifact creation requires).
|
||||||
|
// Instead we mirror the v1.13.18 codecontext_client.ts pattern: resolve
|
||||||
|
// the candidate path against the realpath'd projectRoot, then verify the
|
||||||
|
// result starts with projectRoot + sep (or equals projectRoot).
|
||||||
|
|
||||||
|
import { mkdir, realpath, writeFile } from 'node:fs/promises';
|
||||||
|
import { resolve, sep } from 'node:path';
|
||||||
|
import { PathScopeError } from './path_guard.js';
|
||||||
|
import type { Message } from '../types/api.js';
|
||||||
|
|
||||||
|
export interface HtmlArtifactPayload {
|
||||||
|
html_content: string;
|
||||||
|
char_count: number;
|
||||||
|
title: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtifactWriteResult {
|
||||||
|
path: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ARTIFACT_SUBDIR = '.boocode/artifacts';
|
||||||
|
|
||||||
|
// ---- slug helpers ----
|
||||||
|
|
||||||
|
// Lowercase, replace non-alnum runs with '-', trim leading/trailing '-',
|
||||||
|
// collapse repeated '-', cap at 60 chars. Empty → 'artifact'.
|
||||||
|
function slugify(input: string): string {
|
||||||
|
const cleaned = input
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.replace(/-{2,}/g, '-')
|
||||||
|
.slice(0, 60)
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
return cleaned || 'artifact';
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstHeading(md: string): string | null {
|
||||||
|
// Match the first `# ` ATX heading at the start of a line.
|
||||||
|
const m = md.match(/^[ \t]*#[ \t]+(.+?)\s*$/m);
|
||||||
|
if (!m) return null;
|
||||||
|
const text = m[1]?.trim() ?? '';
|
||||||
|
return text.length > 0 ? text : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstNWords(s: string, n: number): string {
|
||||||
|
const words = s.trim().split(/\s+/).filter(Boolean).slice(0, n);
|
||||||
|
return words.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveMarkdownSlug(messageContent: string): string {
|
||||||
|
const heading = firstHeading(messageContent);
|
||||||
|
if (heading) return slugify(heading);
|
||||||
|
const sixWords = firstNWords(messageContent, 6);
|
||||||
|
return slugify(sixWords);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip HTML tags for inner-text extraction. Crude but sufficient for slug
|
||||||
|
// derivation — we're not rendering, just finding readable words.
|
||||||
|
function stripTags(html: string): string {
|
||||||
|
return html
|
||||||
|
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, ' ')
|
||||||
|
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, ' ')
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTitleTag(html: string): string | null {
|
||||||
|
const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
||||||
|
if (!m) return null;
|
||||||
|
const text = stripTags(m[1] ?? '').trim();
|
||||||
|
return text.length > 0 ? text : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractH1(html: string): string | null {
|
||||||
|
const m = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
|
||||||
|
if (!m) return null;
|
||||||
|
const text = stripTags(m[1] ?? '').trim();
|
||||||
|
return text.length > 0 ? text : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveHtmlSlug(payload: {
|
||||||
|
html_content: string;
|
||||||
|
title: string | null;
|
||||||
|
}): string {
|
||||||
|
if (payload.title && payload.title.trim().length > 0) {
|
||||||
|
return slugify(payload.title);
|
||||||
|
}
|
||||||
|
const title = extractTitleTag(payload.html_content);
|
||||||
|
if (title) return slugify(title);
|
||||||
|
const h1 = extractH1(payload.html_content);
|
||||||
|
if (h1) return slugify(h1);
|
||||||
|
const inner = stripTags(payload.html_content);
|
||||||
|
return slugify(firstNWords(inner, 6));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive title for the html_artifact part payload: <title> → first <h1> →
|
||||||
|
// first 80 chars of inner text. Returns null if nothing useful is found.
|
||||||
|
export function deriveHtmlTitle(html: string): string | null {
|
||||||
|
const t = extractTitleTag(html);
|
||||||
|
if (t) return t;
|
||||||
|
const h1 = extractH1(html);
|
||||||
|
if (h1) return h1;
|
||||||
|
const inner = stripTags(html);
|
||||||
|
if (inner.length === 0) return null;
|
||||||
|
return inner.slice(0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- HTML detection (B4) ----
|
||||||
|
|
||||||
|
// Returns the inner HTML content if `text` is a recognised HTML artifact:
|
||||||
|
// - starts with <!DOCTYPE html> (case-insensitive, whitespace-trimmed), OR
|
||||||
|
// - wrapped entirely in a fenced ```html ... ``` block.
|
||||||
|
// Returns null if neither matches.
|
||||||
|
export function detectHtmlArtifact(text: string): string | null {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed.length === 0) return null;
|
||||||
|
if (/^<!doctype\s+html/i.test(trimmed)) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
// Fenced ```html block consuming the entire (trimmed) message. Allow an
|
||||||
|
// optional trailing newline before the closing fence.
|
||||||
|
const fence = trimmed.match(/^```html\s*\n([\s\S]*?)\n?```\s*$/i);
|
||||||
|
if (fence) {
|
||||||
|
const inner = fence[1] ?? '';
|
||||||
|
if (/^\s*<!doctype\s+html/i.test(inner) || /<html[\s>]/i.test(inner)) {
|
||||||
|
return inner.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- path resolution ----
|
||||||
|
|
||||||
|
// Resolve `<projectRoot>/.boocode/artifacts/<filename>` and verify the
|
||||||
|
// result stays under projectRoot. Mirrors the v1.13.18 codecontext_client.ts
|
||||||
|
// approach: realpath projectRoot first, then prefix-check the candidate.
|
||||||
|
// Throws on escape.
|
||||||
|
async function resolveArtifactPath(
|
||||||
|
projectRoot: string,
|
||||||
|
filename: string,
|
||||||
|
): Promise<{ resolvedRoot: string; artifactsDir: string; absPath: string }> {
|
||||||
|
const resolvedRoot = await realpath(projectRoot);
|
||||||
|
const artifactsDir = resolve(resolvedRoot, ARTIFACT_SUBDIR);
|
||||||
|
const absPath = resolve(artifactsDir, filename);
|
||||||
|
// Lexical prefix check on the resolved candidates. (The `!== resolvedRoot`
|
||||||
|
// branch was dead — ARTIFACT_SUBDIR is non-empty so artifactsDir always
|
||||||
|
// differs from resolvedRoot.)
|
||||||
|
if (!artifactsDir.startsWith(resolvedRoot + sep)) {
|
||||||
|
throw new PathScopeError(
|
||||||
|
`artifacts dir escapes project root: ${artifactsDir}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!absPath.startsWith(artifactsDir + sep)) {
|
||||||
|
throw new PathScopeError(
|
||||||
|
`artifact filename escapes artifacts dir: ${filename}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { resolvedRoot, artifactsDir, absPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
// After mkdir, realpath the artifacts dir and re-verify it stays under
|
||||||
|
// resolvedRoot. Closes the symlink-escape gap: if `.boocode/artifacts` (or
|
||||||
|
// any ancestor below resolvedRoot) is a symlink pointing outside the
|
||||||
|
// project, the lexical check in resolveArtifactPath passes but the actual
|
||||||
|
// write lands outside the sandbox. Throws PathScopeError on escape.
|
||||||
|
async function assertArtifactsDirSafe(
|
||||||
|
artifactsDir: string,
|
||||||
|
resolvedRoot: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const realDir = await realpath(artifactsDir);
|
||||||
|
if (realDir !== resolvedRoot && !realDir.startsWith(resolvedRoot + sep)) {
|
||||||
|
throw new PathScopeError(
|
||||||
|
`artifacts dir resolves outside project root: ${realDir}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure decision helper for whether finalizeCompletion should write the
|
||||||
|
// `html_artifact` part. Exported for unit testing the cap-skip branch.
|
||||||
|
// Returns `{write: true, byteLen}` when the payload is under the cap, or
|
||||||
|
// `{write: false, byteLen, reason: 'cap_exceeded'}` when oversize.
|
||||||
|
export type HtmlArtifactDecision =
|
||||||
|
| { write: true; byteLen: number }
|
||||||
|
| { write: false; byteLen: number; reason: 'cap_exceeded' };
|
||||||
|
|
||||||
|
export function decideHtmlArtifactWrite(
|
||||||
|
htmlContent: string,
|
||||||
|
): HtmlArtifactDecision {
|
||||||
|
const byteLen = Buffer.byteLength(htmlContent, 'utf8');
|
||||||
|
if (byteLen > HTML_ARTIFACT_MAX_BYTES) {
|
||||||
|
return { write: false, byteLen, reason: 'cap_exceeded' };
|
||||||
|
}
|
||||||
|
return { write: true, byteLen };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrl(projectId: string, filename: string): string {
|
||||||
|
return `/api/projects/${projectId}/artifacts/${encodeURIComponent(filename)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WriteContext {
|
||||||
|
projectId: string;
|
||||||
|
projectRoot: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeMarkdownArtifact(
|
||||||
|
message: Pick<Message, 'content'>,
|
||||||
|
ctx: WriteContext,
|
||||||
|
): Promise<ArtifactWriteResult> {
|
||||||
|
const slug = deriveMarkdownSlug(message.content);
|
||||||
|
const filename = `${slug}-${Date.now()}.md`;
|
||||||
|
const { resolvedRoot, artifactsDir, absPath } = await resolveArtifactPath(
|
||||||
|
ctx.projectRoot,
|
||||||
|
filename,
|
||||||
|
);
|
||||||
|
await mkdir(artifactsDir, { recursive: true });
|
||||||
|
await assertArtifactsDirSafe(artifactsDir, resolvedRoot);
|
||||||
|
await writeFile(absPath, message.content, 'utf8');
|
||||||
|
return { path: absPath, url: buildUrl(ctx.projectId, filename) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeHtmlArtifact(
|
||||||
|
payload: HtmlArtifactPayload,
|
||||||
|
ctx: WriteContext,
|
||||||
|
): Promise<ArtifactWriteResult> {
|
||||||
|
const slug = deriveHtmlSlug(payload);
|
||||||
|
const filename = `${slug}-${Date.now()}.html`;
|
||||||
|
const { resolvedRoot, artifactsDir, absPath } = await resolveArtifactPath(
|
||||||
|
ctx.projectRoot,
|
||||||
|
filename,
|
||||||
|
);
|
||||||
|
await mkdir(artifactsDir, { recursive: true });
|
||||||
|
await assertArtifactsDirSafe(artifactsDir, resolvedRoot);
|
||||||
|
await writeFile(absPath, payload.html_content, 'utf8');
|
||||||
|
return { path: absPath, url: buildUrl(ctx.projectId, filename) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1MB cap on HTML artifacts (proposal S6). Larger payloads are not written
|
||||||
|
// to the `html_artifact` part — the assistant text lands as plain content
|
||||||
|
// and a warning is logged. Streaming abort was considered but the graceful
|
||||||
|
// "no artifact, plain text falls back" path is simpler and lossless from
|
||||||
|
// the user's perspective.
|
||||||
|
export const HTML_ARTIFACT_MAX_BYTES = 1_048_576;
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
// which we re-surface with a hint to add the file to .codecontextignore.
|
// which we re-surface with a hint to add the file to .codecontextignore.
|
||||||
|
|
||||||
import { access, copyFile, realpath } from 'node:fs/promises';
|
import { access, copyFile, realpath } from 'node:fs/promises';
|
||||||
import { join } from 'node:path';
|
import { isAbsolute, join, resolve, sep } from 'node:path';
|
||||||
import { truncateIfNeeded } from './truncate.js';
|
import { truncateIfNeeded } from './truncate.js';
|
||||||
|
|
||||||
// v1.13.12 fix: codecontext crashes on empty source files (upstream issue #37)
|
// v1.13.12 fix: codecontext crashes on empty source files (upstream issue #37)
|
||||||
@@ -51,6 +51,45 @@ async function ensureIgnoreFile(projectRoot: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.13.18: resolve a `file_path` arg to an absolute path anchored within
|
||||||
|
// the (already realpath'd) projectRoot. Contract:
|
||||||
|
// - empty/whitespace-only → INVALID_FILE_PATH error
|
||||||
|
// - relative path → resolve(projectRoot, rawPath) (normalises dot-segments)
|
||||||
|
// - absolute path → resolve(rawPath) (also normalises — e.g. /root/../etc
|
||||||
|
// becomes /etc so the prefix-check below rejects it even in the ENOENT
|
||||||
|
// fallthrough where realpath couldn't canonicalise)
|
||||||
|
// - try realpath; on ENOENT fall through with the (normalised) absolute
|
||||||
|
// (the sidecar issues its own "File not found in graph" that the model
|
||||||
|
// can self-correct on; re-implementing the check here would diverge)
|
||||||
|
// - if the final path doesn't sit inside projectRoot → escape error
|
||||||
|
// (same shape as target_dir escape, only the field name differs)
|
||||||
|
async function resolveProjectPath(
|
||||||
|
projectRoot: string,
|
||||||
|
rawPath: string,
|
||||||
|
): Promise<string> {
|
||||||
|
if (rawPath.trim() === '') {
|
||||||
|
throw new Error('INVALID_FILE_PATH: file_path must not be empty');
|
||||||
|
}
|
||||||
|
const candidate = isAbsolute(rawPath) ? resolve(rawPath) : resolve(projectRoot, rawPath);
|
||||||
|
let resolved: string;
|
||||||
|
try {
|
||||||
|
resolved = await realpath(candidate);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
// File doesn't exist yet (or was deleted). Forward the absolute path;
|
||||||
|
// codecontext will return "File not found in graph" which the model
|
||||||
|
// can self-correct on.
|
||||||
|
resolved = candidate;
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (resolved !== projectRoot && !resolved.startsWith(projectRoot + sep)) {
|
||||||
|
throw new Error(`file_path ${rawPath} escapes project root ${projectRoot}`);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CodecontextRequest {
|
export interface CodecontextRequest {
|
||||||
toolName: string;
|
toolName: string;
|
||||||
args: Record<string, unknown>;
|
args: Record<string, unknown>;
|
||||||
@@ -96,7 +135,14 @@ export async function callCodecontext(
|
|||||||
|
|
||||||
// Step 2: re-build args with the resolved target_dir so codecontext sees
|
// Step 2: re-build args with the resolved target_dir so codecontext sees
|
||||||
// the real absolute path, not a symlink or relative form.
|
// the real absolute path, not a symlink or relative form.
|
||||||
const argsToSend = { ...req.args, target_dir: resolvedTarget };
|
// v1.13.18: also resolve file_path when present — the sidecar index is keyed
|
||||||
|
// on absolute paths, so a relative path from the model yields "File not found
|
||||||
|
// in graph". Same escape check as target_dir; ENOENT falls through so the
|
||||||
|
// sidecar produces the canonical "File not found in graph" the model can fix.
|
||||||
|
const argsToSend: Record<string, unknown> = { ...req.args, target_dir: resolvedTarget };
|
||||||
|
if (typeof req.args['file_path'] === 'string' && req.args['file_path'].trim() !== '') {
|
||||||
|
argsToSend['file_path'] = await resolveProjectPath(resolvedProject, req.args['file_path']);
|
||||||
|
}
|
||||||
|
|
||||||
// Step 3: POST with a hard timeout. AbortController + setTimeout pattern
|
// Step 3: POST with a hard timeout. AbortController + setTimeout pattern
|
||||||
// matches web_fetch.ts; nothing fancier needed.
|
// matches web_fetch.ts; nothing fancier needed.
|
||||||
|
|||||||
@@ -47,8 +47,12 @@ export interface FindFilesResult {
|
|||||||
truncated: boolean;
|
truncated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listDir(projectRoot: string, relPath: string): Promise<ListDirResult> {
|
export async function listDir(
|
||||||
const real = await pathGuard(projectRoot, relPath);
|
projectRoot: string,
|
||||||
|
relPath: string,
|
||||||
|
opts?: { extra_roots?: readonly string[] },
|
||||||
|
): Promise<ListDirResult> {
|
||||||
|
const real = await pathGuard(projectRoot, relPath, opts?.extra_roots);
|
||||||
const s = await stat(real);
|
const s = await stat(real);
|
||||||
if (!s.isDirectory()) {
|
if (!s.isDirectory()) {
|
||||||
throw new PathScopeError(`not a directory: ${relPath}`);
|
throw new PathScopeError(`not a directory: ${relPath}`);
|
||||||
@@ -82,8 +86,12 @@ export async function listDir(projectRoot: string, relPath: string): Promise<Lis
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function viewFile(projectRoot: string, relPath: string): Promise<ViewFileResult> {
|
export async function viewFile(
|
||||||
const real = await pathGuard(projectRoot, relPath);
|
projectRoot: string,
|
||||||
|
relPath: string,
|
||||||
|
opts?: { extra_roots?: readonly string[] },
|
||||||
|
): Promise<ViewFileResult> {
|
||||||
|
const real = await pathGuard(projectRoot, relPath, opts?.extra_roots);
|
||||||
const s = await stat(real);
|
const s = await stat(real);
|
||||||
if (!s.isFile()) {
|
if (!s.isFile()) {
|
||||||
throw new PathScopeError(`not a file: ${relPath}`);
|
throw new PathScopeError(`not a file: ${relPath}`);
|
||||||
@@ -119,10 +127,10 @@ interface RipgrepMatch {
|
|||||||
export async function grep(
|
export async function grep(
|
||||||
projectRoot: string,
|
projectRoot: string,
|
||||||
pattern: string,
|
pattern: string,
|
||||||
opts?: { path?: string; max_matches?: number; case_sensitive?: boolean; hidden?: boolean }
|
opts?: { path?: string; max_matches?: number; case_sensitive?: boolean; hidden?: boolean; extra_roots?: readonly string[] }
|
||||||
): Promise<GrepResult> {
|
): Promise<GrepResult> {
|
||||||
const targetPath = opts?.path ?? projectRoot;
|
const targetPath = opts?.path ?? projectRoot;
|
||||||
const target = await pathGuard(projectRoot, targetPath);
|
const target = await pathGuard(projectRoot, targetPath, opts?.extra_roots);
|
||||||
const limit = Math.min(
|
const limit = Math.min(
|
||||||
Math.max(opts?.max_matches ?? DEFAULT_GREP_RESULTS, 1),
|
Math.max(opts?.max_matches ?? DEFAULT_GREP_RESULTS, 1),
|
||||||
MAX_GREP_RESULTS
|
MAX_GREP_RESULTS
|
||||||
@@ -192,14 +200,14 @@ export async function grep(
|
|||||||
export async function findFiles(
|
export async function findFiles(
|
||||||
projectRoot: string,
|
projectRoot: string,
|
||||||
pattern?: string,
|
pattern?: string,
|
||||||
opts?: { type?: 'file' | 'dir'; max_results?: number; path?: string }
|
opts?: { type?: 'file' | 'dir'; max_results?: number; path?: string; extra_roots?: readonly string[] }
|
||||||
): Promise<FindFilesResult> {
|
): Promise<FindFilesResult> {
|
||||||
const limit = Math.min(
|
const limit = Math.min(
|
||||||
Math.max(opts?.max_results ?? DEFAULT_FIND_RESULTS, 1),
|
Math.max(opts?.max_results ?? DEFAULT_FIND_RESULTS, 1),
|
||||||
MAX_FIND_RESULTS
|
MAX_FIND_RESULTS
|
||||||
);
|
);
|
||||||
const target = opts?.path != null
|
const target = opts?.path != null
|
||||||
? await pathGuard(projectRoot, opts.path)
|
? await pathGuard(projectRoot, opts.path, opts?.extra_roots)
|
||||||
: projectRoot;
|
: projectRoot;
|
||||||
const args = ['--files'];
|
const args = ['--files'];
|
||||||
if (pattern) args.push('--glob', pattern);
|
if (pattern) args.push('--glob', pattern);
|
||||||
|
|||||||
161
apps/server/src/services/grant_resolver.ts
Normal file
161
apps/server/src/services/grant_resolver.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
// v1.13.17-cross-repo-reads: derives the grant root for a path the user is
|
||||||
|
// being asked to approve cross-repo read access to.
|
||||||
|
//
|
||||||
|
// Per design decision D1: grant unit = nearest registered project root,
|
||||||
|
// then nearest path-whitelist ancestor that looks like a repo root, then
|
||||||
|
// refuse. Granting the literal file path is too narrow (next file in the
|
||||||
|
// same repo re-prompts). Granting an arbitrary parent dir over-scopes.
|
||||||
|
//
|
||||||
|
// The resolver runs in two contexts:
|
||||||
|
// 1. request_read_access.execute — pre-prompt validation (cheap; bails
|
||||||
|
// early if the path can't plausibly be granted so the user is never
|
||||||
|
// asked about /etc/passwd)
|
||||||
|
// 2. POST /api/chats/:id/grant_read_access — at decision time, re-derives
|
||||||
|
// the root and persists it on sessions.allowed_read_paths
|
||||||
|
//
|
||||||
|
// Sam (2026-05-22 dispatch confirmation): "in the project-root resolver
|
||||||
|
// ancestor walk, stop the moment parent exits PROJECT_ROOT_WHITELIST or hits
|
||||||
|
// filesystem root — check on every iteration, not just final parent.
|
||||||
|
// Symlinked input must not be able to escape the whitelist during the
|
||||||
|
// walk." Hence the loop here checks both the walk bound AND the still-
|
||||||
|
// inside-whitelist invariant every step.
|
||||||
|
|
||||||
|
import { access, realpath } from 'node:fs/promises';
|
||||||
|
import { constants } from 'node:fs';
|
||||||
|
import { dirname, isAbsolute, sep } from 'node:path';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
|
||||||
|
// Files whose presence in a directory marks it as a repo root for grant
|
||||||
|
// purposes. Kept narrow on purpose; broader heuristics (e.g. ".project",
|
||||||
|
// "pyproject.toml") can be added with measured intent. Each entry is a
|
||||||
|
// literal basename — no globs.
|
||||||
|
const REPO_MARKERS: ReadonlyArray<string> = [
|
||||||
|
'.git',
|
||||||
|
'package.json',
|
||||||
|
'go.mod',
|
||||||
|
'Cargo.toml',
|
||||||
|
];
|
||||||
|
|
||||||
|
export type GrantResolution =
|
||||||
|
| { ok: true; root: string; source: 'project' | 'whitelist' }
|
||||||
|
| { ok: false; reason: string };
|
||||||
|
|
||||||
|
function isUnder(child: string, parent: string): boolean {
|
||||||
|
return child === parent || child.startsWith(parent + sep);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exists(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await access(path, constants.F_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isRepoShaped(dir: string): Promise<boolean> {
|
||||||
|
for (const marker of REPO_MARKERS) {
|
||||||
|
if (await exists(`${dir}${sep}${marker}`)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolves an absolute path to its grant root or refuses with a reason
|
||||||
|
// string suitable for surfacing to the model. Pure helper — no DB writes,
|
||||||
|
// no broker publishes. Caller persists the root on session.allowed_read_paths
|
||||||
|
// if it wants the grant to stick.
|
||||||
|
//
|
||||||
|
// Arguments:
|
||||||
|
// sql — used only to read projects.path (no writes)
|
||||||
|
// requestedPath — absolute path the model wants to read
|
||||||
|
// projectRoot — the session's primary project root (already
|
||||||
|
// realpath'd by caller). Used to short-circuit
|
||||||
|
// "already in scope".
|
||||||
|
// whitelistRoot — PROJECT_ROOT_WHITELIST from config (default /opt).
|
||||||
|
// Walk bound for the repo-shape fallback.
|
||||||
|
//
|
||||||
|
// Returns { ok: true, root, source } on success; { ok: false, reason } else.
|
||||||
|
export async function resolveGrantRoot(
|
||||||
|
sql: Sql,
|
||||||
|
requestedPath: string,
|
||||||
|
projectRoot: string,
|
||||||
|
whitelistRoot: string,
|
||||||
|
): Promise<GrantResolution> {
|
||||||
|
if (typeof requestedPath !== 'string' || requestedPath.length === 0) {
|
||||||
|
return { ok: false, reason: 'path is required' };
|
||||||
|
}
|
||||||
|
if (!isAbsolute(requestedPath)) {
|
||||||
|
return { ok: false, reason: 'path must be absolute' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve symlinks so subsequent ancestor checks compare apples-to-apples
|
||||||
|
// with realpath'd projectRoot. If the path doesn't exist at all, bail
|
||||||
|
// before bothering the user — the model is asking about a phantom.
|
||||||
|
let real: string;
|
||||||
|
try {
|
||||||
|
real = await realpath(requestedPath);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, reason: `path does not exist: ${requestedPath}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whitelist guard. Symlinked inputs can resolve outside the whitelist
|
||||||
|
// even when the surface-form path looks inside it; that's why we test
|
||||||
|
// the *real* path here, not the requested one.
|
||||||
|
let realWhitelist: string;
|
||||||
|
try {
|
||||||
|
realWhitelist = await realpath(whitelistRoot);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, reason: `whitelist root does not exist: ${whitelistRoot}` };
|
||||||
|
}
|
||||||
|
if (!isUnder(real, realWhitelist)) {
|
||||||
|
return { ok: false, reason: 'path outside permitted scope' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already in scope? No prompt needed; the tool's caller should retry.
|
||||||
|
if (isUnder(real, projectRoot)) {
|
||||||
|
return { ok: false, reason: 'path already accessible without a grant' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for a registered project whose root is an ancestor of the
|
||||||
|
// requested path. Pick the LONGEST match (nearest ancestor wins) so
|
||||||
|
// sub-projects don't get over-broadened.
|
||||||
|
const projectRows = await sql<{ path: string }[]>`
|
||||||
|
SELECT path FROM projects WHERE status = 'open'
|
||||||
|
`;
|
||||||
|
let bestProject: string | null = null;
|
||||||
|
for (const row of projectRows) {
|
||||||
|
if (!row.path) continue;
|
||||||
|
if (!isUnder(real, row.path)) continue;
|
||||||
|
if (bestProject === null || row.path.length > bestProject.length) {
|
||||||
|
bestProject = row.path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestProject !== null) {
|
||||||
|
return { ok: true, root: bestProject, source: 'project' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repo-shape fallback. Walk from the requested path upward toward the
|
||||||
|
// whitelist root. At every iteration: confirm we're still inside the
|
||||||
|
// whitelist (so a symlinked component can't slip the bound mid-walk)
|
||||||
|
// and confirm we haven't hit the filesystem root. The first dir with a
|
||||||
|
// REPO_MARKER child is the grant root.
|
||||||
|
let cursor = real;
|
||||||
|
while (true) {
|
||||||
|
// Don't grant the whitelist root itself — that would be far too broad.
|
||||||
|
if (cursor === realWhitelist) {
|
||||||
|
return { ok: false, reason: 'no repo-shaped ancestor found under whitelist' };
|
||||||
|
}
|
||||||
|
if (!isUnder(cursor, realWhitelist)) {
|
||||||
|
return { ok: false, reason: 'path outside permitted scope' };
|
||||||
|
}
|
||||||
|
const parent = dirname(cursor);
|
||||||
|
if (parent === cursor) {
|
||||||
|
// Hit filesystem root without finding a repo marker.
|
||||||
|
return { ok: false, reason: 'no repo-shaped ancestor found under whitelist' };
|
||||||
|
}
|
||||||
|
if (await isRepoShaped(cursor)) {
|
||||||
|
return { ok: true, root: cursor, source: 'whitelist' };
|
||||||
|
}
|
||||||
|
cursor = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
import type { MessageMetadata, Session } from '../../types/api.js';
|
import type { MessageMetadata, Session } from '../../types/api.js';
|
||||||
|
import {
|
||||||
|
decideHtmlArtifactWrite,
|
||||||
|
detectHtmlArtifact,
|
||||||
|
deriveHtmlTitle,
|
||||||
|
HTML_ARTIFACT_MAX_BYTES,
|
||||||
|
} from '../artifacts.js';
|
||||||
import * as modelContext from '../model-context.js';
|
import * as modelContext from '../model-context.js';
|
||||||
import { maybeFlagForCompaction } from './payload.js';
|
import { maybeFlagForCompaction } from './payload.js';
|
||||||
import { insertParts, partsFromAssistantMessage } from './parts.js';
|
import { insertParts, partsFromAssistantMessage } from './parts.js';
|
||||||
|
import type { PartInsert } from './parts.js';
|
||||||
import type { InferenceContext, StreamResult, TurnArgs } from './turn.js';
|
import type { InferenceContext, StreamResult, TurnArgs } from './turn.js';
|
||||||
|
|
||||||
export async function handleAbortOrError(
|
export async function handleAbortOrError(
|
||||||
@@ -120,17 +127,42 @@ export async function finalizeCompletion(
|
|||||||
// a kind='reasoning' part alongside the text.
|
// a kind='reasoning' part alongside the text.
|
||||||
// TODO(v1.13.1): wrap the UPDATE above and this insertParts in a single
|
// TODO(v1.13.1): wrap the UPDATE above and this insertParts in a single
|
||||||
// sql.begin before flipping read authority to message_parts.
|
// sql.begin before flipping read authority to message_parts.
|
||||||
await insertParts(
|
const baseParts: PartInsert[] = partsFromAssistantMessage({
|
||||||
ctx.sql,
|
|
||||||
partsFromAssistantMessage({
|
|
||||||
content,
|
content,
|
||||||
tool_calls: null,
|
tool_calls: null,
|
||||||
reasoning: result.reasoning,
|
reasoning: result.reasoning,
|
||||||
}).map((p) => ({
|
}).map((p) => ({
|
||||||
...p,
|
...p,
|
||||||
message_id: assistantMessageId,
|
message_id: assistantMessageId,
|
||||||
})),
|
}));
|
||||||
|
// v1.14.x-html-artifact-panes: opportunistic HTML detection. Adds a
|
||||||
|
// SIBLING html_artifact part — never replaces the text part. 1MB cap is
|
||||||
|
// graceful: oversized payloads are skipped and the assistant message
|
||||||
|
// lands as plain content (warn logged).
|
||||||
|
const htmlContent = detectHtmlArtifact(content);
|
||||||
|
if (htmlContent !== null) {
|
||||||
|
const decision = decideHtmlArtifactWrite(htmlContent);
|
||||||
|
if (!decision.write) {
|
||||||
|
ctx.log.warn(
|
||||||
|
{ assistantMessageId, byteLen: decision.byteLen, cap: HTML_ARTIFACT_MAX_BYTES },
|
||||||
|
'html_artifact exceeded 1MB cap; skipping artifact part',
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
const title = deriveHtmlTitle(htmlContent);
|
||||||
|
const nextSeq = baseParts.reduce((m, p) => Math.max(m, p.sequence), -1) + 1;
|
||||||
|
baseParts.push({
|
||||||
|
message_id: assistantMessageId,
|
||||||
|
sequence: nextSeq,
|
||||||
|
kind: 'html_artifact',
|
||||||
|
payload: {
|
||||||
|
html_content: htmlContent,
|
||||||
|
char_count: htmlContent.length,
|
||||||
|
title,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await insertParts(ctx.sql, baseParts);
|
||||||
// v1.11: flag for compaction on the terminal turn too. Catches the common
|
// v1.11: flag for compaction on the terminal turn too. Catches the common
|
||||||
// case of a turn that hit the limit without invoking tools.
|
// case of a turn that hit the limit without invoking tools.
|
||||||
await maybeFlagForCompaction(ctx, chatId, updated);
|
await maybeFlagForCompaction(ctx, chatId, updated);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
createInferenceRunner,
|
createInferenceRunner,
|
||||||
|
MAX_STEPS,
|
||||||
runAssistantTurn,
|
runAssistantTurn,
|
||||||
runInference,
|
runInference,
|
||||||
} from './turn.js';
|
} from './turn.js';
|
||||||
@@ -16,5 +17,6 @@ export type {
|
|||||||
StreamResult,
|
StreamResult,
|
||||||
TurnArgs,
|
TurnArgs,
|
||||||
} from './turn.js';
|
} from './turn.js';
|
||||||
|
export type { ToolPhaseResult } from './tool-phase.js';
|
||||||
export { detectDoomLoop, DOOM_LOOP_THRESHOLD } from './sentinels.js';
|
export { detectDoomLoop, DOOM_LOOP_THRESHOLD } from './sentinels.js';
|
||||||
export { buildMessagesPayload } from './payload.js';
|
export { buildMessagesPayload } from './payload.js';
|
||||||
|
|||||||
@@ -11,13 +11,16 @@ import type { ToolCall, ToolResult } from '../../types/api.js';
|
|||||||
// (schema.sql adds 'synthesis' to message_parts_kind_chk on startup). The
|
// (schema.sql adds 'synthesis' to message_parts_kind_chk on startup). The
|
||||||
// dispatch's claim that no schema migration was needed assumed kind was a
|
// dispatch's claim that no schema migration was needed assumed kind was a
|
||||||
// bare text column — it isn't; the constraint enumerates allowed values.
|
// bare text column — it isn't; the constraint enumerates allowed values.
|
||||||
|
// v1.14.x-html-artifact-panes: 'html_artifact' added. Schema CHECK constraint
|
||||||
|
// in schema.sql updated in lockstep.
|
||||||
export type PartKind =
|
export type PartKind =
|
||||||
| 'text'
|
| 'text'
|
||||||
| 'tool_call'
|
| 'tool_call'
|
||||||
| 'tool_result'
|
| 'tool_result'
|
||||||
| 'reasoning'
|
| 'reasoning'
|
||||||
| 'step_start'
|
| 'step_start'
|
||||||
| 'synthesis';
|
| 'synthesis'
|
||||||
|
| 'html_artifact';
|
||||||
|
|
||||||
export interface PartInsert {
|
export interface PartInsert {
|
||||||
message_id: string;
|
message_id: string;
|
||||||
|
|||||||
@@ -476,6 +476,202 @@ export async function runDoomLoopSummary(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.14.0: step-cap wrap-up. Mirrors runCapHitSummary structurally — same
|
||||||
|
// in-flight-slot reuse, same tools-disabled streaming-summary call, same
|
||||||
|
// post-finalize sentinel insert + chat_status drop. Difference: the note
|
||||||
|
// text names the step limit rather than the tool budget. Sentinel reuses
|
||||||
|
// metadata.kind = 'cap_hit' so the frontend CapHitSentinel component
|
||||||
|
// renders it without changes.
|
||||||
|
const STEP_CAP_NOTE = (steps: number, cap: number) =>
|
||||||
|
`You've reached the step limit (${steps}/${cap} steps). Produce the best answer you can with what you have. Do not call more tools.`;
|
||||||
|
|
||||||
|
export async function runStepCapSummary(
|
||||||
|
ctx: InferenceContext,
|
||||||
|
args: TurnArgs,
|
||||||
|
session: Session,
|
||||||
|
project: Project,
|
||||||
|
history: Message[],
|
||||||
|
agent: Agent | null,
|
||||||
|
steps: number,
|
||||||
|
cap: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const { sessionId, chatId, assistantMessageId, signal } = args;
|
||||||
|
|
||||||
|
const messages = await buildMessagesPayload(session, project, history, agent, ctx.log);
|
||||||
|
messages.push({ role: 'system', content: STEP_CAP_NOTE(steps, cap) });
|
||||||
|
|
||||||
|
const startedRow = await ctx.sql<{ started_at: string }[]>`
|
||||||
|
UPDATE messages
|
||||||
|
SET started_at = clock_timestamp()
|
||||||
|
WHERE id = ${assistantMessageId}
|
||||||
|
RETURNING started_at
|
||||||
|
`;
|
||||||
|
const startedAt = startedRow[0]?.started_at ?? null;
|
||||||
|
|
||||||
|
ctx.publish(sessionId, {
|
||||||
|
type: 'message_started',
|
||||||
|
message_id: assistantMessageId,
|
||||||
|
chat_id: chatId,
|
||||||
|
role: 'assistant',
|
||||||
|
});
|
||||||
|
|
||||||
|
let accumulated = '';
|
||||||
|
let pendingFlushTimer: NodeJS.Timeout | null = null;
|
||||||
|
let flushPromise: Promise<unknown> = Promise.resolve();
|
||||||
|
const flushNow = () => {
|
||||||
|
if (pendingFlushTimer) {
|
||||||
|
clearTimeout(pendingFlushTimer);
|
||||||
|
pendingFlushTimer = null;
|
||||||
|
}
|
||||||
|
const snapshot = accumulated;
|
||||||
|
flushPromise = flushPromise.then(() =>
|
||||||
|
ctx.sql`UPDATE messages SET content = ${snapshot} WHERE id = ${assistantMessageId}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const scheduleFlush = () => {
|
||||||
|
if (pendingFlushTimer) return;
|
||||||
|
pendingFlushTimer = setTimeout(() => {
|
||||||
|
pendingFlushTimer = null;
|
||||||
|
flushNow();
|
||||||
|
}, DB_FLUSH_INTERVAL_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
let summaryOk = false;
|
||||||
|
let summarySoftCancelled = false;
|
||||||
|
let summaryError: string | null = null;
|
||||||
|
let result: StreamResult | null = null;
|
||||||
|
try {
|
||||||
|
result = await streamCompletion(
|
||||||
|
ctx,
|
||||||
|
session.model,
|
||||||
|
messages,
|
||||||
|
{ tools: null, temperature: agent?.temperature },
|
||||||
|
(delta) => {
|
||||||
|
accumulated += delta;
|
||||||
|
ctx.publish(sessionId, {
|
||||||
|
type: 'delta',
|
||||||
|
message_id: assistantMessageId,
|
||||||
|
chat_id: chatId,
|
||||||
|
content: delta,
|
||||||
|
});
|
||||||
|
scheduleFlush();
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
summaryOk = true;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name === 'AbortError') {
|
||||||
|
summarySoftCancelled = true;
|
||||||
|
} else {
|
||||||
|
summaryError = err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (pendingFlushTimer) {
|
||||||
|
clearTimeout(pendingFlushTimer);
|
||||||
|
pendingFlushTimer = null;
|
||||||
|
}
|
||||||
|
await flushPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summaryOk && result) {
|
||||||
|
const mctx = await modelContext.getModelContext(session.model);
|
||||||
|
const nCtx = mctx?.n_ctx ?? null;
|
||||||
|
const [updated] = await ctx.sql<
|
||||||
|
{ tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[]
|
||||||
|
>`
|
||||||
|
UPDATE messages
|
||||||
|
SET content = ${result.content},
|
||||||
|
status = 'complete',
|
||||||
|
tokens_used = ${result.completionTokens},
|
||||||
|
ctx_used = ${result.promptTokens},
|
||||||
|
ctx_max = ${nCtx},
|
||||||
|
finished_at = clock_timestamp()
|
||||||
|
WHERE id = ${assistantMessageId}
|
||||||
|
RETURNING tokens_used, ctx_used, ctx_max, finished_at
|
||||||
|
`;
|
||||||
|
ctx.publish(sessionId, {
|
||||||
|
type: 'message_complete',
|
||||||
|
message_id: assistantMessageId,
|
||||||
|
chat_id: chatId,
|
||||||
|
tokens_used: updated?.tokens_used ?? null,
|
||||||
|
ctx_used: updated?.ctx_used ?? null,
|
||||||
|
ctx_max: updated?.ctx_max ?? null,
|
||||||
|
started_at: startedAt,
|
||||||
|
finished_at: updated?.finished_at ?? null,
|
||||||
|
model: session.model,
|
||||||
|
});
|
||||||
|
} else if (summarySoftCancelled) {
|
||||||
|
await ctx.sql`
|
||||||
|
UPDATE messages
|
||||||
|
SET content = ${accumulated},
|
||||||
|
status = 'cancelled',
|
||||||
|
finished_at = clock_timestamp()
|
||||||
|
WHERE id = ${assistantMessageId}
|
||||||
|
`;
|
||||||
|
ctx.publish(sessionId, {
|
||||||
|
type: 'message_complete',
|
||||||
|
message_id: assistantMessageId,
|
||||||
|
chat_id: chatId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const errMeta: MessageMetadata = {
|
||||||
|
kind: 'error',
|
||||||
|
error_reason: 'summary_after_cap_failed',
|
||||||
|
error_text: summaryError ?? 'step-cap summary failed',
|
||||||
|
};
|
||||||
|
await ctx.sql`
|
||||||
|
UPDATE messages
|
||||||
|
SET content = ${accumulated},
|
||||||
|
status = 'failed',
|
||||||
|
finished_at = clock_timestamp(),
|
||||||
|
metadata = ${ctx.sql.json(errMeta as never)}
|
||||||
|
WHERE id = ${assistantMessageId}
|
||||||
|
`;
|
||||||
|
ctx.publish(sessionId, {
|
||||||
|
type: 'error',
|
||||||
|
message_id: assistantMessageId,
|
||||||
|
chat_id: chatId,
|
||||||
|
error: summaryError ?? 'step-cap summary failed',
|
||||||
|
reason: 'summary_after_cap_failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [sessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>`
|
||||||
|
UPDATE sessions SET updated_at = clock_timestamp()
|
||||||
|
WHERE id = ${sessionId}
|
||||||
|
RETURNING project_id, name, updated_at
|
||||||
|
`;
|
||||||
|
ctx.publishUser({
|
||||||
|
type: 'session_updated',
|
||||||
|
session_id: sessionId,
|
||||||
|
project_id: sessRow!.project_id,
|
||||||
|
name: sessRow!.name,
|
||||||
|
updated_at: sessRow!.updated_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reuse cap_hit sentinel so the frontend CapHitSentinel component renders
|
||||||
|
// it without changes. The content text distinguishes step cap from budget.
|
||||||
|
await insertCapHitSentinel(ctx, sessionId, chatId, agent, cap);
|
||||||
|
|
||||||
|
if (summaryOk || summarySoftCancelled) {
|
||||||
|
ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'idle', at: new Date().toISOString() });
|
||||||
|
} else {
|
||||||
|
ctx.publishUser({
|
||||||
|
type: 'chat_status',
|
||||||
|
chat_id: chatId,
|
||||||
|
status: 'error',
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
reason: 'summary_after_cap_failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.log.info(
|
||||||
|
{ sessionId, chatId, assistantMessageId, steps, cap, summaryOk, summaryCancelled: summarySoftCancelled },
|
||||||
|
'inference step-cap summary finished',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function insertDoomLoopSentinel(
|
async function insertDoomLoopSentinel(
|
||||||
ctx: InferenceContext,
|
ctx: InferenceContext,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
} from '../../types/api.js';
|
} from '../../types/api.js';
|
||||||
import * as modelContext from '../model-context.js';
|
import * as modelContext from '../model-context.js';
|
||||||
import { toolJsonSchemas, type ToolJsonSchema } from '../tools.js';
|
import { toolJsonSchemas, type ToolJsonSchema } from '../tools.js';
|
||||||
|
import { matchToolGlob } from '../agents.js';
|
||||||
import type { OpenAiMessage } from './payload.js';
|
import type { OpenAiMessage } from './payload.js';
|
||||||
// v1.13.16: extractToolCallBlocks replaces the inline opener-search loop and
|
// v1.13.16: extractToolCallBlocks replaces the inline opener-search loop and
|
||||||
// recognizes both Qwen <tool_call> and Anthropic <invoke> markup in one pass.
|
// recognizes both Qwen <tool_call> and Anthropic <invoke> markup in one pass.
|
||||||
@@ -376,14 +377,14 @@ export async function executeStreamPhase(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Tool whitelist: if an agent is set, filter the global tool list to only the
|
// Tool whitelist: if an agent is set, filter the global tool list to only the
|
||||||
// tool names it allows. Unknown names in agent.tools are dropped silently
|
// tool names it allows. v1.15.0-mcp-multi: uses matchToolGlob for glob
|
||||||
// (handled here by intersection). When no agent: send all tools.
|
// pattern support (e.g. `context7_*`, `!web_*`). When no agent: send all tools.
|
||||||
// v1.11.8: a second filter strips web_search + web_fetch unless the chat
|
// v1.11.8: a second filter strips web_search + web_fetch unless the chat
|
||||||
// has them explicitly enabled. Counts as an opt-in security boundary: the
|
// has them explicitly enabled. Counts as an opt-in security boundary: the
|
||||||
// model can't summon a tool that wasn't offered to it.
|
// model can't summon a tool that wasn't offered to it.
|
||||||
const WEB_TOOL_NAMES: ReadonlySet<string> = new Set(['web_search', 'web_fetch']);
|
const WEB_TOOL_NAMES: ReadonlySet<string> = new Set(['web_search', 'web_fetch']);
|
||||||
const effectiveTools: ToolJsonSchema[] = (agent
|
const effectiveTools: ToolJsonSchema[] = (agent
|
||||||
? toolJsonSchemas().filter((t) => agent.tools.includes(t.function.name))
|
? toolJsonSchemas().filter((t) => matchToolGlob(t.function.name, agent.tools))
|
||||||
: toolJsonSchemas()
|
: toolJsonSchemas()
|
||||||
).filter((t) => webToolsEnabled || !WEB_TOOL_NAMES.has(t.function.name));
|
).filter((t) => webToolsEnabled || !WEB_TOOL_NAMES.has(t.function.name));
|
||||||
const effectiveTemperature = agent?.temperature;
|
const effectiveTemperature = agent?.temperature;
|
||||||
|
|||||||
@@ -10,16 +10,15 @@ import { insertParts, partsFromAssistantMessage, partsFromToolMessage } from './
|
|||||||
// dispatch layer we no longer know which format produced the call, and the
|
// dispatch layer we no longer know which format produced the call, and the
|
||||||
// extra signal is harmless for Qwen-derived calls.
|
// extra signal is harmless for Qwen-derived calls.
|
||||||
import { formatUnknownToolError } from './tool-suggestions.js';
|
import { formatUnknownToolError } from './tool-suggestions.js';
|
||||||
|
// v1.13.17-cross-repo-reads: pre-prompt validation for request_read_access.
|
||||||
|
// Resolves the grant root before pausing the loop so the user is never
|
||||||
|
// prompted about paths we couldn't grant anyway (e.g. /etc/passwd).
|
||||||
|
import { resolveGrantRoot } from '../grant_resolver.js';
|
||||||
import type {
|
import type {
|
||||||
InferenceContext,
|
InferenceContext,
|
||||||
StreamResult,
|
StreamResult,
|
||||||
TurnArgs,
|
TurnArgs,
|
||||||
} from './turn.js';
|
} from './turn.js';
|
||||||
// v1.12.4: ESM value-import cycle. executeToolPhase recurses into
|
|
||||||
// runAssistantTurn which lives in inference.ts. The cycle is safe because
|
|
||||||
// the reference is read at call time (inside an async function body), not
|
|
||||||
// at module top-level. Node + tsc resolve this cleanly.
|
|
||||||
import { runAssistantTurn } from './turn.js';
|
|
||||||
// v1.13.13: synthesis pipeline — replaces the immediate recursive turn when
|
// v1.13.13: synthesis pipeline — replaces the immediate recursive turn when
|
||||||
// any of this batch's tool calls is in SYNTHESIS_TOOLS. Falls through to
|
// any of this batch's tool calls is in SYNTHESIS_TOOLS. Falls through to
|
||||||
// recursion on synthesis failure (timeout / model error). See module header
|
// recursion on synthesis failure (timeout / model error). See module header
|
||||||
@@ -28,7 +27,8 @@ import { SYNTHESIS_TOOLS, runSynthesisPass } from '../synthesisPipeline.js';
|
|||||||
|
|
||||||
async function executeToolCall(
|
async function executeToolCall(
|
||||||
projectRoot: string,
|
projectRoot: string,
|
||||||
toolCall: ToolCall
|
toolCall: ToolCall,
|
||||||
|
extraRoots: readonly string[],
|
||||||
): Promise<{ output: unknown; truncated: boolean; error?: string }> {
|
): Promise<{ output: unknown; truncated: boolean; error?: string }> {
|
||||||
const tool = TOOLS_BY_NAME[toolCall.name];
|
const tool = TOOLS_BY_NAME[toolCall.name];
|
||||||
if (!tool) {
|
if (!tool) {
|
||||||
@@ -63,7 +63,7 @@ async function executeToolCall(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const output = await tool.execute(parsed.data, projectRoot);
|
const output = await tool.execute(parsed.data, projectRoot, extraRoots);
|
||||||
const truncated =
|
const truncated =
|
||||||
typeof output === 'object' && output !== null && 'truncated' in output
|
typeof output === 'object' && output !== null && 'truncated' in output
|
||||||
? Boolean((output as { truncated: unknown }).truncated)
|
? Boolean((output as { truncated: unknown }).truncated)
|
||||||
@@ -81,6 +81,16 @@ async function executeToolCall(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.14.0: return struct from executeToolPhase so the caller (the outer
|
||||||
|
// while loop in turn.ts) can decide whether to continue, break, or handle
|
||||||
|
// synthesis. Replaces the recursive call into runAssistantTurn.
|
||||||
|
export interface ToolPhaseResult {
|
||||||
|
action: 'continue' | 'paused' | 'synthesis_done';
|
||||||
|
toolCallCount: number;
|
||||||
|
toolCalls: ToolCall[];
|
||||||
|
nextAssistantId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function executeToolPhase(
|
export async function executeToolPhase(
|
||||||
ctx: InferenceContext,
|
ctx: InferenceContext,
|
||||||
args: TurnArgs,
|
args: TurnArgs,
|
||||||
@@ -88,8 +98,8 @@ export async function executeToolPhase(
|
|||||||
startedAt: string | null,
|
startedAt: string | null,
|
||||||
session: Session,
|
session: Session,
|
||||||
projectRoot: string
|
projectRoot: string
|
||||||
): Promise<void> {
|
): Promise<ToolPhaseResult> {
|
||||||
const { sessionId, chatId, assistantMessageId, toolsUsed, signal } = args;
|
const { sessionId, chatId, assistantMessageId } = args;
|
||||||
const { content, toolCalls, promptTokens, completionTokens } = result;
|
const { content, toolCalls, promptTokens, completionTokens } = result;
|
||||||
|
|
||||||
// v1.11.3: ctx_max comes from llama-swap /upstream/<model>/props, not the
|
// v1.11.3: ctx_max comes from llama-swap /upstream/<model>/props, not the
|
||||||
@@ -105,7 +115,6 @@ export async function executeToolPhase(
|
|||||||
UPDATE messages
|
UPDATE messages
|
||||||
SET content = ${content},
|
SET content = ${content},
|
||||||
status = 'complete',
|
status = 'complete',
|
||||||
tool_calls = ${ctx.sql.json(toolCalls as never)},
|
|
||||||
tokens_used = ${completionTokens},
|
tokens_used = ${completionTokens},
|
||||||
ctx_used = ${promptTokens},
|
ctx_used = ${promptTokens},
|
||||||
ctx_max = ${nCtx},
|
ctx_max = ${nCtx},
|
||||||
@@ -113,15 +122,11 @@ export async function executeToolPhase(
|
|||||||
WHERE id = ${assistantMessageId}
|
WHERE id = ${assistantMessageId}
|
||||||
RETURNING tokens_used, ctx_used, ctx_max, finished_at
|
RETURNING tokens_used, ctx_used, ctx_max, finished_at
|
||||||
`;
|
`;
|
||||||
// v1.13.0: dual-write to message_parts. v1.13.1-B made parts authoritative
|
// v1.13.20: message_parts is the sole source of truth for tool_calls.
|
||||||
// for reads via the messages_with_parts view; the JSON column write above
|
// Legacy messages.tool_calls column was dropped; reads route through the
|
||||||
// remains for v1.13.1 fallback compatibility (dropped in v1.13.2).
|
// messages_with_parts view.
|
||||||
// v1.13.1-C: include result.reasoning so models with separate reasoning
|
// v1.13.1-C: include result.reasoning so models with separate reasoning
|
||||||
// channels (qwen3.6) get a kind='reasoning' part at sequence 0.
|
// channels (qwen3.6) get a kind='reasoning' part at sequence 0.
|
||||||
// TODO(v1.13.1): wrap the UPDATE above and this insertParts in a single
|
|
||||||
// sql.begin before flipping read authority to message_parts. Without the
|
|
||||||
// transaction, a crash between the two leaves an orphan message that
|
|
||||||
// becomes invisible in the parts-authoritative read path.
|
|
||||||
await insertParts(
|
await insertParts(
|
||||||
ctx.sql,
|
ctx.sql,
|
||||||
partsFromAssistantMessage({
|
partsFromAssistantMessage({
|
||||||
@@ -187,16 +192,9 @@ export async function executeToolPhase(
|
|||||||
if (tc.name === 'ask_user_input') {
|
if (tc.name === 'ask_user_input') {
|
||||||
pausingForUserInput = true;
|
pausingForUserInput = true;
|
||||||
const sentinel = { tool_call_id: tc.id, output: null, truncated: false };
|
const sentinel = { tool_call_id: tc.id, output: null, truncated: false };
|
||||||
await ctx.sql`
|
// v1.13.20: parts-only. The answer-endpoint UPDATE later
|
||||||
UPDATE messages
|
// (messages.ts) will delete and re-insert this part when the user
|
||||||
SET tool_results = ${ctx.sql.json(sentinel as never)}
|
// submits their answer.
|
||||||
WHERE id = ${toolMessageId}
|
|
||||||
`;
|
|
||||||
// v1.13.0: mirror the pending sentinel into message_parts. The
|
|
||||||
// answer-endpoint UPDATE later (messages.ts:576) will delete and
|
|
||||||
// re-insert this part when the user submits their answer.
|
|
||||||
// TODO(v1.13.1): wrap the INSERT + UPDATE + insertParts triple in
|
|
||||||
// a per-iteration sql.begin before flipping read authority.
|
|
||||||
await insertParts(
|
await insertParts(
|
||||||
ctx.sql,
|
ctx.sql,
|
||||||
partsFromToolMessage({ tool_results: sentinel }).map((p) => ({
|
partsFromToolMessage({ tool_results: sentinel }).map((p) => ({
|
||||||
@@ -206,7 +204,63 @@ export async function executeToolPhase(
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tres = await executeToolCall(projectRoot, tc);
|
// v1.13.17-cross-repo-reads: request_read_access pauses identically to
|
||||||
|
// ask_user_input EXCEPT for an up-front validation pass — if the path
|
||||||
|
// can't be granted under the whitelist / repo-shape rules, surface an
|
||||||
|
// immediate denial without prompting the user. Per design D1, we never
|
||||||
|
// ask the user about /etc/passwd or paths outside PROJECT_ROOT_WHITELIST.
|
||||||
|
if (tc.name === 'request_read_access') {
|
||||||
|
const tcArgs = tc.args as { path?: unknown; reason?: unknown };
|
||||||
|
const requested =
|
||||||
|
typeof tcArgs.path === 'string' ? tcArgs.path : '';
|
||||||
|
const resolution = await resolveGrantRoot(
|
||||||
|
ctx.sql,
|
||||||
|
requested,
|
||||||
|
projectRoot,
|
||||||
|
ctx.config.PROJECT_ROOT_WHITELIST,
|
||||||
|
);
|
||||||
|
if (!resolution.ok) {
|
||||||
|
// Auto-deny without pausing. The model sees the reason on its
|
||||||
|
// next turn and decides what to do.
|
||||||
|
const stored = {
|
||||||
|
tool_call_id: tc.id,
|
||||||
|
output: `denied: ${resolution.reason}`,
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
// v1.13.20: parts-only write.
|
||||||
|
await insertParts(
|
||||||
|
ctx.sql,
|
||||||
|
partsFromToolMessage({ tool_results: stored }).map((p) => ({
|
||||||
|
...p,
|
||||||
|
message_id: toolMessageId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
ctx.publish(sessionId, {
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_message_id: toolMessageId,
|
||||||
|
chat_id: chatId,
|
||||||
|
tool_call_id: tc.id,
|
||||||
|
output: stored.output,
|
||||||
|
truncated: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Path is plausibly grantable — install the pending sentinel and
|
||||||
|
// pause. The grant endpoint re-derives the root at decision time
|
||||||
|
// (state may have changed in the meantime) so we don't stash it here.
|
||||||
|
pausingForUserInput = true;
|
||||||
|
const sentinel = { tool_call_id: tc.id, output: null, truncated: false };
|
||||||
|
// v1.13.20: parts-only write.
|
||||||
|
await insertParts(
|
||||||
|
ctx.sql,
|
||||||
|
partsFromToolMessage({ tool_results: sentinel }).map((p) => ({
|
||||||
|
...p,
|
||||||
|
message_id: toolMessageId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tres = await executeToolCall(projectRoot, tc, session.allowed_read_paths);
|
||||||
if (SYNTHESIS_TOOLS.has(tc.name)) {
|
if (SYNTHESIS_TOOLS.has(tc.name)) {
|
||||||
synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) });
|
synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) });
|
||||||
}
|
}
|
||||||
@@ -216,14 +270,7 @@ export async function executeToolPhase(
|
|||||||
truncated: tres.truncated,
|
truncated: tres.truncated,
|
||||||
...(tres.error ? { error: tres.error } : {}),
|
...(tres.error ? { error: tres.error } : {}),
|
||||||
};
|
};
|
||||||
await ctx.sql`
|
// v1.13.20: parts-only write. Reads route through messages_with_parts.
|
||||||
UPDATE messages
|
|
||||||
SET tool_results = ${ctx.sql.json(stored as never)}
|
|
||||||
WHERE id = ${toolMessageId}
|
|
||||||
`;
|
|
||||||
// v1.13.0: dual-write the tool_result part.
|
|
||||||
// TODO(v1.13.1): wrap the INSERT + UPDATE + insertParts triple in a
|
|
||||||
// per-iteration sql.begin before flipping read authority.
|
|
||||||
await insertParts(
|
await insertParts(
|
||||||
ctx.sql,
|
ctx.sql,
|
||||||
partsFromToolMessage({ tool_results: stored }).map((p) => ({
|
partsFromToolMessage({ tool_results: stored }).map((p) => ({
|
||||||
@@ -254,7 +301,12 @@ export async function executeToolPhase(
|
|||||||
{ sessionId, chatId, assistantMessageId },
|
{ sessionId, chatId, assistantMessageId },
|
||||||
'inference paused awaiting user input',
|
'inference paused awaiting user input',
|
||||||
);
|
);
|
||||||
return;
|
return {
|
||||||
|
action: 'paused' as const,
|
||||||
|
toolCallCount: toolCalls.length,
|
||||||
|
toolCalls,
|
||||||
|
nextAssistantId: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.13.13: synthesis-pipeline branch. When any of this batch's tool calls
|
// v1.13.13: synthesis-pipeline branch. When any of this batch's tool calls
|
||||||
@@ -286,30 +338,30 @@ export async function executeToolPhase(
|
|||||||
...(typeof out?.truncated === 'boolean' ? { truncated: out.truncated } : {}),
|
...(typeof out?.truncated === 'boolean' ? { truncated: out.truncated } : {}),
|
||||||
...(typeof out?.outputPath === 'string' ? { outputPath: out.outputPath } : {}),
|
...(typeof out?.outputPath === 'string' ? { outputPath: out.outputPath } : {}),
|
||||||
});
|
});
|
||||||
if (ran) return;
|
if (ran) {
|
||||||
|
return {
|
||||||
|
action: 'synthesis_done' as const,
|
||||||
|
toolCallCount: toolCalls.length,
|
||||||
|
toolCalls,
|
||||||
|
nextAssistantId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
// ran === false → synthesis failed (timeout / model error) → fall through
|
// ran === false → synthesis failed (timeout / model error) → fall through
|
||||||
// to the standard recursive turn below. The synth message (if created)
|
// to the standard continue path below. The synth message (if created)
|
||||||
// was already marked status='failed' inside runSynthesisPass.
|
// was already marked status='failed' inside runSynthesisPass.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.14.0: create the next assistant row and return a continue result.
|
||||||
|
// The caller (outer while loop in turn.ts) handles the iteration.
|
||||||
const [nextAssistant] = await ctx.sql<{ id: string }[]>`
|
const [nextAssistant] = await ctx.sql<{ id: string }[]>`
|
||||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
await runAssistantTurn(ctx, {
|
return {
|
||||||
sessionId,
|
action: 'continue' as const,
|
||||||
chatId,
|
toolCallCount: toolCalls.length,
|
||||||
assistantMessageId: nextAssistant!.id,
|
toolCalls,
|
||||||
// v1.8.2: charge this turn's actual tool invocations against the budget.
|
nextAssistantId: nextAssistant!.id,
|
||||||
// One assistant message can emit multiple tool_calls, so we add the run
|
};
|
||||||
// count, not 1. The next turn's budget check sees the cumulative total.
|
|
||||||
toolsUsed: toolsUsed + result.toolCalls.length,
|
|
||||||
// v1.11.6: append the just-executed tool calls to the per-turn history
|
|
||||||
// so the next runAssistantTurn's doom-loop check can see them. We don't
|
|
||||||
// cap the array length here — per-turn budgets keep it bounded
|
|
||||||
// (typically <30 entries), and slicing happens inside detectDoomLoop.
|
|
||||||
recentToolCalls: [...args.recentToolCalls, ...result.toolCalls],
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,9 @@ import { resolveProjectRoot } from '../path_guard.js';
|
|||||||
import { maybeAutoNameChat } from '../auto_name.js';
|
import { maybeAutoNameChat } from '../auto_name.js';
|
||||||
import { getAgentById } from '../agents.js';
|
import { getAgentById } from '../agents.js';
|
||||||
import * as compaction from '../compaction.js';
|
import * as compaction from '../compaction.js';
|
||||||
import * as modelContext from '../model-context.js';
|
|
||||||
import type { Broker } from '../broker.js';
|
import type { Broker } from '../broker.js';
|
||||||
import { resolveToolBudget } from './budget.js';
|
import { resolveToolBudget } from './budget.js';
|
||||||
import {
|
import {
|
||||||
DOOM_LOOP_THRESHOLD,
|
|
||||||
detectDoomLoop,
|
detectDoomLoop,
|
||||||
} from './sentinels.js';
|
} from './sentinels.js';
|
||||||
import {
|
import {
|
||||||
@@ -33,15 +31,23 @@ import {
|
|||||||
} from './error-handler.js';
|
} from './error-handler.js';
|
||||||
import {
|
import {
|
||||||
executeStreamPhase,
|
executeStreamPhase,
|
||||||
streamCompletion,
|
|
||||||
} from './stream-phase.js';
|
} from './stream-phase.js';
|
||||||
import { executeToolPhase } from './tool-phase.js';
|
import { executeToolPhase, type ToolPhaseResult } from './tool-phase.js';
|
||||||
import { DB_FLUSH_INTERVAL_MS, type StreamPhaseState } from './types.js';
|
import type { StreamPhaseState } from './types.js';
|
||||||
import {
|
import {
|
||||||
runCapHitSummary,
|
runCapHitSummary,
|
||||||
runDoomLoopSummary,
|
runDoomLoopSummary,
|
||||||
|
runStepCapSummary,
|
||||||
} from './sentinel-summaries.js';
|
} from './sentinel-summaries.js';
|
||||||
|
|
||||||
|
// v1.14.0: hard ceiling on the number of stream-and-tool iterations per
|
||||||
|
// user-message turn. Per-agent cap via agent.steps is the primary knob;
|
||||||
|
// MAX_STEPS is the safety ceiling. 200 is 4x the effective budget ceiling
|
||||||
|
// (50 tool calls) — in practice budget fires first unless the model makes
|
||||||
|
// many 0-tool-call iterations (which exit the loop via the non-tool finish
|
||||||
|
// path anyway).
|
||||||
|
export const MAX_STEPS = 200;
|
||||||
|
|
||||||
// v1.12.4: re-exported so external callers (tests, future consumers) keep
|
// v1.12.4: re-exported so external callers (tests, future consumers) keep
|
||||||
// importing from services/inference.js as the public surface.
|
// importing from services/inference.js as the public surface.
|
||||||
export { detectDoomLoop, DOOM_LOOP_THRESHOLD } from './sentinels.js';
|
export { detectDoomLoop, DOOM_LOOP_THRESHOLD } from './sentinels.js';
|
||||||
@@ -145,13 +151,70 @@ export async function runAssistantTurn(
|
|||||||
ctx: InferenceContext,
|
ctx: InferenceContext,
|
||||||
args: TurnArgs,
|
args: TurnArgs,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { sessionId, chatId } = args;
|
const { sessionId, chatId, signal } = args;
|
||||||
|
|
||||||
// v1.11: if the prior turn flagged this chat for compaction, run it first
|
// v1.14.0: resolve agent once at the top. The agent stays fixed for the
|
||||||
// so loadContext below reads the post-compaction history. We swallow
|
// duration of this user-message turn — PATCH agent_id mid-conversation
|
||||||
// compaction failures (clearing the flag so we don't loop) and proceed
|
// takes effect on the next runInference, not mid-loop.
|
||||||
// with the un-compacted history — a slow turn that hits the model's
|
const initialLoaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||||
// hard limit is recoverable; a dead session is not.
|
if (!initialLoaded) {
|
||||||
|
ctx.log.warn({ sessionId }, 'inference: session or project missing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { session, project } = initialLoaded;
|
||||||
|
const agent = session.agent_id
|
||||||
|
? await getAgentById(project.path, session.agent_id)
|
||||||
|
: null;
|
||||||
|
const budget = resolveToolBudget(agent);
|
||||||
|
|
||||||
|
// v1.14.0: effectiveCap = min(agent.steps ?? Infinity, MAX_STEPS).
|
||||||
|
// steps: 0 means "no tool calls allowed" — the first stream phase runs
|
||||||
|
// but if it emits tool calls they are not executed (finalize as text-only).
|
||||||
|
const effectiveCap = Math.min(agent?.steps ?? Infinity, MAX_STEPS);
|
||||||
|
|
||||||
|
// steps: 0 special case — model responds text-only. The while loop would
|
||||||
|
// never enter (effectiveCap === 0), so we handle it explicitly before the
|
||||||
|
// loop. The model always gets at least one chance to respond with text.
|
||||||
|
if (effectiveCap === 0) {
|
||||||
|
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||||
|
if (loaded) {
|
||||||
|
await runTextOnlyTurn(ctx, args, loaded.session, loaded.project, loaded.history, agent);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stepNumber = 0;
|
||||||
|
let toolsUsed = args.toolsUsed;
|
||||||
|
let recentToolCalls = args.recentToolCalls;
|
||||||
|
let assistantMessageId = args.assistantMessageId;
|
||||||
|
|
||||||
|
while (stepNumber < effectiveCap) {
|
||||||
|
// ---- doom-loop check (moved from top-of-function) ----
|
||||||
|
const loop = detectDoomLoop(recentToolCalls);
|
||||||
|
if (loop) {
|
||||||
|
// Need fresh history for the summary.
|
||||||
|
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||||
|
if (loaded) {
|
||||||
|
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, signal };
|
||||||
|
await runDoomLoopSummary(ctx, iterArgs, loaded.session, loaded.project, loaded.history, agent, loop);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- budget check (moved from top-of-function) ----
|
||||||
|
if (toolsUsed >= budget) {
|
||||||
|
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||||
|
if (loaded) {
|
||||||
|
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, signal };
|
||||||
|
await runCapHitSummary(ctx, iterArgs, loaded.session, loaded.project, loaded.history, agent, budget);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- compaction check ----
|
||||||
|
// v1.11: if the prior turn flagged this chat for compaction, run it
|
||||||
|
// before loadContext so we read post-compaction history. Swallow
|
||||||
|
// failures and proceed with un-compacted history.
|
||||||
const chatFlag = await ctx.sql<{ needs_compaction: boolean }[]>`
|
const chatFlag = await ctx.sql<{ needs_compaction: boolean }[]>`
|
||||||
SELECT needs_compaction FROM chats WHERE id = ${chatId}
|
SELECT needs_compaction FROM chats WHERE id = ${chatId}
|
||||||
`;
|
`;
|
||||||
@@ -170,50 +233,103 @@ export async function runAssistantTurn(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- load context (must re-load each iteration — new messages since last step) ----
|
||||||
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
ctx.log.warn({ sessionId }, 'inference: session or project missing');
|
ctx.log.warn({ sessionId }, 'inference: session or project missing mid-loop');
|
||||||
return;
|
break;
|
||||||
}
|
}
|
||||||
const { session, project, history } = loaded;
|
const { session: iterSession, project: iterProject, history } = loaded;
|
||||||
const projectRoot = await resolveProjectRoot(project.path);
|
const projectRoot = await resolveProjectRoot(iterProject.path);
|
||||||
// Agent resolution is per-turn so PATCH agent_id mid-conversation takes
|
|
||||||
// effect on the next message. Unknown agent_id returns null silently —
|
|
||||||
// session falls back to base prompt + all tools + default temperature.
|
|
||||||
const agent = session.agent_id
|
|
||||||
? await getAgentById(project.path, session.agent_id)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// v1.8.2: cap-hit replaces the older "tool loop depth exceeded" failure.
|
// v1.14.0: log step boundary for instrumentation. step_start parts are in
|
||||||
// When we've already burned the budget *before* this turn even runs, we
|
// the schema CHECK but not emitted here — writing to the assistant message
|
||||||
// skip straight to the summary flow — the in-flight assistant message slot
|
// before the stream phase creates a sequence-0 collision with
|
||||||
// gets reused for the wrap-up reply instead of being marked failed.
|
// partsFromAssistantMessage. A WS frame or structured log is sufficient
|
||||||
const budget = resolveToolBudget(agent);
|
// since the frontend doesn't render step boundaries in v1.14.
|
||||||
if (args.toolsUsed >= budget) {
|
ctx.log.info({ sessionId, chatId, step: stepNumber, assistantMessageId }, 'step_start');
|
||||||
await runCapHitSummary(ctx, args, session, project, history, agent, budget);
|
|
||||||
return;
|
// ---- build messages + stream phase ----
|
||||||
|
const messages = await buildMessagesPayload(iterSession, iterProject, history, agent, ctx.log);
|
||||||
|
const webToolsEnabled =
|
||||||
|
iterSession.web_search_enabled ?? iterProject.default_web_search_enabled ?? false;
|
||||||
|
|
||||||
|
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, signal };
|
||||||
|
const state: StreamPhaseState = { accumulated: '', startedAt: null };
|
||||||
|
let result: StreamResult;
|
||||||
|
try {
|
||||||
|
result = await executeStreamPhase(ctx, iterArgs, iterSession, messages, state, agent, webToolsEnabled);
|
||||||
|
} catch (err) {
|
||||||
|
await handleAbortOrError(ctx, iterArgs, state.accumulated, err);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.11.6: doom-loop guard. Detected BEFORE the budget cap (the model can
|
// ---- non-tool finish → finalize and exit ----
|
||||||
// burn through 3 identical calls long before the 15-call budget fires).
|
if (result.toolCalls.length === 0) {
|
||||||
// Same in-flight-slot-reuse pattern as runCapHitSummary — wrap-up reply
|
await finalizeCompletion(ctx, iterArgs, result, state.startedAt, iterSession);
|
||||||
// lands in args.assistantMessageId, then a doom_loop sentinel is inserted
|
break;
|
||||||
// to make the abort visible in the chat history.
|
|
||||||
const loop = detectDoomLoop(args.recentToolCalls);
|
|
||||||
if (loop) {
|
|
||||||
await runDoomLoopSummary(ctx, args, session, project, history, agent, loop);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- steps: 0 edge case ----
|
||||||
|
// effectiveCap check above guarantees we're inside the loop, but this
|
||||||
|
// guard handles the theoretical case where the model emits tool calls
|
||||||
|
// on step 0 when effectiveCap would have been 0 (impossible since the
|
||||||
|
// while condition prevents entry, but kept for safety). If effectiveCap
|
||||||
|
// is 1 and we're on step 0, tool calls ARE executed — steps counts
|
||||||
|
// iterations, not post-first-stream.
|
||||||
|
|
||||||
|
// ---- tool phase ----
|
||||||
|
let toolPhaseResult: ToolPhaseResult;
|
||||||
|
try {
|
||||||
|
toolPhaseResult = await executeToolPhase(ctx, iterArgs, result, state.startedAt, iterSession, projectRoot);
|
||||||
|
} catch (err) {
|
||||||
|
// Tool phase errors are unexpected (individual tool failures are
|
||||||
|
// caught inside executeToolPhase). Log and break.
|
||||||
|
ctx.log.error({ err, sessionId, chatId, step: stepNumber }, 'tool phase threw unexpectedly');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- update loop locals ----
|
||||||
|
toolsUsed += toolPhaseResult.toolCallCount;
|
||||||
|
recentToolCalls = [...recentToolCalls, ...toolPhaseResult.toolCalls];
|
||||||
|
stepNumber++;
|
||||||
|
|
||||||
|
if (toolPhaseResult.action !== 'continue') {
|
||||||
|
// 'paused' (user input) or 'synthesis_done' — stop the loop.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// 'continue' — advance to next assistant message.
|
||||||
|
assistantMessageId = toolPhaseResult.nextAssistantId!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- post-loop: step-cap sentinel ----
|
||||||
|
// When the loop exits because stepNumber reached effectiveCap, the last
|
||||||
|
// iteration's tool phase returned 'continue' with a nextAssistantId that
|
||||||
|
// is still in 'streaming' status (unfilled). Use it for the wrap-up.
|
||||||
|
if (stepNumber >= effectiveCap && effectiveCap < Infinity) {
|
||||||
|
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||||
|
if (loaded) {
|
||||||
|
const capArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, signal };
|
||||||
|
await runStepCapSummary(ctx, capArgs, loaded.session, loaded.project, loaded.history, agent, stepNumber, effectiveCap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1.14.0: special handling for steps: 0 — the model responds text-only.
|
||||||
|
// The while loop never enters (effectiveCap === 0). We stream once with
|
||||||
|
// no tools, finalize, and return. If the model emits tool calls despite
|
||||||
|
// not being offered tools, they're ignored (finalize as text-only).
|
||||||
|
async function runTextOnlyTurn(
|
||||||
|
ctx: InferenceContext,
|
||||||
|
args: TurnArgs,
|
||||||
|
session: Session,
|
||||||
|
project: Project,
|
||||||
|
history: Message[],
|
||||||
|
agent: Agent | null,
|
||||||
|
): Promise<void> {
|
||||||
const messages = await buildMessagesPayload(session, project, history, agent, ctx.log);
|
const messages = await buildMessagesPayload(session, project, history, agent, ctx.log);
|
||||||
|
// Web tools are irrelevant when steps: 0 (no tool execution), but we
|
||||||
// v1.11.8: resolve per-chat web-tools opt-in. Tri-state on the wire:
|
// still need to resolve the flag for executeStreamPhase's signature.
|
||||||
// - session.web_search_enabled = null → inherit project default
|
|
||||||
// - session.web_search_enabled = true/false → explicit
|
|
||||||
// Both web_search and web_fetch are gated by this single flag (the UI
|
|
||||||
// label is "Enable web search and fetch" — same store, both tools).
|
|
||||||
// Default is false unless explicitly opted in, matching the v1.9
|
|
||||||
// plumbing intent ("inert until Batch 8 ships the actual tools").
|
|
||||||
const webToolsEnabled =
|
const webToolsEnabled =
|
||||||
session.web_search_enabled ?? project.default_web_search_enabled ?? false;
|
session.web_search_enabled ?? project.default_web_search_enabled ?? false;
|
||||||
|
|
||||||
@@ -227,8 +343,12 @@ export async function runAssistantTurn(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result.toolCalls.length > 0) {
|
if (result.toolCalls.length > 0) {
|
||||||
await executeToolPhase(ctx, args, result, state.startedAt, session, projectRoot);
|
ctx.log.warn(
|
||||||
return;
|
{ chatId: args.chatId, toolCallCount: result.toolCalls.length },
|
||||||
|
'steps: 0 agent emitted tool calls; ignoring and finalizing as text-only',
|
||||||
|
);
|
||||||
|
// Override: strip tool calls so finalizeCompletion treats it as text-only.
|
||||||
|
result = { ...result, toolCalls: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
await finalizeCompletion(ctx, args, result, state.startedAt, session);
|
await finalizeCompletion(ctx, args, result, state.startedAt, session);
|
||||||
|
|||||||
288
apps/server/src/services/mcp-client.ts
Normal file
288
apps/server/src/services/mcp-client.ts
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
/**
|
||||||
|
* v1.15.0-mcp-multi: multi-server MCP client registry.
|
||||||
|
*
|
||||||
|
* Connects to multiple MCP servers (Streamable HTTP or stdio transport),
|
||||||
|
* discovers tools from each, wraps them as BooCode ToolDefs with a
|
||||||
|
* `<serverName>_<toolName>` name prefix, and routes callTool by prefix.
|
||||||
|
*
|
||||||
|
* Graceful degradation: one failing server doesn't block others.
|
||||||
|
* Read-only invariant: tools with readOnlyHint === false are rejected.
|
||||||
|
*/
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client';
|
||||||
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
import type { McpServerEntry, McpServerConfig } from './mcp-config.js';
|
||||||
|
import type { ToolDef } from './tools.js';
|
||||||
|
|
||||||
|
// ---- Types ----
|
||||||
|
|
||||||
|
interface McpToolAnnotations {
|
||||||
|
readOnlyHint?: boolean;
|
||||||
|
destructiveHint?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface McpToolDef {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
inputSchema: Record<string, unknown>;
|
||||||
|
annotations?: McpToolAnnotations;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerState {
|
||||||
|
client: Client;
|
||||||
|
transport: StreamableHTTPClientTransport | StdioClientTransport;
|
||||||
|
tools: ToolDef<Record<string, unknown>>[];
|
||||||
|
type: 'streamableHttp' | 'stdio';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Module-level state ----
|
||||||
|
|
||||||
|
const servers = new Map<string, ServerState>();
|
||||||
|
// Reverse map: prefixed tool name → server name (built during discovery)
|
||||||
|
const toolToServer = new Map<string, string>();
|
||||||
|
let log: FastifyBaseLogger | null = null;
|
||||||
|
|
||||||
|
const MAX_RESULT_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
// ---- Public API ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to all configured MCP servers, discover tools, and wrap them.
|
||||||
|
* Per-server graceful degradation: a failing server is logged and skipped.
|
||||||
|
*/
|
||||||
|
export async function initialize(
|
||||||
|
entries: McpServerEntry[],
|
||||||
|
logger: FastifyBaseLogger,
|
||||||
|
): Promise<void> {
|
||||||
|
log = logger;
|
||||||
|
|
||||||
|
// Connect servers in parallel — each wrapped in try/catch for isolation
|
||||||
|
await Promise.all(
|
||||||
|
entries.map(async (entry) => {
|
||||||
|
try {
|
||||||
|
await connectServer(entry);
|
||||||
|
} catch (err) {
|
||||||
|
log!.warn(
|
||||||
|
{ err, server: entry.name },
|
||||||
|
`mcp: failed to initialize server "${entry.name}" — its tools will be unavailable`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (servers.size > 0) {
|
||||||
|
const totalTools = Array.from(servers.values()).reduce((n, s) => n + s.tools.length, 0);
|
||||||
|
log.info(
|
||||||
|
{ servers: servers.size, tools: totalTools },
|
||||||
|
'mcp: multi-server initialization complete',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call an MCP tool by its prefixed name. Routes to the correct server
|
||||||
|
* using the toolToServer reverse map.
|
||||||
|
*/
|
||||||
|
export async function callTool(
|
||||||
|
prefixedName: string,
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const serverName = toolToServer.get(prefixedName);
|
||||||
|
if (!serverName) {
|
||||||
|
return { error: true, output: `MCP tool "${prefixedName}" not found in any server` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = servers.get(serverName);
|
||||||
|
if (!state) {
|
||||||
|
return { error: true, output: `MCP server "${serverName}" not available` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip the "<serverName>_" prefix to get the original tool name
|
||||||
|
const originalName = prefixedName.slice(serverName.length + 1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await state.client.callTool({ name: originalName, arguments: args });
|
||||||
|
|
||||||
|
const content = result.content as Array<{ type: string; text?: string; [key: string]: unknown }>;
|
||||||
|
if (!content || content.length === 0) {
|
||||||
|
return '(no output)';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.isError) {
|
||||||
|
const joined = content
|
||||||
|
.map((block) => (block.type === 'text' ? block.text ?? '' : JSON.stringify(block)))
|
||||||
|
.join('\n');
|
||||||
|
return { error: true, output: joined || '(MCP error with no details)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = content.map((block) => {
|
||||||
|
if (block.type === 'text') return block.text ?? '';
|
||||||
|
return JSON.stringify(block);
|
||||||
|
});
|
||||||
|
const joined = parts.join('\n');
|
||||||
|
if (joined.length > MAX_RESULT_BYTES) {
|
||||||
|
log?.warn({ tool: originalName, server: serverName, bytes: joined.length, cap: MAX_RESULT_BYTES }, 'mcp: result truncated');
|
||||||
|
return joined.slice(0, MAX_RESULT_BYTES) + '\n\n[truncated — MCP result exceeded size limit]';
|
||||||
|
}
|
||||||
|
return joined;
|
||||||
|
} catch (err) {
|
||||||
|
log?.warn({ err, tool: originalName, server: serverName }, 'mcp: callTool failed');
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
output: err instanceof Error ? err.message : 'MCP server unreachable',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return all wrapped ToolDefs from all connected servers, flattened. */
|
||||||
|
export function getTools(): ToolDef<Record<string, unknown>>[] {
|
||||||
|
const all: ToolDef<Record<string, unknown>>[] = [];
|
||||||
|
for (const state of servers.values()) {
|
||||||
|
all.push(...state.tools);
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return status of each server (for debug/status endpoints). */
|
||||||
|
export function getMcpServers(): Array<{
|
||||||
|
name: string;
|
||||||
|
type: 'streamableHttp' | 'stdio';
|
||||||
|
toolCount: number;
|
||||||
|
connected: boolean;
|
||||||
|
}> {
|
||||||
|
return Array.from(servers.entries()).map(([name, state]) => ({
|
||||||
|
name,
|
||||||
|
type: state.type,
|
||||||
|
toolCount: state.tools.length,
|
||||||
|
connected: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Graceful shutdown. For stdio servers, the SDK's transport.close() handles
|
||||||
|
* SIGTERM + timeout. For HTTP servers, close the transport.
|
||||||
|
*/
|
||||||
|
export async function shutdown(): Promise<void> {
|
||||||
|
const closePromises: Promise<void>[] = [];
|
||||||
|
for (const [name, state] of servers) {
|
||||||
|
closePromises.push(
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await state.transport.close();
|
||||||
|
log?.info({ server: name }, 'mcp: server transport closed');
|
||||||
|
} catch (err) {
|
||||||
|
log?.warn({ err, server: name }, 'mcp: error closing server transport');
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await Promise.all(closePromises);
|
||||||
|
servers.clear();
|
||||||
|
toolToServer.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Internal helpers ----
|
||||||
|
|
||||||
|
async function connectServer(entry: McpServerEntry): Promise<void> {
|
||||||
|
const { name, config } = entry;
|
||||||
|
|
||||||
|
const client = new Client({ name: 'boocode', version: '1.15.0' });
|
||||||
|
let transport: StreamableHTTPClientTransport | StdioClientTransport;
|
||||||
|
|
||||||
|
if (config.type === 'streamableHttp') {
|
||||||
|
transport = createHttpTransport(config);
|
||||||
|
} else {
|
||||||
|
transport = createStdioTransport(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.connect(transport);
|
||||||
|
|
||||||
|
const result = await client.listTools();
|
||||||
|
const mcpTools = (result.tools ?? []) as McpToolDef[];
|
||||||
|
|
||||||
|
const tools: ToolDef<Record<string, unknown>>[] = [];
|
||||||
|
for (const t of mcpTools) {
|
||||||
|
if (t.annotations?.readOnlyHint === false) {
|
||||||
|
log!.info({ tool: t.name, server: name }, 'mcp: skipping non-read-only tool');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const wrapped = wrapMcpTool(name, t);
|
||||||
|
tools.push(wrapped);
|
||||||
|
toolToServer.set(wrapped.name, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
servers.set(name, { client, transport, tools, type: config.type });
|
||||||
|
|
||||||
|
log!.info(
|
||||||
|
{ server: name, type: config.type, count: tools.length, names: tools.map((t) => t.name) },
|
||||||
|
'mcp: server initialized',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHttpTransport(config: Extract<McpServerConfig, { type: 'streamableHttp' }>): StreamableHTTPClientTransport {
|
||||||
|
const requestInit: RequestInit = {};
|
||||||
|
if (config.headers && Object.keys(config.headers).length > 0) {
|
||||||
|
requestInit.headers = config.headers;
|
||||||
|
}
|
||||||
|
return new StreamableHTTPClientTransport(new URL(config.url), { requestInit });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStdioTransport(config: Extract<McpServerConfig, { type: 'stdio' }>): StdioClientTransport {
|
||||||
|
return new StdioClientTransport({
|
||||||
|
command: config.command,
|
||||||
|
args: config.args,
|
||||||
|
env: config.env,
|
||||||
|
stderr: 'pipe',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrap an MCP tool as a BooCode ToolDef with a server-name prefix. */
|
||||||
|
export function wrapMcpTool(
|
||||||
|
serverName: string,
|
||||||
|
mcpTool: McpToolDef,
|
||||||
|
): ToolDef<Record<string, unknown>> {
|
||||||
|
const prefixedName = `${serverName}_${mcpTool.name}`;
|
||||||
|
return {
|
||||||
|
name: prefixedName,
|
||||||
|
description: mcpTool.description ?? '',
|
||||||
|
inputSchema: z.record(z.unknown()),
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function' as const,
|
||||||
|
function: {
|
||||||
|
name: prefixedName,
|
||||||
|
description: mcpTool.description ?? '',
|
||||||
|
parameters: mcpTool.inputSchema ?? { type: 'object', properties: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
execute: async (input) => {
|
||||||
|
return callTool(prefixedName, input);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exposed for unit tests — extract content from an MCP result. */
|
||||||
|
export function extractContent(
|
||||||
|
content: Array<{ type: string; text?: string; [key: string]: unknown }> | undefined,
|
||||||
|
isError?: boolean,
|
||||||
|
): unknown {
|
||||||
|
if (!content || content.length === 0) return '(no output)';
|
||||||
|
|
||||||
|
const parts = content.map((block) => {
|
||||||
|
if (block.type === 'text') return block.text ?? '';
|
||||||
|
return JSON.stringify(block);
|
||||||
|
});
|
||||||
|
const joined = parts.join('\n');
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return { error: true, output: joined || '(MCP error with no details)' };
|
||||||
|
}
|
||||||
|
return joined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exposed for unit tests — the read-only guard predicate. */
|
||||||
|
export function isToolReadOnly(annotations?: McpToolAnnotations): boolean {
|
||||||
|
return annotations?.readOnlyHint !== false;
|
||||||
|
}
|
||||||
78
apps/server/src/services/mcp-config.ts
Normal file
78
apps/server/src/services/mcp-config.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* v1.15.0-mcp-multi: MCP config file schema + loader.
|
||||||
|
*
|
||||||
|
* Reads a JSON config file (default `/data/mcp.json`) that declares MCP
|
||||||
|
* servers — their transport type, connection parameters, and enabled state.
|
||||||
|
* Schema shape matches opencode's `mcpServers` key for copy-paste compat.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
|
||||||
|
// ---- Zod schema ----
|
||||||
|
|
||||||
|
const McpServerConfigSchema = z.discriminatedUnion('type', [
|
||||||
|
z.object({
|
||||||
|
type: z.literal('streamableHttp'),
|
||||||
|
url: z.string().url(),
|
||||||
|
headers: z.record(z.string()).optional(),
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('stdio'),
|
||||||
|
command: z.string().min(1),
|
||||||
|
args: z.array(z.string()).default([]),
|
||||||
|
env: z.record(z.string()).optional(),
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const McpConfigSchema = z.object({
|
||||||
|
mcpServers: z.record(z.string(), McpServerConfigSchema).default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type McpServerConfig = z.infer<typeof McpServerConfigSchema>;
|
||||||
|
|
||||||
|
export interface McpServerEntry {
|
||||||
|
name: string;
|
||||||
|
config: McpServerConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Loader ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and validate the MCP config file. Returns enabled servers only.
|
||||||
|
* File missing → log info, return []. Parse/validation error → log warn, return [].
|
||||||
|
*/
|
||||||
|
export function loadMcpConfig(configPath: string, log: FastifyBaseLogger): McpServerEntry[] {
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = readFileSync(configPath, 'utf8');
|
||||||
|
} catch {
|
||||||
|
log.info(`mcp: config not found at ${configPath}, skipping`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let json: unknown;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
log.warn({ err }, `mcp: failed to parse ${configPath} as JSON`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = McpConfigSchema.safeParse(json);
|
||||||
|
if (!result.success) {
|
||||||
|
log.warn({ errors: result.error.flatten().fieldErrors }, `mcp: invalid config at ${configPath}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries: McpServerEntry[] = [];
|
||||||
|
for (const [name, config] of Object.entries(result.data.mcpServers)) {
|
||||||
|
if (config.enabled) {
|
||||||
|
entries.push({ name, config });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
@@ -16,9 +16,22 @@ export async function resolveProjectRoot(projectPath: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isUnder(real: string, root: string): boolean {
|
||||||
|
return real === root || real.startsWith(root + sep);
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1.13.17-cross-repo-reads: pathGuard now accepts an optional extraRoots
|
||||||
|
// list (typically session.allowed_read_paths). The primary projectRoot is
|
||||||
|
// tried first; if the resolved path doesn't sit under it, each extraRoot is
|
||||||
|
// tried in turn. Throws PathScopeError if no root accepts. The error message
|
||||||
|
// includes a hint pointing the model at the request_read_access tool so it
|
||||||
|
// can self-correct on the next turn — extraRoots IS the persistence
|
||||||
|
// mechanism for those grants, so we only suggest it when there's a missing
|
||||||
|
// grant to ask for (i.e. the path isn't already under any allowed root).
|
||||||
export async function pathGuard(
|
export async function pathGuard(
|
||||||
projectRoot: string,
|
projectRoot: string,
|
||||||
requested: string
|
requested: string,
|
||||||
|
extraRoots: readonly string[] = [],
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (typeof requested !== 'string' || requested.length === 0) {
|
if (typeof requested !== 'string' || requested.length === 0) {
|
||||||
throw new PathScopeError('path is required');
|
throw new PathScopeError('path is required');
|
||||||
@@ -30,10 +43,13 @@ export async function pathGuard(
|
|||||||
} catch {
|
} catch {
|
||||||
throw new PathScopeError(`path does not exist: ${requested}`);
|
throw new PathScopeError(`path does not exist: ${requested}`);
|
||||||
}
|
}
|
||||||
if (real !== projectRoot && !real.startsWith(projectRoot + sep)) {
|
if (isUnder(real, projectRoot)) return real;
|
||||||
|
for (const extra of extraRoots) {
|
||||||
|
if (extra.length === 0) continue;
|
||||||
|
if (isUnder(real, extra)) return real;
|
||||||
|
}
|
||||||
throw new PathScopeError(
|
throw new PathScopeError(
|
||||||
`path escapes project root: ${requested} -> ${real}`
|
`path escapes project root: ${requested} -> ${real}. ` +
|
||||||
|
`Use request_read_access(path, reason) to ask the user for permission.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return real;
|
|
||||||
}
|
|
||||||
|
|||||||
82
apps/server/src/services/request_read_access.ts
Normal file
82
apps/server/src/services/request_read_access.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
// v1.13.17-cross-repo-reads: tool the model uses to request read access to
|
||||||
|
// a path outside its session's primary project root. When the model emits
|
||||||
|
// view_file("/opt/forks/foo/go.mod") under a session scoped to /opt/boocode,
|
||||||
|
// pathGuard's error message hints at this tool. The model then emits
|
||||||
|
// request_read_access(path="/opt/forks/foo/go.mod",
|
||||||
|
// reason="investigating foo to write the design doc")
|
||||||
|
// The tool's execute does cheap up-front validation: if the requested path
|
||||||
|
// can't possibly be granted under the current whitelist + repo-shape rules,
|
||||||
|
// it returns a denial immediately without prompting the user. Otherwise, the
|
||||||
|
// tool-phase pause branch (parallel of ask_user_input) stores a pending
|
||||||
|
// sentinel and waits for the user's allow/deny via the grant_read_access
|
||||||
|
// endpoint.
|
||||||
|
//
|
||||||
|
// The execute body never directly mutates state; the grant endpoint owns
|
||||||
|
// the persistence path. This keeps the tool-side logic side-effect-free
|
||||||
|
// (it's just a request) and matches ask_user_input's "server-side no-op
|
||||||
|
// fallback, pause happens in tool-phase" shape.
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef } from './tools.js';
|
||||||
|
|
||||||
|
const RequestReadAccessInput = z.object({
|
||||||
|
path: z.string().min(1),
|
||||||
|
reason: z.string().min(1).max(500),
|
||||||
|
});
|
||||||
|
type RequestReadAccessInputT = z.infer<typeof RequestReadAccessInput>;
|
||||||
|
|
||||||
|
export const requestReadAccess: ToolDef<RequestReadAccessInputT> = {
|
||||||
|
name: 'request_read_access',
|
||||||
|
description:
|
||||||
|
"Ask the user for read-only access to a path outside the current " +
|
||||||
|
"session's project scope. Use when a previous read tool (view_file, " +
|
||||||
|
'list_dir, grep, find_files) was refused with a path-escapes-project ' +
|
||||||
|
'error and the path is plausibly under another known repository (e.g. ' +
|
||||||
|
'/opt/forks/foo). Provide a short reason describing why you need the ' +
|
||||||
|
"access. Pauses the conversation until the user picks Allow or Deny; " +
|
||||||
|
'the next assistant turn sees the result. On Allow, the tool result ' +
|
||||||
|
'is "granted: <root>" — subsequent reads under that root succeed for ' +
|
||||||
|
'the rest of the session. On Deny, the tool result is "denied". Do ' +
|
||||||
|
'not call this for paths that are already inside the project root.',
|
||||||
|
inputSchema: RequestReadAccessInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'request_read_access',
|
||||||
|
description:
|
||||||
|
"Ask the user for read-only access to a path outside the session's " +
|
||||||
|
'project scope. Pauses the conversation until the user picks Allow ' +
|
||||||
|
'or Deny. Subsequent reads under the granted root succeed for the ' +
|
||||||
|
'rest of the session.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
path: {
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'Absolute path the model wants to read. Must be under the ' +
|
||||||
|
"server's PROJECT_ROOT_WHITELIST (default /opt) and outside " +
|
||||||
|
"the session's primary project root.",
|
||||||
|
},
|
||||||
|
reason: {
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'Short rationale (<=500 chars) shown to the user explaining ' +
|
||||||
|
'why the access is needed. The user uses this to decide.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['path', 'reason'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Server-side no-op. The "execution" of request_read_access is the
|
||||||
|
// pause-and-resume cycle managed by tool-phase.ts + the grant endpoint.
|
||||||
|
// The inference loop catches this tool name BEFORE executeToolCall fires
|
||||||
|
// and inserts a pending sentinel instead — this fallback only runs if
|
||||||
|
// something bypasses that branch, in which case we surface the pending
|
||||||
|
// shape so downstream code can still detect it. Mirrors ask_user_input.
|
||||||
|
async execute(input) {
|
||||||
|
return { _pending: true, path: input.path, reason: input.reason };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -21,7 +21,15 @@ import {
|
|||||||
watchChanges,
|
watchChanges,
|
||||||
getSemanticNeighborhoods,
|
getSemanticNeighborhoods,
|
||||||
getFrameworkAnalysis,
|
getFrameworkAnalysis,
|
||||||
|
getBlastRadius,
|
||||||
|
getHotFiles,
|
||||||
|
getRoutes,
|
||||||
|
getMiddleware,
|
||||||
} from './tools/codecontext/index.js';
|
} from './tools/codecontext/index.js';
|
||||||
|
// v1.13.17-cross-repo-reads: cross-repo read grant request tool. Paired
|
||||||
|
// with the pause-on-pending-grant branch in inference/tool-phase.ts and the
|
||||||
|
// POST /api/chats/:id/grant_read_access endpoint in routes/messages.ts.
|
||||||
|
import { requestReadAccess } from './request_read_access.js';
|
||||||
|
|
||||||
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||||
const DEFAULT_VIEW_LINES = 200;
|
const DEFAULT_VIEW_LINES = 200;
|
||||||
@@ -45,7 +53,13 @@ export interface ToolDef<TInput> {
|
|||||||
description: string;
|
description: string;
|
||||||
inputSchema: z.ZodType<TInput>;
|
inputSchema: z.ZodType<TInput>;
|
||||||
jsonSchema: ToolJsonSchema;
|
jsonSchema: ToolJsonSchema;
|
||||||
execute(input: TInput, projectRoot: string): Promise<unknown>;
|
// v1.13.17-cross-repo-reads: extraRoots is the session's
|
||||||
|
// allowed_read_paths, threaded through executeToolCall in tool-phase.ts.
|
||||||
|
// Only the filesystem tools (view_file, list_dir, grep, find_files,
|
||||||
|
// view_truncated_output) forward it to pathGuard; other tools accept the
|
||||||
|
// arg and ignore it. The execute signature stays compatible with
|
||||||
|
// pre-v1.13.17 callsites because the parameter is optional.
|
||||||
|
execute(input: TInput, projectRoot: string, extraRoots?: readonly string[]): Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ViewFileInput = z.object({
|
const ViewFileInput = z.object({
|
||||||
@@ -78,14 +92,19 @@ export const viewFile: ToolDef<ViewFileInputT> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async execute(input, projectRoot) {
|
async execute(input, projectRoot, extraRoots) {
|
||||||
const real = await pathGuard(projectRoot, input.path);
|
const real = await pathGuard(projectRoot, input.path, extraRoots);
|
||||||
// v1.11.7: secret-file deny check. Test the project-relative path
|
// v1.11.7: secret-file deny check. Test the project-relative path
|
||||||
// (matches the form continue.dev's patterns expect: basenames + dir
|
// (matches the form continue.dev's patterns expect: basenames + dir
|
||||||
// segments). Throw a typed error so executeToolCall in inference.ts
|
// segments). Throw a typed error so executeToolCall in inference.ts
|
||||||
// surfaces a clear "blocked" message to the LLM instead of silently
|
// surfaces a clear "blocked" message to the LLM instead of silently
|
||||||
// returning content the user wanted hidden.
|
// returning content the user wanted hidden.
|
||||||
const relPath = relative(projectRoot, real) || basename(real);
|
// v1.13.17: when the resolved path is outside the primary projectRoot
|
||||||
|
// (i.e. via an allowed_read_paths grant), `relative()` returns "../…"
|
||||||
|
// which won't match secret-file basename patterns. Re-anchor on the
|
||||||
|
// file's basename so the secret deny still fires across all grant roots.
|
||||||
|
const rel = relative(projectRoot, real);
|
||||||
|
const relPath = rel && !rel.startsWith('..') ? rel : basename(real);
|
||||||
if (isSecretPath(relPath)) {
|
if (isSecretPath(relPath)) {
|
||||||
throw new SecretBlockedError(relPath);
|
throw new SecretBlockedError(relPath);
|
||||||
}
|
}
|
||||||
@@ -157,8 +176,8 @@ export const listDir: ToolDef<ListDirInputT> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async execute(input, projectRoot) {
|
async execute(input, projectRoot, extraRoots) {
|
||||||
const real = await pathGuard(projectRoot, input.path);
|
const real = await pathGuard(projectRoot, input.path, extraRoots);
|
||||||
const s = await stat(real);
|
const s = await stat(real);
|
||||||
if (!s.isDirectory()) {
|
if (!s.isDirectory()) {
|
||||||
throw new PathScopeError(`not a directory: ${input.path}`);
|
throw new PathScopeError(`not a directory: ${input.path}`);
|
||||||
@@ -264,7 +283,7 @@ export const grep: ToolDef<GrepInputT> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async execute(input, projectRoot) {
|
async execute(input, projectRoot, extraRoots) {
|
||||||
const limit = Math.min(
|
const limit = Math.min(
|
||||||
Math.max(input.max_results ?? DEFAULT_GREP_RESULTS, 1),
|
Math.max(input.max_results ?? DEFAULT_GREP_RESULTS, 1),
|
||||||
MAX_GREP_RESULTS
|
MAX_GREP_RESULTS
|
||||||
@@ -276,6 +295,7 @@ export const grep: ToolDef<GrepInputT> = {
|
|||||||
max_matches: limit,
|
max_matches: limit,
|
||||||
case_sensitive: input.case_sensitive,
|
case_sensitive: input.case_sensitive,
|
||||||
hidden: input.hidden,
|
hidden: input.hidden,
|
||||||
|
extra_roots: extraRoots,
|
||||||
});
|
});
|
||||||
const reshaped = result.matches.map((m) => ({
|
const reshaped = result.matches.map((m) => ({
|
||||||
path: m.path,
|
path: m.path,
|
||||||
@@ -325,7 +345,7 @@ export const findFiles: ToolDef<FindFilesInputT> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async execute(input, projectRoot) {
|
async execute(input, projectRoot, extraRoots) {
|
||||||
const limit = Math.min(
|
const limit = Math.min(
|
||||||
Math.max(input.max_results ?? DEFAULT_FIND_RESULTS, 1),
|
Math.max(input.max_results ?? DEFAULT_FIND_RESULTS, 1),
|
||||||
MAX_FIND_RESULTS
|
MAX_FIND_RESULTS
|
||||||
@@ -335,6 +355,7 @@ export const findFiles: ToolDef<FindFilesInputT> = {
|
|||||||
const result = await fileOpsFindFiles(projectRoot, input.pattern, {
|
const result = await fileOpsFindFiles(projectRoot, input.pattern, {
|
||||||
path: input.path,
|
path: input.path,
|
||||||
max_results: limit,
|
max_results: limit,
|
||||||
|
extra_roots: extraRoots,
|
||||||
});
|
});
|
||||||
// v1.11.7: drop paths matching secret patterns. The original `total`
|
// v1.11.7: drop paths matching secret patterns. The original `total`
|
||||||
// from file_ops includes pre-truncation count; we report the visible
|
// from file_ops includes pre-truncation count; we report the visible
|
||||||
@@ -383,7 +404,10 @@ export const viewTruncatedOutput: ToolDef<ViewTruncatedOutputInputT> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async execute(input, _projectRoot) {
|
// view_truncated_output doesn't touch the filesystem — it pulls from tmpfs
|
||||||
|
// by opaque id. extraRoots is irrelevant here; declared for signature parity
|
||||||
|
// with the v1.13.17 ToolDef contract.
|
||||||
|
async execute(input, _projectRoot, _extraRoots) {
|
||||||
const content = await readTruncation(input.id);
|
const content = await readTruncation(input.id);
|
||||||
if (content === null) {
|
if (content === null) {
|
||||||
return {
|
return {
|
||||||
@@ -631,7 +655,9 @@ export const askUserInput: ToolDef<AskUserInputInputT> = {
|
|||||||
// of the system prompt, so any order drift would invalidate every cached
|
// of the system prompt, so any order drift would invalidate every cached
|
||||||
// turn. Single source of truth for ordering lives here — toolJsonSchemas()
|
// turn. Single source of truth for ordering lives here — toolJsonSchemas()
|
||||||
// and TOOLS_BY_NAME inherit it.
|
// and TOOLS_BY_NAME inherit it.
|
||||||
export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
|
// v1.14.1-mcp-poc: changed from ReadonlyArray to let-bound mutable array
|
||||||
|
// so appendMcpTools() can push MCP-discovered tools at startup.
|
||||||
|
export let ALL_TOOLS: ToolDef<unknown>[] = [
|
||||||
viewFile as ToolDef<unknown>,
|
viewFile as ToolDef<unknown>,
|
||||||
viewTruncatedOutput as ToolDef<unknown>,
|
viewTruncatedOutput as ToolDef<unknown>,
|
||||||
listDir as ToolDef<unknown>,
|
listDir as ToolDef<unknown>,
|
||||||
@@ -658,6 +684,16 @@ export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
|
|||||||
watchChanges as ToolDef<unknown>,
|
watchChanges as ToolDef<unknown>,
|
||||||
getSemanticNeighborhoods as ToolDef<unknown>,
|
getSemanticNeighborhoods as ToolDef<unknown>,
|
||||||
getFrameworkAnalysis as ToolDef<unknown>,
|
getFrameworkAnalysis as ToolDef<unknown>,
|
||||||
|
// v1.16: codesight-merge tools. Backed by the same codecontext sidecar.
|
||||||
|
getBlastRadius as ToolDef<unknown>,
|
||||||
|
getHotFiles as ToolDef<unknown>,
|
||||||
|
getRoutes as ToolDef<unknown>,
|
||||||
|
getMiddleware as ToolDef<unknown>,
|
||||||
|
// v1.13.17-cross-repo-reads: paired with the pause-on-pending-grant
|
||||||
|
// branch in tool-phase.ts. Read-only — only ever READS files; the only
|
||||||
|
// state change is appending to sessions.allowed_read_paths via the
|
||||||
|
// grant endpoint, gated by user consent.
|
||||||
|
requestReadAccess as ToolDef<unknown>,
|
||||||
].sort((a, b) => a.name.localeCompare(b.name));
|
].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
|
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
|
||||||
@@ -694,12 +730,29 @@ export const READ_ONLY_TOOL_NAMES = [
|
|||||||
'watch_changes',
|
'watch_changes',
|
||||||
'get_semantic_neighborhoods',
|
'get_semantic_neighborhoods',
|
||||||
'get_framework_analysis',
|
'get_framework_analysis',
|
||||||
|
// v1.13.17-cross-repo-reads: pauses execution but doesn't mutate project
|
||||||
|
// state directly (the grant endpoint appends to sessions.allowed_read_paths
|
||||||
|
// only with user consent). Belongs in the read-only budget tier.
|
||||||
|
'request_read_access',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
|
export let TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
|
||||||
ALL_TOOLS.map((t) => [t.name, t])
|
ALL_TOOLS.map((t) => [t.name, t])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// v1.14.1-mcp-poc: append MCP-discovered tools at startup. Called once
|
||||||
|
// from index.ts after mcpClient.initialize(). Re-sorts ALL_TOOLS and
|
||||||
|
// rebuilds TOOLS_BY_NAME. READ_ONLY_TOOL_NAMES is not rebuilt because
|
||||||
|
// it's a const tuple used only for budget-tier checks; MCP tools are
|
||||||
|
// individually checked via their category at budget resolution time —
|
||||||
|
// they are all read_only by construction (the read-only guard in
|
||||||
|
// mcp-client.ts rejects any tool with readOnlyHint: false).
|
||||||
|
export function appendMcpTools(mcpTools: ToolDef<unknown>[]): void {
|
||||||
|
if (mcpTools.length === 0) return;
|
||||||
|
ALL_TOOLS = [...ALL_TOOLS, ...mcpTools].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
TOOLS_BY_NAME = Object.fromEntries(ALL_TOOLS.map((t) => [t.name, t]));
|
||||||
|
}
|
||||||
|
|
||||||
// v1.13.15-tools: tiered tool loading. BOOCODE_TOOLS env var (`core` |
|
// v1.13.15-tools: tiered tool loading. BOOCODE_TOOLS env var (`core` |
|
||||||
// `standard` | `all`) filters the agent's tool whitelist before LLM dispatch.
|
// `standard` | `all`) filters the agent's tool whitelist before LLM dispatch.
|
||||||
// Daily-driver token win on qwen3.6-35b-a3b — the 35B-A3B MoE benefits from
|
// Daily-driver token win on qwen3.6-35b-a3b — the 35B-A3B MoE benefits from
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef } from '../../tools.js';
|
||||||
|
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||||
|
|
||||||
|
export const GetBlastRadiusInput = z.object({
|
||||||
|
file_path: z.string().trim().min(1),
|
||||||
|
});
|
||||||
|
export type GetBlastRadiusInputT = z.infer<typeof GetBlastRadiusInput>;
|
||||||
|
|
||||||
|
const DESCRIPTION =
|
||||||
|
'Returns all files that depend (transitively) on the given file, with depth tracking. ' +
|
||||||
|
'Use to assess the impact of changing a file — "what breaks if I modify this?" ' +
|
||||||
|
'Traverses the import graph in reverse via BFS. Results sorted by distance (closest dependents first).';
|
||||||
|
|
||||||
|
export async function executeGetBlastRadius(
|
||||||
|
input: GetBlastRadiusInputT,
|
||||||
|
projectPath: string,
|
||||||
|
fetcher: typeof fetch = fetch,
|
||||||
|
): Promise<CodecontextResponse> {
|
||||||
|
return callCodecontext(
|
||||||
|
{ toolName: 'get_blast_radius', args: { file_path: input.file_path }, projectPath },
|
||||||
|
fetcher,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getBlastRadius: ToolDef<GetBlastRadiusInputT> = {
|
||||||
|
name: 'get_blast_radius',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
inputSchema: GetBlastRadiusInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'get_blast_radius',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
file_path: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Absolute or project-relative path to the file to analyze.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['file_path'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input, projectRoot) {
|
||||||
|
return await executeGetBlastRadius(input, projectRoot);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -5,7 +5,7 @@ import type { ToolDef } from '../../tools.js';
|
|||||||
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||||
|
|
||||||
export const GetDependenciesInput = z.object({
|
export const GetDependenciesInput = z.object({
|
||||||
file_path: z.string().optional(),
|
file_path: z.string().trim().optional(),
|
||||||
direction: z.enum(['incoming', 'outgoing', 'both']).optional(),
|
direction: z.enum(['incoming', 'outgoing', 'both']).optional(),
|
||||||
});
|
});
|
||||||
export type GetDependenciesInputT = z.infer<typeof GetDependenciesInput>;
|
export type GetDependenciesInputT = z.infer<typeof GetDependenciesInput>;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { ToolDef } from '../../tools.js';
|
|||||||
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||||
|
|
||||||
export const GetFileAnalysisInput = z.object({
|
export const GetFileAnalysisInput = z.object({
|
||||||
file_path: z.string().min(1),
|
file_path: z.string().trim().min(1),
|
||||||
});
|
});
|
||||||
export type GetFileAnalysisInputT = z.infer<typeof GetFileAnalysisInput>;
|
export type GetFileAnalysisInputT = z.infer<typeof GetFileAnalysisInput>;
|
||||||
|
|
||||||
|
|||||||
50
apps/server/src/services/tools/codecontext/get_hot_files.ts
Normal file
50
apps/server/src/services/tools/codecontext/get_hot_files.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef } from '../../tools.js';
|
||||||
|
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||||
|
|
||||||
|
export const GetHotFilesInput = z.object({
|
||||||
|
limit: z.number().int().min(1).max(100).optional(),
|
||||||
|
});
|
||||||
|
export type GetHotFilesInputT = z.infer<typeof GetHotFilesInput>;
|
||||||
|
|
||||||
|
const DESCRIPTION =
|
||||||
|
'Returns the most-imported files in the project, ranked by incoming import count. ' +
|
||||||
|
'Hot files are high-risk change targets — many other files depend on them. ' +
|
||||||
|
'Use to identify core modules and assess refactoring risk.';
|
||||||
|
|
||||||
|
export async function executeGetHotFiles(
|
||||||
|
input: GetHotFilesInputT,
|
||||||
|
projectPath: string,
|
||||||
|
fetcher: typeof fetch = fetch,
|
||||||
|
): Promise<CodecontextResponse> {
|
||||||
|
return callCodecontext(
|
||||||
|
{ toolName: 'get_hot_files', args: input.limit != null ? { limit: input.limit } : {}, projectPath },
|
||||||
|
fetcher,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getHotFiles: ToolDef<GetHotFilesInputT> = {
|
||||||
|
name: 'get_hot_files',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
inputSchema: GetHotFilesInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'get_hot_files',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
limit: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Maximum number of files to return (default 20, max 100).',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input, projectRoot) {
|
||||||
|
return await executeGetHotFiles(input, projectRoot);
|
||||||
|
},
|
||||||
|
};
|
||||||
41
apps/server/src/services/tools/codecontext/get_middleware.ts
Normal file
41
apps/server/src/services/tools/codecontext/get_middleware.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef } from '../../tools.js';
|
||||||
|
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||||
|
|
||||||
|
export const GetMiddlewareInput = z.object({});
|
||||||
|
export type GetMiddlewareInputT = z.infer<typeof GetMiddlewareInput>;
|
||||||
|
|
||||||
|
const DESCRIPTION =
|
||||||
|
'Detects middleware registrations in the project. Identifies auth, CORS, rate-limit, ' +
|
||||||
|
'security-headers, error-handler, logging, and validation middleware by analyzing ' +
|
||||||
|
'import names (@fastify/cors, helmet, etc.) and registration patterns ' +
|
||||||
|
'(app.register, app.addHook, app.setErrorHandler).';
|
||||||
|
|
||||||
|
export async function executeGetMiddleware(
|
||||||
|
_input: GetMiddlewareInputT,
|
||||||
|
projectPath: string,
|
||||||
|
fetcher: typeof fetch = fetch,
|
||||||
|
): Promise<CodecontextResponse> {
|
||||||
|
return callCodecontext({ toolName: 'get_middleware', args: {}, projectPath }, fetcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMiddleware: ToolDef<GetMiddlewareInputT> = {
|
||||||
|
name: 'get_middleware',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
inputSchema: GetMiddlewareInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'get_middleware',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input, projectRoot) {
|
||||||
|
return await executeGetMiddleware(input, projectRoot);
|
||||||
|
},
|
||||||
|
};
|
||||||
50
apps/server/src/services/tools/codecontext/get_routes.ts
Normal file
50
apps/server/src/services/tools/codecontext/get_routes.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef } from '../../tools.js';
|
||||||
|
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||||
|
|
||||||
|
export const GetRoutesInput = z.object({
|
||||||
|
framework: z.string().trim().optional(),
|
||||||
|
});
|
||||||
|
export type GetRoutesInputT = z.infer<typeof GetRoutesInput>;
|
||||||
|
|
||||||
|
const DESCRIPTION =
|
||||||
|
'Extracts HTTP routes from the project via tree-sitter AST analysis. ' +
|
||||||
|
'Detects Fastify and Express route registrations (app.get, app.post, app.route, router.use, etc.) ' +
|
||||||
|
'with method, path, file, line number, and inferred tags (db, auth, cache). ' +
|
||||||
|
'Optional framework filter narrows to "fastify" or "express".';
|
||||||
|
|
||||||
|
export async function executeGetRoutes(
|
||||||
|
input: GetRoutesInputT,
|
||||||
|
projectPath: string,
|
||||||
|
fetcher: typeof fetch = fetch,
|
||||||
|
): Promise<CodecontextResponse> {
|
||||||
|
const args: Record<string, unknown> = {};
|
||||||
|
if (input.framework) args.framework = input.framework;
|
||||||
|
return callCodecontext({ toolName: 'get_routes', args, projectPath }, fetcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getRoutes: ToolDef<GetRoutesInputT> = {
|
||||||
|
name: 'get_routes',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
inputSchema: GetRoutesInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'get_routes',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
framework: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter to a specific framework: "fastify" or "express". Omit for all.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input, projectRoot) {
|
||||||
|
return await executeGetRoutes(input, projectRoot);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -5,7 +5,7 @@ import type { ToolDef } from '../../tools.js';
|
|||||||
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||||
|
|
||||||
export const GetSemanticNeighborhoodsInput = z.object({
|
export const GetSemanticNeighborhoodsInput = z.object({
|
||||||
file_path: z.string().optional(),
|
file_path: z.string().trim().optional(),
|
||||||
include_basic: z.boolean().optional(),
|
include_basic: z.boolean().optional(),
|
||||||
include_quality: z.boolean().optional(),
|
include_quality: z.boolean().optional(),
|
||||||
max_results: z.number().int().positive().optional(),
|
max_results: z.number().int().positive().optional(),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { callCodecontext, type CodecontextResponse } from '../../codecontext_cli
|
|||||||
|
|
||||||
export const GetSymbolInfoInput = z.object({
|
export const GetSymbolInfoInput = z.object({
|
||||||
symbol_name: z.string().min(1),
|
symbol_name: z.string().min(1),
|
||||||
file_path: z.string().optional(),
|
file_path: z.string().trim().optional(),
|
||||||
framework_type: z.string().optional(),
|
framework_type: z.string().optional(),
|
||||||
});
|
});
|
||||||
export type GetSymbolInfoInputT = z.infer<typeof GetSymbolInfoInput>;
|
export type GetSymbolInfoInputT = z.infer<typeof GetSymbolInfoInput>;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// v1.12 Track B.2: codecontext tool registry. Re-exports the 8 ToolDefs so
|
// codecontext tool registry. Re-exports ToolDefs so tools.ts can pull them
|
||||||
// tools.ts can pull them in one line.
|
// in one line. v1.12: 8 original tools. v1.16: +4 codesight-merge tools.
|
||||||
|
|
||||||
export { getCodebaseOverview } from './get_codebase_overview.js';
|
export { getCodebaseOverview } from './get_codebase_overview.js';
|
||||||
export { getFileAnalysis } from './get_file_analysis.js';
|
export { getFileAnalysis } from './get_file_analysis.js';
|
||||||
@@ -9,3 +9,7 @@ export { getDependencies } from './get_dependencies.js';
|
|||||||
export { watchChanges } from './watch_changes.js';
|
export { watchChanges } from './watch_changes.js';
|
||||||
export { getSemanticNeighborhoods } from './get_semantic_neighborhoods.js';
|
export { getSemanticNeighborhoods } from './get_semantic_neighborhoods.js';
|
||||||
export { getFrameworkAnalysis } from './get_framework_analysis.js';
|
export { getFrameworkAnalysis } from './get_framework_analysis.js';
|
||||||
|
export { getBlastRadius } from './get_blast_radius.js';
|
||||||
|
export { getHotFiles } from './get_hot_files.js';
|
||||||
|
export { getRoutes } from './get_routes.js';
|
||||||
|
export { getMiddleware } from './get_middleware.js';
|
||||||
|
|||||||
@@ -42,9 +42,40 @@ export interface Session {
|
|||||||
// v1.12.1: server-side workspace pane layout. Replaces per-device
|
// v1.12.1: server-side workspace pane layout. Replaces per-device
|
||||||
// localStorage so all devices viewing the session see the same panes.
|
// localStorage so all devices viewing the session see the same panes.
|
||||||
workspace_panes: WorkspacePane[];
|
workspace_panes: WorkspacePane[];
|
||||||
|
// v1.13.17: absolute paths the agent has been granted read access to via
|
||||||
|
// the request_read_access tool. Empty by default; populated only by the
|
||||||
|
// grant_read_access endpoint's allow branch. Revoked via PATCH session.
|
||||||
|
// path_guard's extraRoots check consults this list before refusing reads
|
||||||
|
// outside the primary project root.
|
||||||
|
allowed_read_paths: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkspacePaneKind = 'chat' | 'terminal' | 'agent' | 'empty' | 'settings';
|
// v1.14.x-html-artifact-panes: 'markdown_artifact' + 'html_artifact' added.
|
||||||
|
// Optional payload state lives on the pane row itself so the jsonb survives
|
||||||
|
// a hard reload without needing a re-fetch.
|
||||||
|
export type WorkspacePaneKind =
|
||||||
|
| 'chat'
|
||||||
|
| 'terminal'
|
||||||
|
| 'agent'
|
||||||
|
| 'empty'
|
||||||
|
| 'settings'
|
||||||
|
| 'markdown_artifact'
|
||||||
|
| 'html_artifact';
|
||||||
|
|
||||||
|
// v1.14.x: reference-only — the actual artifact body lives in the message
|
||||||
|
// row (markdown) or message_parts.payload (html_artifact). Pane components
|
||||||
|
// fetch on mount.
|
||||||
|
export interface MarkdownArtifactState {
|
||||||
|
chat_id: string;
|
||||||
|
message_id: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HtmlArtifactState {
|
||||||
|
chat_id: string;
|
||||||
|
message_id: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkspacePane {
|
export interface WorkspacePane {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -52,6 +83,9 @@ export interface WorkspacePane {
|
|||||||
chatId?: string;
|
chatId?: string;
|
||||||
chatIds: string[];
|
chatIds: string[];
|
||||||
activeChatIdx: number;
|
activeChatIdx: number;
|
||||||
|
// v1.14.x: populated only when kind === 'markdown_artifact' / 'html_artifact'.
|
||||||
|
markdown_artifact_state?: MarkdownArtifactState;
|
||||||
|
html_artifact_state?: HtmlArtifactState;
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.8.1: agents come from two sources. 'global' = /data/AGENTS.md (always
|
// v1.8.1: agents come from two sources. 'global' = /data/AGENTS.md (always
|
||||||
@@ -72,6 +106,9 @@ export interface Agent {
|
|||||||
// agent's toolset (30 if all tools are read-only, 10 otherwise) or 15 for
|
// agent's toolset (30 if all tools are read-only, 10 otherwise) or 15 for
|
||||||
// raw chat with no agent.
|
// raw chat with no agent.
|
||||||
max_tool_calls: number | null;
|
max_tool_calls: number | null;
|
||||||
|
// v1.14.0: per-agent step cap for the outer inference loop. null means
|
||||||
|
// bounded only by MAX_STEPS (200). 0 means "no tool calls allowed."
|
||||||
|
steps: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// One entry per malformed `## Name` block. Per-block errors don't fail the
|
// One entry per malformed `## Name` block. Per-block errors don't fail the
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"lib": ["ES2022"],
|
"lib": ["ES2022"],
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
"declaration": false,
|
"declaration": true,
|
||||||
"sourceMap": true
|
"sourceMap": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
|
|||||||
@@ -123,7 +123,20 @@ export const api = {
|
|||||||
get: (id: string) => request<Session>(`/api/sessions/${id}`),
|
get: (id: string) => request<Session>(`/api/sessions/${id}`),
|
||||||
update: (
|
update: (
|
||||||
id: string,
|
id: string,
|
||||||
body: Partial<Pick<Session, 'name' | 'model' | 'system_prompt' | 'agent_id' | 'web_search_enabled'>>
|
body: Partial<
|
||||||
|
Pick<
|
||||||
|
Session,
|
||||||
|
| 'name'
|
||||||
|
| 'model'
|
||||||
|
| 'system_prompt'
|
||||||
|
| 'agent_id'
|
||||||
|
| 'web_search_enabled'
|
||||||
|
// v1.13.17: revocation path — frontend sends the shortened list
|
||||||
|
// when the user removes a grant. Grants are appended only via the
|
||||||
|
// separate grantReadAccess endpoint below.
|
||||||
|
| 'allowed_read_paths'
|
||||||
|
>
|
||||||
|
>
|
||||||
) =>
|
) =>
|
||||||
request<Session>(`/api/sessions/${id}`, {
|
request<Session>(`/api/sessions/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -228,6 +241,19 @@ export const api = {
|
|||||||
body: JSON.stringify({ tool_call_id: toolCallId, answers }),
|
body: JSON.stringify({ tool_call_id: toolCallId, answers }),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
// v1.13.17-cross-repo-reads: resume a paused request_read_access. On
|
||||||
|
// 'allow' the server re-resolves the grant root and appends it to
|
||||||
|
// sessions.allowed_read_paths; the returned list reflects the post-
|
||||||
|
// grant state. On 'deny' the array is unchanged.
|
||||||
|
grantReadAccess: (chatId: string, toolCallId: string, decision: 'allow' | 'deny') =>
|
||||||
|
request<{
|
||||||
|
tool_message_id: string;
|
||||||
|
assistant_message_id: string;
|
||||||
|
allowed_read_paths: string[];
|
||||||
|
}>(`/api/chats/${chatId}/grant_read_access`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ tool_call_id: toolCallId, decision }),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
messages: {
|
messages: {
|
||||||
@@ -250,6 +276,24 @@ export const api = {
|
|||||||
request<void>(`/api/chats/${chatId}/messages/${messageId}`, {
|
request<void>(`/api/chats/${chatId}/messages/${messageId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
}),
|
}),
|
||||||
|
// v1.14.x-html-artifact-panes: write the artifact to
|
||||||
|
// <projectRoot>/.boocode/artifacts/<slug>-<ts>.<ext> and return the
|
||||||
|
// path + a /api/projects/.../artifacts/<filename> URL the browser can
|
||||||
|
// GET to download. fmt=html requires the assistant message to carry an
|
||||||
|
// html_artifact part (404 otherwise).
|
||||||
|
downloadArtifact: (chatId: string, messageId: string, fmt: 'md' | 'html') =>
|
||||||
|
request<{ path: string; url: string }>(
|
||||||
|
`/api/chats/${chatId}/messages/${messageId}/artifacts/download?fmt=${fmt}`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
),
|
||||||
|
// v1.14.x-html-artifact-panes: fetch the html_artifact part payload so
|
||||||
|
// HtmlArtifactPane can render the iframe srcdoc. 404 = no html_artifact
|
||||||
|
// part on this message; MessageBubble uses that as a signal to fall back
|
||||||
|
// to the markdown pane variant.
|
||||||
|
getHtmlArtifact: (chatId: string, messageId: string) =>
|
||||||
|
request<{ html_content: string; char_count: number; title: string }>(
|
||||||
|
`/api/chats/${chatId}/messages/${messageId}/html_artifact`,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
models: () => request<ModelInfo[]>('/api/models'),
|
models: () => request<ModelInfo[]>('/api/models'),
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ export interface Session {
|
|||||||
web_search_enabled: boolean | null;
|
web_search_enabled: boolean | null;
|
||||||
// v1.12.1: server-authoritative pane layout, replaces localStorage.
|
// v1.12.1: server-authoritative pane layout, replaces localStorage.
|
||||||
workspace_panes: WorkspacePane[];
|
workspace_panes: WorkspacePane[];
|
||||||
|
// v1.13.17: paths the agent has been granted read access to via the
|
||||||
|
// request_read_access tool. Empty by default. Settings UI surfaces the
|
||||||
|
// list with per-row revoke; the grant flow itself appends through the
|
||||||
|
// dedicated POST /api/chats/:id/grant_read_access endpoint (not PATCH).
|
||||||
|
allowed_read_paths: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.8.1: 'global' = /data/AGENTS.md (always-on), 'project' = per-project
|
// v1.8.1: 'global' = /data/AGENTS.md (always-on), 'project' = per-project
|
||||||
@@ -68,6 +73,9 @@ export interface Agent {
|
|||||||
// the agent's toolset (30 for all read-only, 10 otherwise) or 15 for raw
|
// the agent's toolset (30 for all read-only, 10 otherwise) or 15 for raw
|
||||||
// chat with no agent.
|
// chat with no agent.
|
||||||
max_tool_calls: number | null;
|
max_tool_calls: number | null;
|
||||||
|
// v1.14.0: per-agent step cap for the outer inference loop. null means
|
||||||
|
// bounded only by MAX_STEPS (200). 0 means "no tool calls allowed."
|
||||||
|
steps: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentParseError {
|
export interface AgentParseError {
|
||||||
@@ -311,7 +319,37 @@ export interface AskUserAnswerSet {
|
|||||||
// v1.9: 'settings' is an ephemeral pane kind — never persisted, always
|
// v1.9: 'settings' is an ephemeral pane kind — never persisted, always
|
||||||
// singleton per workspace. The pane hook filters it out before writing to
|
// singleton per workspace. The pane hook filters it out before writing to
|
||||||
// localStorage and dedupes on insertion via toggleSettingsPane().
|
// localStorage and dedupes on insertion via toggleSettingsPane().
|
||||||
export type WorkspacePaneKind = 'chat' | 'terminal' | 'agent' | 'empty' | 'settings';
|
// v1.14.x-html-artifact-panes: 'markdown_artifact' + 'html_artifact' added.
|
||||||
|
// Both carry payload state on the WorkspacePane row itself so
|
||||||
|
// useWorkspacePanes's JSON-string dedup + persisted jsonb stay self-contained
|
||||||
|
// — no extra fetch on rehydrate.
|
||||||
|
export type WorkspacePaneKind =
|
||||||
|
| 'chat'
|
||||||
|
| 'terminal'
|
||||||
|
| 'agent'
|
||||||
|
| 'empty'
|
||||||
|
| 'settings'
|
||||||
|
| 'markdown_artifact'
|
||||||
|
| 'html_artifact';
|
||||||
|
|
||||||
|
// v1.14.x: per-pane artifact payloads. Optional + namespaced so older saved
|
||||||
|
// pane rows (without these fields) deserialize unchanged.
|
||||||
|
// v1.14.x: pane state is a reference only — the pane component fetches the
|
||||||
|
// actual content on mount. This keeps sessions.workspace_panes jsonb small and
|
||||||
|
// makes the message body / html_artifact part the single source of truth.
|
||||||
|
export interface MarkdownArtifactState {
|
||||||
|
// chat_id is needed for the download endpoint
|
||||||
|
// (POST /api/chats/:chat_id/messages/:msg_id/artifacts/download).
|
||||||
|
chat_id: string;
|
||||||
|
message_id: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HtmlArtifactState {
|
||||||
|
chat_id: string;
|
||||||
|
message_id: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkspacePane {
|
export interface WorkspacePane {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -319,6 +357,9 @@ export interface WorkspacePane {
|
|||||||
chatId?: string;
|
chatId?: string;
|
||||||
chatIds: string[];
|
chatIds: string[];
|
||||||
activeChatIdx: number;
|
activeChatIdx: number;
|
||||||
|
// v1.14.x: populated only when kind === 'markdown_artifact' / 'html_artifact'.
|
||||||
|
markdown_artifact_state?: MarkdownArtifactState;
|
||||||
|
html_artifact_state?: HtmlArtifactState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WsFrame =
|
export type WsFrame =
|
||||||
|
|||||||
116
apps/web/src/components/HtmlArtifactPane.tsx
Normal file
116
apps/web/src/components/HtmlArtifactPane.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
// v1.14.x-html-artifact-panes: full-height HTML artifact viewer. Renders the
|
||||||
|
// model's HTML inside a sandboxed iframe — no allow-same-origin, srcdoc only
|
||||||
|
// (no separate URL), CSP injected by the backend writer. JS runs inside the
|
||||||
|
// iframe (interactive controls work) but fetch / WS / tracking pixels are
|
||||||
|
// blocked by connect-src 'none' on the CSP. NO Copy button per the spec.
|
||||||
|
//
|
||||||
|
// Pane state is a reference only (chat_id + message_id + title); the iframe
|
||||||
|
// payload is fetched on mount from
|
||||||
|
// GET /api/chats/:chat_id/messages/:msg_id/html_artifact so that
|
||||||
|
// sessions.workspace_panes jsonb stays small and message_parts.payload is the
|
||||||
|
// single source of truth.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Download, X } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
import type { HtmlArtifactState } from '@/api/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
chatId: string;
|
||||||
|
state: HtmlArtifactState;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HtmlArtifactPane({ chatId, state, onClose }: Props) {
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
const [htmlContent, setHtmlContent] = useState<string | null>(null);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setHtmlContent(null);
|
||||||
|
setLoadError(null);
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const payload = await api.messages.getHtmlArtifact(chatId, state.message_id);
|
||||||
|
if (cancelled) return;
|
||||||
|
setHtmlContent(payload.html_content);
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelled) return;
|
||||||
|
setLoadError(err instanceof Error ? err.message : 'failed to load HTML artifact');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [chatId, state.message_id]);
|
||||||
|
|
||||||
|
async function download() {
|
||||||
|
if (downloading) return;
|
||||||
|
setDownloading(true);
|
||||||
|
try {
|
||||||
|
const { url, path } = await api.messages.downloadArtifact(
|
||||||
|
chatId,
|
||||||
|
state.message_id,
|
||||||
|
'html',
|
||||||
|
);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.rel = 'noopener';
|
||||||
|
a.click();
|
||||||
|
toast.success(`Saved to ${path}`);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'download failed');
|
||||||
|
} finally {
|
||||||
|
setDownloading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
|
||||||
|
<span className="text-xs text-muted-foreground truncate flex-1" title={state.title}>
|
||||||
|
{state.title || 'HTML artifact'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void download()}
|
||||||
|
disabled={downloading || htmlContent === null}
|
||||||
|
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
|
aria-label="Download HTML"
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
<Download size={12} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
|
aria-label="Close artifact pane"
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 overflow-hidden bg-background">
|
||||||
|
{loadError ? (
|
||||||
|
<div className="p-4 text-sm text-destructive">Failed to load: {loadError}</div>
|
||||||
|
) : htmlContent === null ? (
|
||||||
|
<div className="p-4 text-sm text-muted-foreground">Loading HTML artifact…</div>
|
||||||
|
) : (
|
||||||
|
<iframe
|
||||||
|
// Sandbox attributes are non-negotiable per the v1.14.x spec S5:
|
||||||
|
// no allow-same-origin → opaque origin → can't reach parent cookies
|
||||||
|
// or DOM. srcdoc (not src) means no URL exists to leak. JS runs
|
||||||
|
// (allow-scripts) but connect-src 'none' on the CSP inside the
|
||||||
|
// payload blocks fetch / WS / pixels.
|
||||||
|
srcDoc={htmlContent}
|
||||||
|
sandbox="allow-scripts allow-clipboard-write allow-downloads"
|
||||||
|
className="w-full h-full border-0"
|
||||||
|
title={state.title || 'HTML artifact'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
apps/web/src/components/MarkdownArtifactPane.tsx
Normal file
137
apps/web/src/components/MarkdownArtifactPane.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// v1.14.x-html-artifact-panes: dedicated full-height Markdown viewer used
|
||||||
|
// when a user clicks "Open in pane" on an assistant message that has NO
|
||||||
|
// html_artifact part. Header carries Copy (raw source) + Download (server-
|
||||||
|
// materialised .md under <projectRoot>/.boocode/artifacts/) + close.
|
||||||
|
//
|
||||||
|
// Pane state is a reference only (chat_id + message_id + title); the markdown
|
||||||
|
// body is fetched on mount from GET /api/chats/:chat_id/messages by locating
|
||||||
|
// the matching message_id. This keeps sessions.workspace_panes jsonb small
|
||||||
|
// and the assistant message row remains the single source of truth.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Check, Copy, Download, X } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
import type { MarkdownArtifactState } from '@/api/types';
|
||||||
|
import { MarkdownRenderer } from './MarkdownRenderer';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
chatId: string;
|
||||||
|
state: MarkdownArtifactState;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarkdownArtifactPane({ chatId, state, onClose }: Props) {
|
||||||
|
const [justCopied, setJustCopied] = useState(false);
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
const [content, setContent] = useState<string | null>(null);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setContent(null);
|
||||||
|
setLoadError(null);
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
// No single-message GET endpoint exists; the chat-messages list is
|
||||||
|
// already cached server-side and the lookup is O(n) over a small
|
||||||
|
// window. Cheaper than adding a new route for one call site.
|
||||||
|
const messages = await api.chats.messages(chatId);
|
||||||
|
if (cancelled) return;
|
||||||
|
const msg = messages.find((m) => m.id === state.message_id);
|
||||||
|
if (!msg) {
|
||||||
|
setLoadError('Message not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setContent(msg.content ?? '');
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelled) return;
|
||||||
|
setLoadError(err instanceof Error ? err.message : 'failed to load message');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [chatId, state.message_id]);
|
||||||
|
|
||||||
|
async function copy() {
|
||||||
|
if (content === null) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(content);
|
||||||
|
setJustCopied(true);
|
||||||
|
setTimeout(() => setJustCopied(false), 1200);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'copy failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function download() {
|
||||||
|
if (downloading) return;
|
||||||
|
setDownloading(true);
|
||||||
|
try {
|
||||||
|
const { url, path } = await api.messages.downloadArtifact(
|
||||||
|
chatId,
|
||||||
|
state.message_id,
|
||||||
|
'md',
|
||||||
|
);
|
||||||
|
// Trigger browser download from the returned URL. The endpoint stamps
|
||||||
|
// Content-Disposition: attachment so the click lands as a save.
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.rel = 'noopener';
|
||||||
|
a.click();
|
||||||
|
toast.success(`Saved to ${path}`);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'download failed');
|
||||||
|
} finally {
|
||||||
|
setDownloading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
|
||||||
|
<span className="text-xs text-muted-foreground truncate flex-1" title={state.title}>
|
||||||
|
{state.title || 'Markdown artifact'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void copy()}
|
||||||
|
disabled={content === null}
|
||||||
|
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
|
aria-label="Copy markdown source"
|
||||||
|
title="Copy"
|
||||||
|
>
|
||||||
|
{justCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void download()}
|
||||||
|
disabled={downloading || content === null}
|
||||||
|
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
|
aria-label="Download markdown"
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
<Download size={12} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
|
aria-label="Close artifact pane"
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 overflow-auto px-4 py-3 text-sm">
|
||||||
|
{loadError ? (
|
||||||
|
<div className="text-destructive">Failed to load: {loadError}</div>
|
||||||
|
) : content === null ? (
|
||||||
|
<div className="text-muted-foreground">Loading…</div>
|
||||||
|
) : (
|
||||||
|
<MarkdownRenderer content={content} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
apps/web/src/components/MarkdownRenderer.tsx
Normal file
148
apps/web/src/components/MarkdownRenderer.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
// v1.14.x-html-artifact-panes: extracted from MessageBubble.tsx so both the
|
||||||
|
// in-chat bubble renderer and the MarkdownArtifactPane share the same Shiki +
|
||||||
|
// remark-gfm + path-linkifier pipeline. Behavior preserved byte-for-byte from
|
||||||
|
// the original MessageBubble.MarkdownBody helper (and its linkify helpers).
|
||||||
|
import { Children, cloneElement, isValidElement } from 'react';
|
||||||
|
import type { ReactElement, ReactNode } from 'react';
|
||||||
|
import Markdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import { CodeBlock } from './CodeBlock';
|
||||||
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
|
|
||||||
|
// Match path-shaped substrings ending in `.ext`. Additionally require a `/`
|
||||||
|
// in the match to reduce false positives in prose (e.g. plain `foo.ts` won't
|
||||||
|
// match, but `src/foo.ts` will). False positives at the edges are accepted
|
||||||
|
// per Sam's design decision (2026-05-14).
|
||||||
|
const PATH_REGEX = /([a-zA-Z0-9._/-]+\.[a-zA-Z0-9]+)/g;
|
||||||
|
|
||||||
|
function isPathLike(s: string): boolean {
|
||||||
|
return s.includes('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitOpenFile(path: string): void {
|
||||||
|
sessionEvents.emit({ type: 'open_file_in_browser', path });
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkifyPaths(text: string, keyPrefix: string): ReactNode {
|
||||||
|
const out: ReactNode[] = [];
|
||||||
|
let lastIdx = 0;
|
||||||
|
let idx = 0;
|
||||||
|
for (const match of text.matchAll(PATH_REGEX)) {
|
||||||
|
const matchedText = match[0];
|
||||||
|
const start = match.index ?? 0;
|
||||||
|
if (!isPathLike(matchedText)) continue;
|
||||||
|
if (start > lastIdx) out.push(text.slice(lastIdx, start));
|
||||||
|
out.push(
|
||||||
|
<button
|
||||||
|
key={`${keyPrefix}-${idx}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => emitOpenFile(matchedText)}
|
||||||
|
className="text-primary underline cursor-pointer hover:text-primary/80"
|
||||||
|
>
|
||||||
|
{matchedText}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
lastIdx = start + matchedText.length;
|
||||||
|
idx += 1;
|
||||||
|
}
|
||||||
|
if (out.length === 0) return text;
|
||||||
|
if (lastIdx < text.length) out.push(text.slice(lastIdx));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkifyChildren(children: ReactNode, keyPrefix = 'l'): ReactNode {
|
||||||
|
const arr = Children.toArray(children);
|
||||||
|
return arr.map((child, i) => {
|
||||||
|
if (typeof child === 'string') {
|
||||||
|
return (
|
||||||
|
<span key={`${keyPrefix}-${i}`}>
|
||||||
|
{linkifyPaths(child, `${keyPrefix}-${i}`)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isValidElement(child)) {
|
||||||
|
const el = child as ReactElement<{ children?: ReactNode }>;
|
||||||
|
if (el.type === 'code' || el.type === CodeBlock) return child;
|
||||||
|
const grandchildren = el.props.children;
|
||||||
|
if (grandchildren === undefined) return child;
|
||||||
|
return cloneElement(el, {
|
||||||
|
key: el.key ?? `linkified-${i}`,
|
||||||
|
children: linkifyChildren(grandchildren, `${keyPrefix}-${i}`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeRenderer = (props: { children?: unknown; className?: string }) => {
|
||||||
|
const { children, className, ...rest } = props;
|
||||||
|
const text = String(children ?? '').replace(/\n$/, '');
|
||||||
|
const langMatch = /language-([\w-]+)/.exec(className ?? '');
|
||||||
|
const isBlock = !!langMatch || text.includes('\n');
|
||||||
|
if (isBlock) {
|
||||||
|
return <CodeBlock code={text} lang={langMatch?.[1]} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<code
|
||||||
|
{...rest}
|
||||||
|
className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]"
|
||||||
|
>
|
||||||
|
{children as React.ReactNode}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MarkdownRenderer({ content }: { content: string }) {
|
||||||
|
return (
|
||||||
|
<Markdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
pre: ({ children }) => <>{children}</>,
|
||||||
|
code: codeRenderer,
|
||||||
|
a: ({ children, href }) => (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="underline decoration-muted-foreground/40 underline-offset-2 hover:decoration-foreground"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
ul: ({ children }) => (
|
||||||
|
<ul className="list-disc pl-5 space-y-1">{children}</ul>
|
||||||
|
),
|
||||||
|
ol: ({ children }) => (
|
||||||
|
<ol className="list-decimal pl-5 space-y-1">{children}</ol>
|
||||||
|
),
|
||||||
|
li: ({ children }) => <li>{linkifyChildren(children)}</li>,
|
||||||
|
p: ({ children }) => (
|
||||||
|
<p className="leading-relaxed">{linkifyChildren(children)}</p>
|
||||||
|
),
|
||||||
|
h1: ({ children }) => <h1 className="text-base font-semibold mt-2">{children}</h1>,
|
||||||
|
h2: ({ children }) => <h2 className="text-sm font-semibold mt-2">{children}</h2>,
|
||||||
|
h3: ({ children }) => <h3 className="text-sm font-semibold mt-1">{children}</h3>,
|
||||||
|
blockquote: ({ children }) => (
|
||||||
|
<blockquote className="border-l-2 border-border pl-3 text-muted-foreground">
|
||||||
|
{children}
|
||||||
|
</blockquote>
|
||||||
|
),
|
||||||
|
table: ({ children }) => (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="border-collapse text-xs">{children}</table>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
th: ({ children }) => (
|
||||||
|
<th className="border border-border px-2 py-1 text-left font-medium">{children}</th>
|
||||||
|
),
|
||||||
|
td: ({ children }) => (
|
||||||
|
<td className="border border-border px-2 py-1">
|
||||||
|
{linkifyChildren(children)}
|
||||||
|
</td>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Markdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,14 @@
|
|||||||
import { Children, cloneElement, isValidElement, useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { ReactElement, ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import Markdown from 'react-markdown';
|
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, PanelRightOpen } from 'lucide-react';
|
||||||
import remarkGfm from 'remark-gfm';
|
|
||||||
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2 } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { Chat, ErrorReason, Message } from '@/api/types';
|
import type { Chat, ErrorReason, Message } from '@/api/types';
|
||||||
import { api } from '@/api/client';
|
import { api, ApiError } from '@/api/client';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events';
|
import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events';
|
||||||
import { CapHitSentinel } from './CapHitSentinel';
|
import { CapHitSentinel } from './CapHitSentinel';
|
||||||
import { DoomLoopSentinel } from './DoomLoopSentinel';
|
import { DoomLoopSentinel } from './DoomLoopSentinel';
|
||||||
import { CodeBlock } from './CodeBlock';
|
import { MarkdownRenderer } from './MarkdownRenderer';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
@@ -90,76 +88,20 @@ const ERROR_REASON_LABELS: Record<ErrorReason, string> = {
|
|||||||
summary_after_cap_failed: 'Summary after tool budget hit failed',
|
summary_after_cap_failed: 'Summary after tool budget hit failed',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Match path-shaped substrings ending in `.ext`. Additionally require a `/`
|
// v1.14.x-html-artifact-panes: MarkdownBody and its path-linkifier helpers
|
||||||
// in the match to reduce false positives in prose (e.g. plain `foo.ts` won't
|
// moved to apps/web/src/components/MarkdownRenderer.tsx so the new artifact
|
||||||
// match, but `src/foo.ts` will). False positives at the edges are accepted
|
// panes can render assistant content with the same Shiki + remark-gfm setup.
|
||||||
// per Sam's design decision (2026-05-14).
|
|
||||||
const PATH_REGEX = /([a-zA-Z0-9._/-]+\.[a-zA-Z0-9]+)/g;
|
|
||||||
|
|
||||||
function isPathLike(s: string): boolean {
|
// Pane-header title derivation for a markdown artifact. Order matches the
|
||||||
return s.includes('/');
|
// server slug logic in services/artifacts.ts: first `# ` heading → first 6
|
||||||
}
|
// words of the body → 'Markdown artifact'. Truncated to keep the pane header
|
||||||
|
// readable.
|
||||||
function emitOpenFile(path: string): void {
|
function deriveMarkdownTitle(content: string): string {
|
||||||
sessionEvents.emit({ type: 'open_file_in_browser', path });
|
const headingMatch = content.match(/^\s*#\s+(.+?)\s*$/m);
|
||||||
}
|
if (headingMatch && headingMatch[1]) return headingMatch[1].slice(0, 80);
|
||||||
|
const words = content.trim().split(/\s+/).slice(0, 6).join(' ');
|
||||||
// Split a plain string into a flat array of strings and clickable button
|
if (words) return words.slice(0, 80);
|
||||||
// nodes for path-shaped substrings. If no matches, returns the original
|
return 'Markdown artifact';
|
||||||
// string verbatim (no array wrapping).
|
|
||||||
function linkifyPaths(text: string, keyPrefix: string): ReactNode {
|
|
||||||
const out: ReactNode[] = [];
|
|
||||||
let lastIdx = 0;
|
|
||||||
let idx = 0;
|
|
||||||
for (const match of text.matchAll(PATH_REGEX)) {
|
|
||||||
const matchedText = match[0];
|
|
||||||
const start = match.index ?? 0;
|
|
||||||
if (!isPathLike(matchedText)) continue;
|
|
||||||
if (start > lastIdx) out.push(text.slice(lastIdx, start));
|
|
||||||
out.push(
|
|
||||||
<button
|
|
||||||
key={`${keyPrefix}-${idx}`}
|
|
||||||
type="button"
|
|
||||||
onClick={() => emitOpenFile(matchedText)}
|
|
||||||
className="text-primary underline cursor-pointer hover:text-primary/80"
|
|
||||||
>
|
|
||||||
{matchedText}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
lastIdx = start + matchedText.length;
|
|
||||||
idx += 1;
|
|
||||||
}
|
|
||||||
if (out.length === 0) return text;
|
|
||||||
if (lastIdx < text.length) out.push(text.slice(lastIdx));
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walk react-markdown children, linkifying string text nodes. Children of
|
|
||||||
// <code> nodes (CodeBlock and inline code) are left untouched — the regex
|
|
||||||
// shouldn't run inside code spans.
|
|
||||||
function linkifyChildren(children: ReactNode, keyPrefix = 'l'): ReactNode {
|
|
||||||
const arr = Children.toArray(children);
|
|
||||||
return arr.map((child, i) => {
|
|
||||||
if (typeof child === 'string') {
|
|
||||||
return (
|
|
||||||
<span key={`${keyPrefix}-${i}`}>
|
|
||||||
{linkifyPaths(child, `${keyPrefix}-${i}`)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (isValidElement(child)) {
|
|
||||||
const el = child as ReactElement<{ children?: ReactNode }>;
|
|
||||||
// Skip inline/block code — paths in code spans aren't link targets.
|
|
||||||
if (el.type === 'code' || el.type === CodeBlock) return child;
|
|
||||||
const grandchildren = el.props.children;
|
|
||||||
if (grandchildren === undefined) return child;
|
|
||||||
return cloneElement(el, {
|
|
||||||
key: el.key ?? `linkified-${i}`,
|
|
||||||
children: linkifyChildren(grandchildren, `${keyPrefix}-${i}`),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return child;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -170,80 +112,6 @@ interface Props {
|
|||||||
capHitInfo?: { position: number; isLatest: boolean };
|
capHitInfo?: { position: number; isLatest: boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
function MarkdownBody({ content }: { content: string }) {
|
|
||||||
return (
|
|
||||||
<Markdown
|
|
||||||
remarkPlugins={[remarkGfm]}
|
|
||||||
components={{
|
|
||||||
pre: ({ children }) => <>{children}</>,
|
|
||||||
code: (props) => {
|
|
||||||
const { children, className, ...rest } = props as {
|
|
||||||
children?: unknown;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
const text = String(children ?? '').replace(/\n$/, '');
|
|
||||||
const langMatch = /language-([\w-]+)/.exec(className ?? '');
|
|
||||||
const isBlock = !!langMatch || text.includes('\n');
|
|
||||||
if (isBlock) {
|
|
||||||
return <CodeBlock code={text} lang={langMatch?.[1]} />;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<code
|
|
||||||
{...rest}
|
|
||||||
className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]"
|
|
||||||
>
|
|
||||||
{children as React.ReactNode}
|
|
||||||
</code>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
a: ({ children, href }) => (
|
|
||||||
<a
|
|
||||||
href={href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="underline decoration-muted-foreground/40 underline-offset-2 hover:decoration-foreground"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
),
|
|
||||||
ul: ({ children }) => (
|
|
||||||
<ul className="list-disc pl-5 space-y-1">{children}</ul>
|
|
||||||
),
|
|
||||||
ol: ({ children }) => (
|
|
||||||
<ol className="list-decimal pl-5 space-y-1">{children}</ol>
|
|
||||||
),
|
|
||||||
li: ({ children }) => <li>{linkifyChildren(children)}</li>,
|
|
||||||
p: ({ children }) => (
|
|
||||||
<p className="leading-relaxed">{linkifyChildren(children)}</p>
|
|
||||||
),
|
|
||||||
h1: ({ children }) => <h1 className="text-base font-semibold mt-2">{children}</h1>,
|
|
||||||
h2: ({ children }) => <h2 className="text-sm font-semibold mt-2">{children}</h2>,
|
|
||||||
h3: ({ children }) => <h3 className="text-sm font-semibold mt-1">{children}</h3>,
|
|
||||||
blockquote: ({ children }) => (
|
|
||||||
<blockquote className="border-l-2 border-border pl-3 text-muted-foreground">
|
|
||||||
{children}
|
|
||||||
</blockquote>
|
|
||||||
),
|
|
||||||
table: ({ children }) => (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="border-collapse text-xs">{children}</table>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
th: ({ children }) => (
|
|
||||||
<th className="border border-border px-2 py-1 text-left font-medium">{children}</th>
|
|
||||||
),
|
|
||||||
td: ({ children }) => (
|
|
||||||
<td className="border border-border px-2 py-1">
|
|
||||||
{linkifyChildren(children)}
|
|
||||||
</td>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</Markdown>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatsLine({ message }: { message: Message }) {
|
function StatsLine({ message }: { message: Message }) {
|
||||||
const tokens = message.tokens_used;
|
const tokens = message.tokens_used;
|
||||||
if (typeof tokens !== 'number' || tokens <= 0) return null;
|
if (typeof tokens !== 'number' || tokens <= 0) return null;
|
||||||
@@ -337,6 +205,54 @@ function ActionRow({
|
|||||||
const canRegen = isAssistant && message.status !== 'streaming';
|
const canRegen = isAssistant && message.status !== 'streaming';
|
||||||
const canFork = message.status === 'complete';
|
const canFork = message.status === 'complete';
|
||||||
const canDelete = message.status !== 'streaming';
|
const canDelete = message.status !== 'streaming';
|
||||||
|
const [openingPane, setOpeningPane] = useState(false);
|
||||||
|
|
||||||
|
// v1.14.x-html-artifact-panes: probe for an html_artifact part. If present,
|
||||||
|
// open the HTML pane variant; otherwise fall back to the markdown variant.
|
||||||
|
// Title derivation for markdown: first `# ` heading → first 6 words of the
|
||||||
|
// body → 'Markdown artifact' (mirrors the slug logic in
|
||||||
|
// services/artifacts.ts).
|
||||||
|
async function openInPane() {
|
||||||
|
if (openingPane || message.status === 'streaming') return;
|
||||||
|
setOpeningPane(true);
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
const payload = await api.messages.getHtmlArtifact(
|
||||||
|
message.chat_id,
|
||||||
|
message.id,
|
||||||
|
);
|
||||||
|
sessionEvents.emit({
|
||||||
|
type: 'open_html_artifact_pane',
|
||||||
|
state: {
|
||||||
|
chat_id: message.chat_id,
|
||||||
|
message_id: message.id,
|
||||||
|
title: payload.title,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
// 404 (no html_artifact part) is the expected fall-through path —
|
||||||
|
// markdown variant opens below. Any other error (network, 500) is
|
||||||
|
// a real failure; toast and bail rather than masquerading as markdown.
|
||||||
|
const status = err instanceof ApiError ? err.status : null;
|
||||||
|
if (status !== 404) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'open in pane failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const title = deriveMarkdownTitle(message.content);
|
||||||
|
sessionEvents.emit({
|
||||||
|
type: 'open_markdown_artifact_pane',
|
||||||
|
state: {
|
||||||
|
chat_id: message.chat_id,
|
||||||
|
message_id: message.id,
|
||||||
|
title,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setOpeningPane(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -350,6 +266,18 @@ function ActionRow({
|
|||||||
>
|
>
|
||||||
{justCopied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
{justCopied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||||
</button>
|
</button>
|
||||||
|
{isAssistant && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void openInPane()}
|
||||||
|
disabled={openingPane || message.status === 'streaming'}
|
||||||
|
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
|
aria-label="Open in pane"
|
||||||
|
title="Open in pane"
|
||||||
|
>
|
||||||
|
<PanelRightOpen className="size-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{isAssistant && (
|
{isAssistant && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -588,7 +516,7 @@ function SummaryCard({ message }: { message: Message }) {
|
|||||||
</div>
|
</div>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="px-3 pb-3 text-xs leading-relaxed border-t pt-2">
|
<div className="px-3 pb-3 text-xs leading-relaxed border-t pt-2">
|
||||||
<MarkdownBody content={message.content} />
|
<MarkdownRenderer content={message.content} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -667,7 +595,7 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
|
|||||||
{(hasContent || isStreaming) && (
|
{(hasContent || isStreaming) && (
|
||||||
<SendToTerminalMenu>
|
<SendToTerminalMenu>
|
||||||
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
|
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
|
||||||
{hasContent ? <MarkdownBody content={message.content} /> : null}
|
{hasContent ? <MarkdownRenderer content={message.content} /> : null}
|
||||||
{isStreaming && (
|
{isStreaming && (
|
||||||
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
|
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { MessageBubble } from './MessageBubble';
|
|||||||
import { ToolCallGroup } from './ToolCallGroup';
|
import { ToolCallGroup } from './ToolCallGroup';
|
||||||
import { ToolCallLine, type ToolRun } from './ToolCallLine';
|
import { ToolCallLine, type ToolRun } from './ToolCallLine';
|
||||||
import { AskUserInputCard } from './AskUserInputCard';
|
import { AskUserInputCard } from './AskUserInputCard';
|
||||||
|
import { RequestReadAccessCard } from './RequestReadAccessCard';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
@@ -85,7 +86,9 @@ function group(items: RenderItem[]): RenderItem[] {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const name = item.run.call.name;
|
const name = item.run.call.name;
|
||||||
if (name === 'ask_user_input') {
|
if (name === 'ask_user_input' || name === 'request_read_access') {
|
||||||
|
// v1.13.17: same rationale as ask_user_input — grouping would collapse
|
||||||
|
// the interactive pause card into a non-actionable ToolCallLine.
|
||||||
out.push(item);
|
out.push(item);
|
||||||
i += 1;
|
i += 1;
|
||||||
continue;
|
continue;
|
||||||
@@ -181,6 +184,16 @@ export function MessageList({ messages, sessionChats }: Props) {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (item.run.call.name === 'request_read_access') {
|
||||||
|
return (
|
||||||
|
<RequestReadAccessCard
|
||||||
|
key={item.key}
|
||||||
|
toolCall={item.run.call}
|
||||||
|
toolResult={item.run.result}
|
||||||
|
chatId={item.chatId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
return <ToolCallLine key={item.key} run={item.run} />;
|
return <ToolCallLine key={item.key} run={item.run} />;
|
||||||
}
|
}
|
||||||
return <ToolCallGroup key={item.key} runs={item.runs} />;
|
return <ToolCallGroup key={item.key} runs={item.runs} />;
|
||||||
|
|||||||
193
apps/web/src/components/RequestReadAccessCard.tsx
Normal file
193
apps/web/src/components/RequestReadAccessCard.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Check, FolderOpen, ShieldOff } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import type { ToolCall, ToolResult } from '@/api/types';
|
||||||
|
|
||||||
|
// v1.13.17-cross-repo-reads. Renders an inline allow/deny picker for a
|
||||||
|
// paused request_read_access tool call. Mirrors AskUserInputCard's pending
|
||||||
|
// vs answered render dance:
|
||||||
|
// - Pending: server pre-stamps a sentinel tool_result with output=null.
|
||||||
|
// The card shows path + reason and lets the user pick Allow or Deny.
|
||||||
|
// - Answered: the eventual WS tool_result frame carries the actual
|
||||||
|
// decision string ("granted: <root>" or "denied" or "denied: <reason>").
|
||||||
|
// The card flips to a read-only summary line.
|
||||||
|
//
|
||||||
|
// Tool name discrimination lives in MessageList.flatten/group — anything
|
||||||
|
// with tc.name === 'request_read_access' bypasses grouping and renders this
|
||||||
|
// card directly.
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
toolCall: ToolCall;
|
||||||
|
toolResult: ToolResult | null;
|
||||||
|
chatId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedArgs {
|
||||||
|
path: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(raw: unknown): ParsedArgs | null {
|
||||||
|
if (!raw || typeof raw !== 'object') return null;
|
||||||
|
const obj = raw as { path?: unknown; reason?: unknown };
|
||||||
|
if (typeof obj.path !== 'string' || obj.path.length === 0) return null;
|
||||||
|
if (typeof obj.reason !== 'string' || obj.reason.length === 0) return null;
|
||||||
|
return { path: obj.path, reason: obj.reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
function decisionVariant(output: unknown): 'granted' | 'denied' | 'unknown' {
|
||||||
|
if (typeof output !== 'string') return 'unknown';
|
||||||
|
if (output.startsWith('granted:')) return 'granted';
|
||||||
|
if (output === 'denied' || output.startsWith('denied:')) return 'denied';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RequestReadAccessCard({ toolCall, toolResult, chatId }: Props) {
|
||||||
|
const args = parseArgs(toolCall.args);
|
||||||
|
|
||||||
|
if (!args) {
|
||||||
|
return (
|
||||||
|
<div className="rounded border border-destructive/40 bg-destructive/10 text-xs px-3 py-2 text-destructive">
|
||||||
|
request_read_access: malformed tool args
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-null output means the WS tool_result frame arrived (or the row was
|
||||||
|
// re-fetched from history).
|
||||||
|
const answered = toolResult && toolResult.output !== null;
|
||||||
|
if (answered) {
|
||||||
|
return <AnsweredView args={args} output={toolResult!.output} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PendingView args={args} toolCallId={toolCall.id} chatId={chatId} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PendingView({
|
||||||
|
args,
|
||||||
|
toolCallId,
|
||||||
|
chatId,
|
||||||
|
}: {
|
||||||
|
args: ParsedArgs;
|
||||||
|
toolCallId: string;
|
||||||
|
chatId: string;
|
||||||
|
}) {
|
||||||
|
const [submitting, setSubmitting] = useState<'allow' | 'deny' | null>(null);
|
||||||
|
|
||||||
|
async function decide(decision: 'allow' | 'deny') {
|
||||||
|
if (submitting) return;
|
||||||
|
setSubmitting(decision);
|
||||||
|
try {
|
||||||
|
await api.chats.grantReadAccess(chatId, toolCallId, decision);
|
||||||
|
// Card stays mounted; the incoming WS tool_result frame swaps it to
|
||||||
|
// AnsweredView via the parent prop change.
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'request failed');
|
||||||
|
setSubmitting(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-amber-500/40 bg-amber-500/5 text-sm">
|
||||||
|
<div className="px-4 py-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs uppercase tracking-wide text-amber-700 dark:text-amber-300">
|
||||||
|
<ShieldOff className="size-3.5" />
|
||||||
|
<span>Read-access request</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">Path</div>
|
||||||
|
<div className="font-mono text-xs break-all rounded bg-background/60 border px-2 py-1">
|
||||||
|
{args.path}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">Reason</div>
|
||||||
|
<div className="text-sm leading-snug whitespace-pre-wrap">{args.reason}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground pt-1">
|
||||||
|
Allow grants the agent read access to the matching repository root for
|
||||||
|
the rest of this session. Revoke any time from the session settings.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 border-t border-amber-500/20 px-4 py-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={submitting !== null}
|
||||||
|
onClick={() => void decide('deny')}
|
||||||
|
>
|
||||||
|
{submitting === 'deny' ? 'Denying…' : 'Deny'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={submitting !== null}
|
||||||
|
onClick={() => void decide('allow')}
|
||||||
|
>
|
||||||
|
{submitting === 'allow' ? 'Allowing…' : 'Allow'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnsweredView({ args, output }: { args: ParsedArgs; output: unknown }) {
|
||||||
|
const variant = decisionVariant(output);
|
||||||
|
const text = typeof output === 'string' ? output : 'unknown';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
variant === 'granted'
|
||||||
|
? 'rounded-lg border border-emerald-500/40 bg-emerald-500/5 text-sm'
|
||||||
|
: variant === 'denied'
|
||||||
|
? 'rounded-lg border bg-muted/20 text-sm'
|
||||||
|
: 'rounded-lg border border-destructive/40 bg-destructive/5 text-sm'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="px-4 py-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs uppercase tracking-wide">
|
||||||
|
{variant === 'granted' ? (
|
||||||
|
<>
|
||||||
|
<Check className="size-3.5 text-emerald-600" />
|
||||||
|
<span className="text-emerald-700 dark:text-emerald-300">Read access granted</span>
|
||||||
|
</>
|
||||||
|
) : variant === 'denied' ? (
|
||||||
|
<>
|
||||||
|
<ShieldOff className="size-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">Read access denied</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ShieldOff className="size-3.5 text-destructive" />
|
||||||
|
<span className="text-destructive">Read access request — unknown result</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">Path</div>
|
||||||
|
<div className="font-mono text-xs break-all rounded bg-background/60 border px-2 py-1">
|
||||||
|
{args.path}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{variant === 'granted' && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">Granted root</div>
|
||||||
|
<div className="font-mono text-xs break-all rounded bg-background/60 border px-2 py-1 flex items-center gap-1.5">
|
||||||
|
<FolderOpen className="size-3 shrink-0 text-muted-foreground" />
|
||||||
|
<span>{text.replace(/^granted:\s*/, '')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{variant === 'denied' && text !== 'denied' && (
|
||||||
|
<div className="text-[11px] text-muted-foreground">
|
||||||
|
{text.replace(/^denied:\s*/, '')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import { terminalsRegistry } from '@/lib/events';
|
|||||||
import { ChatPane } from '@/components/panes/ChatPane';
|
import { ChatPane } from '@/components/panes/ChatPane';
|
||||||
import { SettingsPane } from '@/components/panes/SettingsPane';
|
import { SettingsPane } from '@/components/panes/SettingsPane';
|
||||||
import { TerminalPane } from '@/components/panes/TerminalPane';
|
import { TerminalPane } from '@/components/panes/TerminalPane';
|
||||||
|
import { MarkdownArtifactPane } from '@/components/MarkdownArtifactPane';
|
||||||
|
import { HtmlArtifactPane } from '@/components/HtmlArtifactPane';
|
||||||
import { ChatTabBar } from '@/components/ChatTabBar';
|
import { ChatTabBar } from '@/components/ChatTabBar';
|
||||||
import { SessionLandingPage } from '@/components/SessionLandingPage';
|
import { SessionLandingPage } from '@/components/SessionLandingPage';
|
||||||
import {
|
import {
|
||||||
@@ -182,6 +184,7 @@ export function Workspace({
|
|||||||
{panes.map((pane, idx) => {
|
{panes.map((pane, idx) => {
|
||||||
const isSettings = pane.kind === 'settings';
|
const isSettings = pane.kind === 'settings';
|
||||||
const isTerminal = pane.kind === 'terminal';
|
const isTerminal = pane.kind === 'terminal';
|
||||||
|
const isArtifact = pane.kind === 'markdown_artifact' || pane.kind === 'html_artifact';
|
||||||
// v1.9: when maximized, hide every pane except the settings one.
|
// v1.9: when maximized, hide every pane except the settings one.
|
||||||
// display:none keeps the React tree mounted so streams / drafts
|
// display:none keeps the React tree mounted so streams / drafts
|
||||||
// survive the toggle without re-mount cost.
|
// survive the toggle without re-mount cost.
|
||||||
@@ -195,7 +198,7 @@ export function Workspace({
|
|||||||
}
|
}
|
||||||
// Terminal panes own their tab strip (no chats, no ChatTabBar) and
|
// Terminal panes own their tab strip (no chats, no ChatTabBar) and
|
||||||
// are not drag-reorderable for now — keeps the layout grid simple.
|
// are not drag-reorderable for now — keeps the layout grid simple.
|
||||||
const isChromeless = isSettings || isTerminal;
|
const isChromeless = isSettings || isTerminal || isArtifact;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={pane.id}
|
key={pane.id}
|
||||||
@@ -318,6 +321,18 @@ export function Workspace({
|
|||||||
label={terminalLabels.get(pane.id) ?? 'Terminal'}
|
label={terminalLabels.get(pane.id) ?? 'Terminal'}
|
||||||
active={idx === activePaneIdx}
|
active={idx === activePaneIdx}
|
||||||
/>
|
/>
|
||||||
|
) : pane.kind === 'markdown_artifact' && pane.markdown_artifact_state ? (
|
||||||
|
<MarkdownArtifactPane
|
||||||
|
chatId={pane.markdown_artifact_state.chat_id}
|
||||||
|
state={pane.markdown_artifact_state}
|
||||||
|
onClose={() => removePane(idx)}
|
||||||
|
/>
|
||||||
|
) : pane.kind === 'html_artifact' && pane.html_artifact_state ? (
|
||||||
|
<HtmlArtifactPane
|
||||||
|
chatId={pane.html_artifact_state.chat_id}
|
||||||
|
state={pane.html_artifact_state}
|
||||||
|
onClose={() => removePane(idx)}
|
||||||
|
/>
|
||||||
) : pane.kind === 'chat' && pane.chatId ? (
|
) : pane.kind === 'chat' && pane.chatId ? (
|
||||||
<ChatPane
|
<ChatPane
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Archive, Maximize2, Minimize2, X } from 'lucide-react';
|
import { Archive, FolderOpen, Maximize2, Minimize2, Trash2, X } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { Project, Session } from '@/api/types';
|
import type { Project, Session } from '@/api/types';
|
||||||
@@ -269,6 +269,8 @@ function SessionSection({ session, project }: { session: Session; project: Proje
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AllowedReadPathsSection session={session} />
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
@@ -337,6 +339,76 @@ function SessionSection({ session, project }: { session: Session; project: Proje
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.13.17-cross-repo-reads: revoke UI for session.allowed_read_paths.
|
||||||
|
// Append happens through the inline request_read_access pause flow; this
|
||||||
|
// section only shrinks the list. PATCH /api/sessions/:id replaces the
|
||||||
|
// whole array, so we send the original list minus the deleted entry.
|
||||||
|
function AllowedReadPathsSection({ session }: { session: Session }) {
|
||||||
|
const [paths, setPaths] = useState<string[]>(session.allowed_read_paths);
|
||||||
|
const [pendingDelete, setPendingDelete] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Re-sync on session prop change (e.g. WS session_updated after a new
|
||||||
|
// grant lands). Without this, a grant approved in this same chat wouldn't
|
||||||
|
// appear in the list until the user closes and reopens settings.
|
||||||
|
useEffect(() => {
|
||||||
|
setPaths(session.allowed_read_paths);
|
||||||
|
}, [session.id, session.allowed_read_paths]);
|
||||||
|
|
||||||
|
async function remove(path: string) {
|
||||||
|
if (pendingDelete) return;
|
||||||
|
setPendingDelete(path);
|
||||||
|
const next = paths.filter((p) => p !== path);
|
||||||
|
try {
|
||||||
|
const updated = await api.sessions.update(session.id, { allowed_read_paths: next });
|
||||||
|
setPaths(updated.allowed_read_paths);
|
||||||
|
toast.success('Grant revoked');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'failed to revoke');
|
||||||
|
} finally {
|
||||||
|
setPendingDelete(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Cross-repo read grants
|
||||||
|
</label>
|
||||||
|
{paths.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground italic">
|
||||||
|
The agent has no access outside this project. Grants are created when
|
||||||
|
the agent asks for them inline.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{paths.map((p) => (
|
||||||
|
<li
|
||||||
|
key={p}
|
||||||
|
className="flex items-center gap-2 rounded border bg-background/60 px-2 py-1.5"
|
||||||
|
>
|
||||||
|
<FolderOpen className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="font-mono text-xs flex-1 min-w-0 break-all">{p}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void remove(p)}
|
||||||
|
disabled={pendingDelete !== null}
|
||||||
|
aria-label={`Revoke ${p}`}
|
||||||
|
title="Revoke"
|
||||||
|
className="inline-flex items-center justify-center size-7 rounded text-muted-foreground hover:bg-muted hover:text-destructive disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Grants are session-scoped. Archiving the session clears them.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ProjectSection({ project }: { project: Project }) {
|
function ProjectSection({ project }: { project: Project }) {
|
||||||
const [name, setName] = useState(project.name);
|
const [name, setName] = useState(project.name);
|
||||||
const [defaultPrompt, setDefaultPrompt] = useState(project.default_system_prompt);
|
const [defaultPrompt, setDefaultPrompt] = useState(project.default_system_prompt);
|
||||||
|
|||||||
@@ -2,7 +2,14 @@
|
|||||||
// across hooks (e.g. AI rename arriving via WS in the session view needs to
|
// across hooks (e.g. AI rename arriving via WS in the session view needs to
|
||||||
// also refresh the sidebar's session list).
|
// also refresh the sidebar's session list).
|
||||||
|
|
||||||
import type { Chat, ErrorReason, Project, Session } from '@/api/types';
|
import type {
|
||||||
|
Chat,
|
||||||
|
ErrorReason,
|
||||||
|
HtmlArtifactState,
|
||||||
|
MarkdownArtifactState,
|
||||||
|
Project,
|
||||||
|
Session,
|
||||||
|
} from '@/api/types';
|
||||||
import type { Attachment } from '@/lib/attachments';
|
import type { Attachment } from '@/lib/attachments';
|
||||||
|
|
||||||
export interface SessionRenamedEvent {
|
export interface SessionRenamedEvent {
|
||||||
@@ -68,6 +75,19 @@ export interface OpenChatInActivePaneEvent {
|
|||||||
chat_id: string;
|
chat_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.14.x-html-artifact-panes: ActionRow's "Open in pane" button emits one of
|
||||||
|
// these; useWorkspacePanes subscribes and inserts the corresponding artifact
|
||||||
|
// pane (or focuses an existing one keyed by message_id).
|
||||||
|
export interface OpenMarkdownArtifactPaneEvent {
|
||||||
|
type: 'open_markdown_artifact_pane';
|
||||||
|
state: MarkdownArtifactState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenHtmlArtifactPaneEvent {
|
||||||
|
type: 'open_html_artifact_pane';
|
||||||
|
state: HtmlArtifactState;
|
||||||
|
}
|
||||||
|
|
||||||
// Client-side event fired by the sidebar Settings button when a session is
|
// Client-side event fired by the sidebar Settings button when a session is
|
||||||
// currently mounted. Session.tsx subscribes and calls
|
// currently mounted. Session.tsx subscribes and calls
|
||||||
// panesHook.toggleSettingsPane() (open on first click, close on second).
|
// panesHook.toggleSettingsPane() (open on first click, close on second).
|
||||||
@@ -154,6 +174,8 @@ export type SessionEvent =
|
|||||||
| OpenFileInBrowserEvent
|
| OpenFileInBrowserEvent
|
||||||
| AttachChatFileEvent
|
| AttachChatFileEvent
|
||||||
| OpenChatInActivePaneEvent
|
| OpenChatInActivePaneEvent
|
||||||
|
| OpenMarkdownArtifactPaneEvent
|
||||||
|
| OpenHtmlArtifactPaneEvent
|
||||||
| OpenSettingsPaneEvent
|
| OpenSettingsPaneEvent
|
||||||
| SessionArchivedEvent
|
| SessionArchivedEvent
|
||||||
| ChatCreatedEvent
|
| ChatCreatedEvent
|
||||||
|
|||||||
@@ -154,6 +154,11 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
|
|||||||
case 'open_chat_in_active_pane':
|
case 'open_chat_in_active_pane':
|
||||||
// Consumed by Workspace; sidebar has no business with pane state.
|
// Consumed by Workspace; sidebar has no business with pane state.
|
||||||
return prev;
|
return prev;
|
||||||
|
case 'open_markdown_artifact_pane':
|
||||||
|
case 'open_html_artifact_pane':
|
||||||
|
// v1.14.x-html-artifact-panes: consumed by useWorkspacePanes; sidebar
|
||||||
|
// has no business with pane state.
|
||||||
|
return prev;
|
||||||
case 'open_settings_pane':
|
case 'open_settings_pane':
|
||||||
// Consumed by Session.tsx (calls toggleSettingsPane on its panesHook).
|
// Consumed by Session.tsx (calls toggleSettingsPane on its panesHook).
|
||||||
// Sidebar data is untouched.
|
// Sidebar data is untouched.
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
import type { DragEvent } from 'react';
|
import type { DragEvent } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { WorkspacePane } from '@/api/types';
|
import type {
|
||||||
|
HtmlArtifactState,
|
||||||
|
MarkdownArtifactState,
|
||||||
|
WorkspacePane,
|
||||||
|
} from '@/api/types';
|
||||||
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
|
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
|
|
||||||
@@ -43,6 +47,28 @@ function settingsPane(): WorkspacePane {
|
|||||||
return { id: generateId(), kind: 'settings', chatIds: [], activeChatIdx: -1 };
|
return { id: generateId(), kind: 'settings', chatIds: [], activeChatIdx: -1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.14.x-html-artifact-panes: artifact pane factories. Payload travels with
|
||||||
|
// the pane row so the sessions.workspace_panes jsonb survives reload.
|
||||||
|
function markdownArtifactPane(state: MarkdownArtifactState): WorkspacePane {
|
||||||
|
return {
|
||||||
|
id: generateId(),
|
||||||
|
kind: 'markdown_artifact',
|
||||||
|
chatIds: [],
|
||||||
|
activeChatIdx: -1,
|
||||||
|
markdown_artifact_state: state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlArtifactPane(state: HtmlArtifactState): WorkspacePane {
|
||||||
|
return {
|
||||||
|
id: generateId(),
|
||||||
|
kind: 'html_artifact',
|
||||||
|
chatIds: [],
|
||||||
|
activeChatIdx: -1,
|
||||||
|
html_artifact_state: state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// v1.9: settings panes are ephemeral. Filter them out before persisting so a
|
// v1.9: settings panes are ephemeral. Filter them out before persisting so a
|
||||||
// page reload always returns to a clean workspace; the user re-opens via the
|
// page reload always returns to a clean workspace; the user re-opens via the
|
||||||
// sidebar Settings button when needed.
|
// sidebar Settings button when needed.
|
||||||
@@ -169,6 +195,50 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
});
|
});
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
|
// v1.14.x-html-artifact-panes: ActionRow's "Open in pane" emits one of
|
||||||
|
// these per click. If a pane already exists for the same message_id, focus
|
||||||
|
// it instead of stacking a duplicate. Otherwise append (capped at MAX_PANES;
|
||||||
|
// settings panes don't count, matching addSplitPane's rule).
|
||||||
|
useEffect(() => {
|
||||||
|
return sessionEvents.subscribe((ev) => {
|
||||||
|
if (
|
||||||
|
ev.type !== 'open_markdown_artifact_pane' &&
|
||||||
|
ev.type !== 'open_html_artifact_pane'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPanes((prev) => {
|
||||||
|
const targetKind: WorkspacePane['kind'] =
|
||||||
|
ev.type === 'open_html_artifact_pane' ? 'html_artifact' : 'markdown_artifact';
|
||||||
|
const messageId = ev.state.message_id;
|
||||||
|
const existingIdx = prev.findIndex((p) =>
|
||||||
|
p.kind === 'markdown_artifact'
|
||||||
|
? p.markdown_artifact_state?.message_id === messageId
|
||||||
|
: p.kind === 'html_artifact'
|
||||||
|
? p.html_artifact_state?.message_id === messageId
|
||||||
|
: false,
|
||||||
|
);
|
||||||
|
if (existingIdx >= 0) {
|
||||||
|
setActivePaneIdx(existingIdx);
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
if (nonSettingsCount(prev) >= MAX_PANES) {
|
||||||
|
toast.error(`Maximum ${MAX_PANES} panes`);
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const newPane =
|
||||||
|
ev.type === 'open_html_artifact_pane'
|
||||||
|
? htmlArtifactPane(ev.state)
|
||||||
|
: markdownArtifactPane(ev.state);
|
||||||
|
// Defensive: assert kind matches for the discriminated union.
|
||||||
|
if (newPane.kind !== targetKind) return prev;
|
||||||
|
const next = [...prev, newPane];
|
||||||
|
setActivePaneIdx(next.length - 1);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// v1.12.1: debounced PATCH on every change. Settings panes are stripped
|
// v1.12.1: debounced PATCH on every change. Settings panes are stripped
|
||||||
// before saving (ephemeral per v1.9).
|
// before saving (ephemeral per v1.9).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# BooCode v1.x — Roadmap
|
# BooCode v1.x — Roadmap
|
||||||
|
|
||||||
Last updated: 2026-05-22
|
Last updated: 2026-05-23
|
||||||
|
|
||||||
> **Companion doc:** `boocode_code_review.md` holds the full external-repo inventory, lift rationale, and license analysis. This document is the canonical source for shipping state, version ordering, and what's planned vs. shipped.
|
> **Companion doc:** `boocode_code_review.md` holds the full external-repo inventory, lift rationale, and license analysis. This document is the canonical source for shipping state, version ordering, and what's planned vs. shipped.
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ External code lifted from / referenced in: see `boocode_code_review.md` for full
|
|||||||
|
|
||||||
-----
|
-----
|
||||||
|
|
||||||
## Shipped (status as of 2026-05-22)
|
## Shipped (status as of 2026-05-23)
|
||||||
|
|
||||||
|Version |Theme |Tag |
|
|Version |Theme |Tag |
|
||||||
|-----------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------|
|
|-----------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------|
|
||||||
@@ -72,9 +72,9 @@ External code lifted from / referenced in: see `boocode_code_review.md` for full
|
|||||||
|
|
||||||
-----
|
-----
|
||||||
|
|
||||||
### Shipped (v1.13.x — written 2026-05-22, retagged same day)
|
### Shipped (v1.13.x — strangler-fig closed 2026-05-23)
|
||||||
|
|
||||||
All v1.13.x batches were retagged to the `vMAJOR.MINOR.PATCH-slug` scheme on 2026-05-22. `CHANGELOG.md` is the canonical per-tag record (slug describes what shipped; tag name alone recalls the batch). Tip is `v1.13.14-skills-audit` (`0fa46cd`); the next batch is `v1.13.15-codecontext-synth` (this batch, tag pending). Tags in chronological order:
|
All v1.13.x batches use the `vMAJOR.MINOR.PATCH-slug` tag scheme adopted 2026-05-22. `CHANGELOG.md` is the canonical per-tag record (slug describes what shipped; tag name alone recalls the batch). The v1.13.x line ran 21 batches over a single intense window; the umbrella `v1.13` tag sits on `211e903` (same commit as `v1.13.20-drop-legacy-cols`), marking the strangler-fig closed. Tags in chronological order:
|
||||||
|
|
||||||
- `v1.13.0-ai-sdk-v6` — AI SDK v6 migration; `streamCompletion` adapter; `messages_with_parts` view; reasoning_parts end-to-end
|
- `v1.13.0-ai-sdk-v6` — AI SDK v6 migration; `streamCompletion` adapter; `messages_with_parts` view; reasoning_parts end-to-end
|
||||||
- `v1.13.1-cleanup-bundle` — `statement_timeout='30s'`, alpha-sorted tool registry, 60s stuck-row sweeper, `experimental_repairToolCall` pass-through
|
- `v1.13.1-cleanup-bundle` — `statement_timeout='30s'`, alpha-sorted tool registry, 60s stuck-row sweeper, `experimental_repairToolCall` pass-through
|
||||||
@@ -93,113 +93,13 @@ All v1.13.x batches were retagged to the `vMAJOR.MINOR.PATCH-slug` scheme on 202
|
|||||||
- `v1.13.14-skills-audit` — 26 skills vendored + audited via 5 parallel agent teams; 14 kept, 11 dropped, 1 migrated to BOOCHAT.md/BOOCODER.md
|
- `v1.13.14-skills-audit` — 26 skills vendored + audited via 5 parallel agent teams; 14 kept, 11 dropped, 1 migrated to BOOCHAT.md/BOOCODER.md
|
||||||
- `v1.13.15-codecontext-synth` — forced second-inference synthesis pass for codecontext overview tools (truncation-aware extraction; auto-fetched top-N files + project docs; 32k payload-budget contract preserved)
|
- `v1.13.15-codecontext-synth` — forced second-inference synthesis pass for codecontext overview tools (truncation-aware extraction; auto-fetched top-N files + project docs; 32k payload-budget contract preserved)
|
||||||
- `v1.13.16-xml-parser` — Anthropic `<invoke>` parser support + Levenshtein-based unknown-tool recovery hints (qwen3.6 drift to Claude Code-style tool names like `read_file`); xml-parser test coverage
|
- `v1.13.16-xml-parser` — Anthropic `<invoke>` parser support + Levenshtein-based unknown-tool recovery hints (qwen3.6 drift to Claude Code-style tool names like `read_file`); xml-parser test coverage
|
||||||
|
- `v1.13.17-cross-repo-reads` — `request_read_access` tool + per-session `allowed_read_paths` grants; `pathGuard` extended with `extraRoots`; pause/resume reuses the `ask_user_input` mechanism
|
||||||
|
- `v1.13.18-codecontext-file-path` — `resolveProjectPath` in `codecontext_client.ts` realpath-resolves `file_path` arg the same way `target_dir` was; closes the silent-fail path the sidecar exhibited on relative paths
|
||||||
|
- `v1.13.19-html-artifact-panes` — pane-based artifact viewer with on-request HTML; `<!DOCTYPE html>` detection adds `message_parts.kind='html_artifact'` row; Markdown + HTML panes both open via "Open in pane" affordance; iframe sandbox `allow-scripts allow-clipboard-write allow-downloads` (no `allow-same-origin`, `srcDoc`); CSP `connect-src 'none'`. Scope-revised mid-design from auto-bias-to-HTML to Markdown-default / HTML-on-request
|
||||||
|
- `v1.13.20-drop-legacy-cols` — final strangler-fig step. Drops `messages.tool_calls` + `tool_results` columns; 10 dual-write sites removed (recon caught 2 beyond the original roadmap inventory); `messages_with_parts` view simplified to parts-only subselects via `CREATE OR REPLACE` before the column DROPs (Postgres ordering constraint). Adversarial-review catch: `discard_stale` had a `RETURNING tool_calls, tool_results` clause; fixed via two-step UPDATE-then-SELECT-from-view. `Message` API type retains the fields — view synthesizes them from parts so the wire shape is unchanged
|
||||||
|
- `v1.13` — **umbrella tag on the same commit as v1.13.20.** Marks the AI SDK v6 + parts-table migration complete
|
||||||
|
|
||||||
The remaining strangler-fig final step (drop `messages.tool_calls` + `tool_results` columns) is still pending under its old `v1.13.2` working name; will get a new tag slug when scoped.
|
The v1.13.x line is closed. Three batches still sit in the **In flight** column conceptually but none of them are v1.13.x scope: **live-smoke of v1.13.19** (manual browser exercise of the artifact panes — five minutes, independent), and the two v1.14 branches below. Independent siblings (`v1.14.x-mcp`, `v1.14.x-html`, `v1.16`) can ship in any order relative to v1.14 itself.
|
||||||
|
|
||||||
## In flight / next (v1.13.x cleanup line)
|
|
||||||
|
|
||||||
Five more single-dispatch batches before the strangler-fig closes. Each ships independently with its own smoke and rollback surface. **Do not fold.** Order is locked:
|
|
||||||
|
|
||||||
### v1.13.8 — system-prompt prefix stability verify-and-measure (REFRAMED, 2026-05-22)
|
|
||||||
|
|
||||||
**Original plan:** add a `system_prompt_cache` DB table keyed by `(agent_id, project_id, skills_version)`, mtime-invalidated.
|
|
||||||
|
|
||||||
**Why reframed:** recon disproved the premise. `apps/server/src/services/system-prompt.ts:buildSystemPrompt` already runs over mtime-cached inputs at the file layer:
|
|
||||||
|
|
||||||
- BOOCHAT.md / BOOCODER.md cached in `system-prompt.ts:25` (`cachedGuidance`, keyed by mtime)
|
|
||||||
- global + per-project AGENTS.md cached in `agents.ts:245` (`safeStat` pattern, 60s TTL)
|
|
||||||
- `session.system_prompt` / `project.default_system_prompt` are DB scalars (byte-stable until edited)
|
|
||||||
- BASE_SYSTEM_PROMPT is a hardcoded template with `${projectPath}` interpolation
|
|
||||||
|
|
||||||
Output assembly is a microsecond pure-string concat with no I/O. Skills aren't in the prefix (runtime discovery via `skill_find`). Tools live in a separate request body field, alpha-sorted by v1.13.3. **In theory the prefix is already byte-stable across turns; nothing has measured it.**
|
|
||||||
|
|
||||||
**New scope — instrumentation only, no cache:**
|
|
||||||
|
|
||||||
1. SHA-256 fingerprint of `buildSystemPrompt`'s output logged per turn at `level=info`, msg `prefix-fingerprint`, with project_id / agent_id / session_id / prefix_hash / prefix_length / mtime fields.
|
|
||||||
2. Module-level `Map<sessionId, lastHash>` observer. On hash change for a known session → emit `prefix-drift` at `level=warn` with `prev_hash`, `new_hash`, and a field-level `changed_inputs` diff.
|
|
||||||
3. Unit-level byte-stability assertion in `system-prompt.test.ts`: two consecutive `buildSystemPrompt` calls with the same inputs return byte-identical strings.
|
|
||||||
|
|
||||||
**Decision criterion:** smoke 5 turns in a fresh session. 5 identical hashes + zero drift logs → close v1.13.8 as no-op, **drop the DB cache plan permanently**, move to v1.13.9. If drift surfaces → characterize the failure mode in a follow-up batch (the answer may not be a cache at all).
|
|
||||||
|
|
||||||
**Doctrine:** matches the v1.13.6 audit pattern. Don't add infrastructure without a proven cache miss. The v1.12.0 mtime caches at the input layer plus alpha tool ordering at the request body layer already address the load-bearing cache-stability surfaces.
|
|
||||||
|
|
||||||
**Dispatch brief:** `handoff_v1.13.8_prefix_verify.md`.
|
|
||||||
|
|
||||||
**Estimated:** ~95 LoC (system-prompt.ts + small `getAgentsMtimes` accessor in agents.ts + 3 new tests).
|
|
||||||
|
|
||||||
### v1.13.9 — compaction overflow trigger formula
|
|
||||||
|
|
||||||
opencode pattern: `0.85 * ctx_max` early trigger (not at 100% saturation). Reduces tail-loss risk and gives compaction a safer window. Tiny change but tied to v1.13.4's tier logic — sequence matters.
|
|
||||||
|
|
||||||
**Lift source:** `anomalyco/opencode` `session/overflow.ts`.
|
|
||||||
|
|
||||||
**Estimated:** ~30 LoC.
|
|
||||||
|
|
||||||
### v1.13.10 — per-tool token cost accounting
|
|
||||||
|
|
||||||
Rolling average per tool, surfaced in AgentPicker tooltip + agent-pick decisions. Backend tracks `(tool_name, prompt_tokens_in, completion_tokens_out)` per call; surfaces a 100-call rolling mean. Frontend reads it for tool-cost hints. **Depends on v1.13.7's `includeUsage` fix** — without real token numbers in DB rows, the rolling average is empty.
|
|
||||||
|
|
||||||
**Estimated:** ~250 LoC.
|
|
||||||
|
|
||||||
### v1.13.11 — WebSocket frame typing
|
|
||||||
|
|
||||||
Zod schemas validated both ends. Catches the recurring class of bug that drove the 2026-05-21 debugging spike (silent protocol drift). Upfront work that pays back every time the protocol changes. `chat_status`, `usage`, `parts_appended`, `session_workspace_updated`, `tool_running` — every frame gets a Zod schema, every send/receive site validates.
|
|
||||||
|
|
||||||
**Estimated:** ~300 LoC.
|
|
||||||
|
|
||||||
### v1.13.12 — skills audit pass (NEW, 2026-05-22)
|
|
||||||
|
|
||||||
**Goal:** apply the rules→recipes split (per Codeminer42 activation-gap data: plain skills invoke 6% in clean multi-turn, `CLAUDE.md`/`AGENTS.md` is 100% present) to BooCode's 7 vendored v1.12 skills. Sort each into: (a) move to `AGENTS.md` as always-true rule, (b) keep as recipe invoked via `/skill <name>`, (c) move bulky context into `references/` flat subdirectory inside the skill, (d) delete (Claude already does it reliably).
|
|
||||||
|
|
||||||
**Scope:**
|
|
||||||
|
|
||||||
1. **Audit each of the 7 vendored skills against the 4-way split.** Most workflow-rule content ("always do X before Y", "never do Z") moves to `AGENTS.md` since it should be 100% present. Recipe content ("here's how to scaffold a component", "here's the release checklist") stays as skill, gets `context: fork` if heavy.
|
|
||||||
1. **Adopt Anthropic best-practices conventions** for any skills that remain after audit: gerund names (`scaffolding-components`, not `component-helper`), SKILL.md ≤500 lines, references one level deep, third-person imperative voice, MCP tool references in `ServerName:tool_name` format, no Windows-style paths, no time-sensitive info, consistent terminology, no "voodoo constants."
|
|
||||||
1. **Run each remaining skill through the 4-step validation protocol** from `mgechev/skills-best-practices` (Discovery → Logic → Edge Case → Architecture Refinement) using a fresh Claude chat per step. Prompts are paste-ready; ~10 minutes per skill.
|
|
||||||
1. **Install `skillgrade` on Sam's host** (`npm i -g skillgrade`). For each remaining skill, write a minimal `eval.yaml` with 2–3 tasks and run `skillgrade --smoke` (5 trials, ~5 min) to confirm the skill triggers when expected and produces correct output. **Likely outcome: some skills show 0–20% trigger rate — confirms they belong in AGENTS.md, not as skills.**
|
|
||||||
1. **Document the rules→recipes split as a BooCode convention** in `BOOCODER.md` / `BOOCHAT.md`. Future-proofs against re-adding workflow rules as skills.
|
|
||||||
|
|
||||||
**Lift sources:**
|
|
||||||
|
|
||||||
- `blog.codeminer42.com/stop-putting-best-practices-in-skills/` — empirical 6%/33%/66%/100% invocation-rate data with Vercel-style multi-turn methodology. The activation-gap framing.
|
|
||||||
- `mgechev/skills-best-practices` (25 stars, MIT) — 4-step validation protocol with paste-ready prompts. Directory structure conventions.
|
|
||||||
- `mgechev/skillgrade` (132 stars, MIT) — agent-agnostic skill eval framework. `eval.yaml` task+grader schema. Smoke/reliable/regression presets.
|
|
||||||
- `platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices` — canonical Anthropic standard. 500-line ceiling, gerund naming, progressive disclosure patterns, MCP tool reference format, verification checklist.
|
|
||||||
|
|
||||||
**Dependencies:** none (the 7 v1.12 skills already exist; this is an audit pass on shipped material). Can ship at any point in the v1.13.x line.
|
|
||||||
|
|
||||||
**Estimated:** zero code changes, ~one evening of audit work, plus skillgrade install. Per-skill eval.yaml authoring is ~30 min per skill including the 4-step validation. Total roughly 5–6 hours of focused work for all 7 skills.
|
|
||||||
|
|
||||||
### v1.13.2 — drop legacy columns (final phase of strangler-fig)
|
|
||||||
|
|
||||||
**Wait at least one week of production traffic on v1.13.1 before shipping.** The dual-write is rollback insurance. Drop the columns and that rollback is gone.
|
|
||||||
|
|
||||||
**Verification query before shipping:**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
COUNT(*) FILTER (WHERE m.tool_calls IS NOT NULL AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM message_parts p WHERE p.message_id = m.id AND p.kind = 'tool_call'
|
|
||||||
)) AS missing_tool_call_parts,
|
|
||||||
COUNT(*) FILTER (WHERE m.tool_results IS NOT NULL AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM message_parts p WHERE p.message_id = m.id AND p.kind = 'tool_result'
|
|
||||||
)) AS missing_tool_result_parts
|
|
||||||
FROM messages m
|
|
||||||
WHERE m.created_at > '2026-05-22'::timestamptz;
|
|
||||||
```
|
|
||||||
|
|
||||||
Both columns must read 0.
|
|
||||||
|
|
||||||
**Scope (~150 LoC, mostly deletions):**
|
|
||||||
|
|
||||||
1. Remove dual-write from every v1.13.0 site: `tool-phase.ts` (3 sites), `finalizeCompletion`, `skills.ts` (2 sites), `messages.ts` answer flow, `chats.ts` (fork). Keep only the parts write.
|
|
||||||
1. Simplify `messages_with_parts` view — drop COALESCE fallbacks since legacy columns are about to disappear.
|
|
||||||
1. `ALTER TABLE messages DROP COLUMN tool_calls, DROP COLUMN tool_results`.
|
|
||||||
1. Remove `tool_calls`/`tool_results` fields from `Message` API type. API boundary unchanged (frontend already reads parts-derived values).
|
|
||||||
1. Drop the stale `messages_status_check` cleanup DO block from v1.12.1 schema if still present.
|
|
||||||
1. Update test fixtures in `inference.test.ts` and `compaction.test.ts` to construct parts instead of inline `tool_calls: null, tool_results: null` literals. ~30 fixture rewrites.
|
|
||||||
|
|
||||||
After v1.13.2 ships, tag the umbrella `v1.13` on the same commit (or on -C — Sam's call).
|
|
||||||
|
|
||||||
-----
|
-----
|
||||||
|
|
||||||
@@ -263,45 +163,52 @@ After v1.13.2 ships, tag the umbrella `v1.13` on the same commit (or on -C — S
|
|||||||
|
|
||||||
-----
|
-----
|
||||||
|
|
||||||
## v1.14.x-html — HTML artifacts in BooChat (NEW, 2026-05-22)
|
## v1.14.x-html — pane-based artifact viewer with Markdown + HTML (REVISED, 2026-05-23)
|
||||||
|
|
||||||
**Goal:** integrate Thariq Shihipar's "HTML > Markdown for agent output at length" pattern (`claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html`, May 20 2026) into BooChat. Bias the model toward HTML for outputs >100 lines: information density, visual clarity, interactive controls (sliders/knobs/SVG diagrams/side-by-side comparisons), shareability. BooChat already renders into a webview, so the surface fit is unusually good.
|
**Goal:** every assistant message gets an "Open in pane" affordance that renders it as an artifact — Markdown by default (the model's normal output), HTML only when the user explicitly asks for it (e.g. "render this as HTML", "make me a dashboard", "build an interactive diagram"). Both artifact types open in BooChat's existing workspace splitter. Markdown panes have **Copy** (raw source) + **Download** (`.md`); HTML panes have **Download** (`.html`) only. No inline iframe preview — artifacts are pane-only.
|
||||||
|
|
||||||
|
Inspired by Thariq Shihipar's "HTML > Markdown at length" pattern (`claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html`, May 20 2026), but scoped down from that post's "auto-bias to HTML for >100 lines" recommendation: Markdown stays the default everywhere, HTML is an on-request rendering target for cases where interactive controls / diagrams / side-by-side layouts pay off.
|
||||||
|
|
||||||
**Scope:**
|
**Scope:**
|
||||||
|
|
||||||
1. **Model-side prompting** (no code change yet, just AGENTS.md guidance):
|
1. **Model-side prompting** (no code change, just AGENTS.md guidance):
|
||||||
- Add HTML-bias rule to global `AGENTS.md`: "For outputs >100 lines, default to a self-contained `<!DOCTYPE html>...</html>` artifact unless the user explicitly asks for Markdown. For outputs <100 lines or for short conversational replies, stay in Markdown."
|
- Add HTML-on-request rule to global `AGENTS.md`: "Stay in Markdown by default for all outputs, short or long. Switch to a self-contained `<!DOCTYPE html>...</html>` artifact only when the user explicitly asks (e.g. 'render this as HTML', 'make a dashboard', 'build a diagram')."
|
||||||
- Reasoning shown in the rule: HTML carries diagrams, tabs, illustrations, code-with-syntax-highlighting, interactive controls, mobile-responsive layouts. Markdown is restrictive at any length.
|
- Inline the `web-artifacts-builder` "avoid AI slop" design principles for when HTML is requested: no excessive centered layouts, no purple gradients, no uniform rounded corners, no Inter font, no generic AI aesthetics.
|
||||||
- Cite Thariq's blog post in the rule comment so future audit passes know where it came from.
|
- Cite Thariq's blog post in the rule comment so future audit passes know where the design conventions came from.
|
||||||
1. **Detection at the BooChat backend.** In `apps/chat/services/inference/stream-phase.ts` post-processing: detect any assistant text part starting with `<!DOCTYPE html>` (case-insensitive, whitespace-trimmed) — or wrapped in a fenced ` ```html` block — and tag it as an HTML artifact. Emit a new part kind `html_artifact` into `message_parts` (CHECK constraint update). Payload: `{html_content, char_count, title}`. Title pulled from `<title>` tag or first `<h1>` if available.
|
1. **Detection at the BooChat backend.** In `apps/chat/services/inference/stream-phase.ts` post-processing: detect any assistant text part starting with `<!DOCTYPE html>` (case-insensitive, whitespace-trimmed) — or wrapped in a fenced ` ```html` block — and tag it as an HTML artifact. Emit a new part kind `html_artifact` into `message_parts` (CHECK constraint update). Payload: `{html_content, char_count, title}`. Title pulled from `<title>` tag or first `<h1>` if available. Detection is opportunistic — when the model produces HTML (because the user asked), the tag fires; otherwise the message stays plain-Markdown and no `html_artifact` part is written.
|
||||||
1. **Three render targets (Sam's pick: "3 with a download"):**
|
1. **Pane-only render surface.** Every assistant message in the chat stream gets an "Open in pane" affordance (icon button in the message footer, alongside the existing copy/regenerate controls). Clicking it opens the message as an artifact pane in BooChat's existing workspace splitter, alongside the file viewer and BooTerm. Pane is dismissible. Pane state persisted via `sessions.workspace_panes jsonb` (the v1.12.1 schema already supports this).
|
||||||
- **Inline preview** in the chat stream: small sandboxed iframe (~400px tall), renders the artifact next to where it was streamed. Default size, click-to-expand.
|
- **Markdown pane** — renders via the same Markdown component used inline in `MessageBubble` (so syntax highlighting, fenced code blocks, tables, etc. all work). Header carries **Copy** (writes raw Markdown source to clipboard via `navigator.clipboard.writeText`) and **Download** (`.md`) buttons.
|
||||||
- **Open in pane**: button on the inline preview opens the artifact in a full-height pane in BooChat's existing workspace splitter, alongside the file viewer and BooTerm. Pane is dismissible. Pane state persisted via `sessions.workspace_panes jsonb` (the v1.12.1 schema already supports this).
|
- **HTML pane** — renders the artifact in a sandboxed iframe at full pane height. Header carries **Download** (`.html`) only. **No Copy button** — HTML source isn't useful clipboard content; if the user wants the source they can Download and inspect.
|
||||||
- **Download**: button writes the artifact to `/opt/<project>/.boocode/artifacts/<slug>-<unix-timestamp>.html` (path-guarded same as native write tools), surfaces an OS download link via the existing file-serving path. Filename slug derived from artifact title.
|
1. **Download path & filename slug.** Both formats write to `/opt/<project>/.boocode/artifacts/<slug>-<unix-timestamp>.<ext>` (path-guarded same as native write tools), and surface an OS download link via the existing file-serving path.
|
||||||
1. **Security stance — locked 2026-05-22:** the iframe is sandboxed with `sandbox="allow-scripts allow-clipboard-write allow-downloads"`. **Crucially, omit `allow-same-origin`** so the artifact has its own opaque origin and cannot read BooChat's cookies, Authelia session, or DOM. Backend serves the iframe content via `srcdoc=...` inline (not `src=`) so no separate URL exists to disclose. CSP header on the iframe response: `default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none'`. The `connect-src 'none'` is the key clause — artifacts can't `fetch()`, can't open WebSockets, can't ping a tracking pixel, can't exfiltrate. JS runs (so Thariq's interactive knobs/sliders/copy-as-prompt buttons work) but nothing else network-touching does. **None of Thariq's blog examples need the relaxed permissions** — they're all client-side.
|
- Markdown slug: derived from the message's first heading (`# ...`) if present, else the first 6 words of the message body, lowercased + hyphenated.
|
||||||
1. **Frontend rendering** (`apps/web/src/components/HtmlArtifactPart.tsx`):
|
- HTML slug: derived from the artifact's `<title>` tag if present, else first `<h1>`, else first 6 words of the inner text. Same lowercase-hyphen treatment.
|
||||||
- Inline preview: `<iframe srcdoc={html_content} sandbox="allow-scripts allow-clipboard-write allow-downloads" className="..." />` with the strict-sandbox attributes above.
|
1. **Security stance for HTML pane — locked 2026-05-22:** the iframe is sandboxed with `sandbox="allow-scripts allow-clipboard-write allow-downloads"`. **Crucially, omit `allow-same-origin`** so the artifact has its own opaque origin and cannot read BooChat's cookies, Authelia session, or DOM. Backend serves the iframe content via `srcdoc=...` inline (not `src=`) so no separate URL exists to disclose. CSP header on the iframe response: `default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none'`. The `connect-src 'none'` is the key clause — artifacts can't `fetch()`, can't open WebSockets, can't ping a tracking pixel, can't exfiltrate. JS runs (so interactive knobs/sliders/copy-as-prompt buttons work) but nothing else network-touching does.
|
||||||
- "Open in pane" button: dispatches workspace-pane action with `{type: 'html_artifact', message_part_id, html_content}`.
|
1. **Frontend components:**
|
||||||
- "Download" button: POST to new endpoint `/api/chats/:id/artifacts/:part_id/download` which writes to disk (path-guarded) and returns the absolute path or pre-signed URL for the existing static-file serving route.
|
- `apps/web/src/components/MarkdownArtifactPane.tsx` — pane shell + header (Copy + Download) + Markdown render reusing the existing component.
|
||||||
1. **No artifact persistence beyond the chat.** Artifacts live in `message_parts.payload->>'html_content'` with the chat. Downloads go to `/opt/<project>/.boocode/artifacts/` and are user-managed from there. No separate artifacts table.
|
- `apps/web/src/components/HtmlArtifactPane.tsx` — pane shell + header (Download only) + `<iframe srcdoc={html_content} sandbox="allow-scripts allow-clipboard-write allow-downloads" />`.
|
||||||
1. **Token-budget guard.** Single artifact can be at most 1MB of HTML in `message_parts.payload`. Larger triggers a streaming abort with a friendly error: "Artifact exceeded 1MB; consider splitting into multiple files or reducing inline assets."
|
- `MessageBubble.tsx` — add "Open in pane" affordance to every assistant message footer. Dispatches workspace-pane action `{type: 'markdown_artifact' | 'html_artifact', message_id, html_content?}`. When the message has an `html_artifact` part, the affordance opens as an HTML pane; otherwise it opens as a Markdown pane.
|
||||||
1. **No `web-artifacts-builder` skill vendor.** That skill (`anthropics/skills/web-artifacts-builder`) is built for Claude.ai's runtime with Vite + Parcel + tspaths + html-inline toolchain. BooChat has no shell execution surface. The pattern transplants; the toolchain doesn't. Treat the skill's "avoid AI slop" design principles (no excessive centered layouts, no purple gradients, no uniform rounded corners, no Inter font) as conventions inlined in the HTML-bias AGENTS.md rule. The init/bundle scripts are out of scope.
|
- Download button → POST to new endpoint `/api/chats/:id/messages/:msg_id/artifacts/download?fmt=md|html` which writes to disk (path-guarded) and returns the absolute path or pre-signed URL for the existing static-file serving route.
|
||||||
|
1. **No artifact persistence beyond the chat.** Artifacts live in `message_parts.payload->>'html_content'` (for HTML) or are derived on-demand from the assistant message's content (for Markdown). Downloads go to `/opt/<project>/.boocode/artifacts/` and are user-managed from there. No separate artifacts table.
|
||||||
|
1. **Token-budget guard.** Single HTML artifact can be at most 1MB of HTML in `message_parts.payload`. Larger triggers a streaming abort with a friendly error: "Artifact exceeded 1MB; consider splitting into multiple files or reducing inline assets." Markdown artifacts have no separate cap — they're bounded by the existing message-size envelope.
|
||||||
|
1. **No `web-artifacts-builder` skill vendor.** That skill (`anthropics/skills/web-artifacts-builder`) is built for Claude.ai's runtime with Vite + Parcel + tspaths + html-inline toolchain. BooChat has no shell execution surface. The pattern transplants; the toolchain doesn't. Treat the skill's "avoid AI slop" design principles as conventions inlined in the HTML-on-request AGENTS.md rule. The init/bundle scripts are out of scope.
|
||||||
|
|
||||||
**Lift sources:**
|
**Lift sources:**
|
||||||
|
|
||||||
- `claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html` (Thariq Shihipar, May 20 2026) — the pattern, the use-case taxonomy (specs/code-review/design/reports/custom editors), the design philosophy.
|
- `claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html` (Thariq Shihipar, May 20 2026) — design conventions and use-case taxonomy (specs/code-review/design/reports/custom editors). The "auto-bias for >100 lines" recommendation is deliberately NOT lifted.
|
||||||
- HTML iframe sandbox spec (web platform standard, no license issues).
|
- HTML iframe sandbox spec (web platform standard, no license issues).
|
||||||
- `anthropics/skills/web-artifacts-builder` — design-principle reference only ("avoid AI slop" rules). **Do not vendor the toolchain.**
|
- `anthropics/skills/web-artifacts-builder` — design-principle reference only ("avoid AI slop" rules). **Do not vendor the toolchain.**
|
||||||
|
|
||||||
**Dependencies:** v1.13 merged (`message_parts` table is where artifacts live). Independent of v1.14 (outer loop) and v1.14.x-mcp (MCP PoC). Can ship in any order relative to those.
|
**Dependencies:** v1.13 merged (`message_parts` table is where HTML artifacts live). Independent of v1.14 (outer loop) and v1.14.x-mcp (MCP PoC). Can ship in any order relative to those.
|
||||||
|
|
||||||
**Estimated:** ~400 LoC. Roughly half backend (detection + part-kind extension + download endpoint + path-guard integration), half frontend (HtmlArtifactPart component + pane integration + download button wiring).
|
**Estimated:** ~400 LoC. Roughly half backend (HTML detection + part-kind extension + download endpoint + path-guard integration + Markdown slug derivation) and half frontend (two artifact-pane components + MessageBubble affordance + pane integration + download wiring).
|
||||||
|
|
||||||
**Schema addition:**
|
**Schema addition:**
|
||||||
|
|
||||||
- `message_parts.kind` CHECK constraint adds `'html_artifact'` to the allowed set.
|
- `message_parts.kind` CHECK constraint adds `'html_artifact'` to the allowed set.
|
||||||
|
|
||||||
**Skip-condition:** none — independent batch, ships clean any time after v1.13. Highest user-visible payoff of any v1.13.x/v1.14.x batch (transforms what the model can produce, not just how the backend handles it).
|
**Skip-condition:** none — independent batch, ships clean any time after v1.13. Pane-based artifact view is a structural UX improvement (full-height read surface for long replies, durable download path) on top of the HTML-on-request rendering capability.
|
||||||
|
|
||||||
|
**Shipped as `v1.13.19-html-artifact-panes` on 2026-05-23.** Two scope-revisions during impl: (a) the HTML-on-request rule landed in `BOOCHAT.md` (always-true rules layer), not `data/AGENTS.md` (per-agent registry) — per BOOCHAT.md's own convention block. (b) Pane state stayed reference-only — `{chat_id, message_id, title}` — content fetched on mount via the existing chat-messages endpoint (Markdown) and a new `GET /api/chats/:id/messages/:msg_id/html_artifact` (HTML). Storing content in pane state would have ridden 1MB blobs through the `session_workspace_updated` WS frame and bloated the jsonb column on multi-pane sessions. Defense-in-depth additions beyond the original proposal: `X-Content-Type-Options: nosniff` + `Content-Security-Policy: sandbox` on the GET serve route, and `assertArtifactsDirSafe` realpaths the artifacts dir after `mkdir` to close a symlink-escape gap that would otherwise let a planted symlink under `.boocode/artifacts/` route writes outside the project root. Smoke not run pre-tag; first deploy is the smoke.
|
||||||
|
|
||||||
-----
|
-----
|
||||||
|
|
||||||
@@ -501,8 +408,12 @@ term.indifferentketchup.com → booterm :9501 (or routed under code.
|
|||||||
- **v1.13.12-ws-schemas:** none (Zod schemas + wrappers in TS, no DB)
|
- **v1.13.12-ws-schemas:** none (Zod schemas + wrappers in TS, no DB)
|
||||||
- **v1.13.13-ws-publish:** none (publish-site conversion + protocol-drift fix in `compaction.ts`, no DB)
|
- **v1.13.13-ws-publish:** none (publish-site conversion + protocol-drift fix in `compaction.ts`, no DB)
|
||||||
- **v1.13.14-skills-audit:** none (skills + AGENTS.md migration into git via `.gitignore` negation patterns; no DB)
|
- **v1.13.14-skills-audit:** none (skills + AGENTS.md migration into git via `.gitignore` negation patterns; no DB)
|
||||||
- **v1.13.15-codecontext-synth (this batch, tag pending):** `message_parts.kind` CHECK constraint extended with `'synthesis'` value (DROP + DO $$ pg_constraint idempotency-guarded re-add)
|
- **v1.13.15-codecontext-synth:** `message_parts.kind` CHECK constraint extended with `'synthesis'` value (DROP + DO $$ pg_constraint idempotency-guarded re-add)
|
||||||
- **(column drop, pending — old working name v1.13.2):** drop `messages.tool_calls`, `messages.tool_results`; simplify `messages_with_parts` view
|
- **v1.13.16-xml-parser:** none (parser change + new `tool-suggestions.ts` helper in TS, no DB)
|
||||||
|
- **v1.13.17-cross-repo-reads:** `sessions.allowed_read_paths text[] NOT NULL DEFAULT ARRAY[]::text[]` (per-session cross-repo read grants)
|
||||||
|
- **v1.13.18-codecontext-file-path:** none (path resolver in `codecontext_client.ts`, no DB)
|
||||||
|
- **v1.13.19-html-artifact-panes:** `message_parts.kind` CHECK constraint extended with `'html_artifact'` value (same v1.13.15 pattern)
|
||||||
|
- **v1.13.20-drop-legacy-cols:** `ALTER TABLE messages DROP COLUMN tool_calls, DROP COLUMN tool_results` (the strangler-fig's final phase). `messages_with_parts` view rewritten to parts-only subselects via `CREATE OR REPLACE VIEW` BEFORE the drops (Postgres ordering constraint). v1.12.1 `messages_status_check`/`messages_role_check` cleanup block removed (one-shot effective long ago)
|
||||||
- **v1.14:** `agents.steps` column (or AGENTS.md parser extension; no DB if file-only)
|
- **v1.14:** `agents.steps` column (or AGENTS.md parser extension; no DB if file-only)
|
||||||
- **v1.14.x-mcp (NEW):** none — single-server MCP-client PoC is config-only at first, no schema change
|
- **v1.14.x-mcp (NEW):** none — single-server MCP-client PoC is config-only at first, no schema change
|
||||||
- **v1.14.x-html (NEW):** `message_parts.kind` CHECK constraint extended with `'html_artifact'` value
|
- **v1.14.x-html (NEW):** `message_parts.kind` CHECK constraint extended with `'html_artifact'` value
|
||||||
@@ -612,7 +523,17 @@ Earlier May 18 chat recommended Option A (thin orchestration shell over OpenCode
|
|||||||
|
|
||||||
### v1.13.x cleanup line locked (2026-05-22)
|
### v1.13.x cleanup line locked (2026-05-22)
|
||||||
|
|
||||||
After the 2026-05-22 retag, the v1.13.x cleanup line in `vMAJOR.MINOR.PATCH-slug` form is **v1.13.0-ai-sdk-v6 ✅ → v1.13.1-cleanup-bundle ✅ → v1.13.2-compaction-prune ✅ → v1.13.3-truncate ✅ → v1.13.4-reasoning-fix ✅ → v1.13.5-stability-bundle ✅ → v1.13.6-prefix-stability ✅ → v1.13.7-compaction-trigger ✅ → v1.13.8-tool-cost ✅ → v1.13.9-agentlint ✅ → v1.13.10-openspec ✅ → v1.13.11-tools ✅ → v1.13.12-ws-schemas ✅ → v1.13.13-ws-publish ✅ → v1.13.14-skills-audit ✅ → v1.13.15-codecontext-synth ✅ → v1.13.16-xml-parser ✅ → column drop (final, pending — old working name v1.13.2)**. **Do not fold.** Smoke isolation matters: each batch has a distinct rollback surface, and bisecting a 750-LoC merge across four unrelated changes is worse than four separate dispatches.
|
The v1.13.x cleanup line shipped 21 batches over a single intense window in `vMAJOR.MINOR.PATCH-slug` form: **v1.13.0-ai-sdk-v6 ✅ → v1.13.1-cleanup-bundle ✅ → v1.13.2-compaction-prune ✅ → v1.13.3-truncate ✅ → v1.13.4-reasoning-fix ✅ → v1.13.5-stability-bundle ✅ → v1.13.6-prefix-stability ✅ → v1.13.7-compaction-trigger ✅ → v1.13.8-tool-cost ✅ → v1.13.9-agentlint ✅ → v1.13.10-openspec ✅ → v1.13.11-tools ✅ → v1.13.12-ws-schemas ✅ → v1.13.13-ws-publish ✅ → v1.13.14-skills-audit ✅ → v1.13.15-codecontext-synth ✅ → v1.13.16-xml-parser ✅ → v1.13.17-cross-repo-reads ✅ → v1.13.18-codecontext-file-path ✅ → v1.13.19-html-artifact-panes ✅ → v1.13.20-drop-legacy-cols ✅** → umbrella `v1.13` ✅. **Do not fold** was the discipline — each batch has a distinct rollback surface, and bisecting a 750-LoC merge across four unrelated changes is worse than four separate dispatches. Held throughout; CHANGELOG.md is the per-tag canonical record.
|
||||||
|
|
||||||
|
### Numbering and scope-revision discipline during v1.13.x (2026-05-23)
|
||||||
|
|
||||||
|
The v1.13.x line ran 21 batches; planned-vs-shipped numbering diverged for half of them, and three batches had material scope revisions mid-design. Pattern that emerged and is worth carrying forward:
|
||||||
|
|
||||||
|
- **Patch numbers are assigned at ship time, not in planning.** The proposal/openspec folder uses a planning slug (e.g. `v1.14.x-html-artifact-panes`); the final tag uses a concrete patch monotonic-per-minor (e.g. `v1.13.19-html-artifact-panes`). Avoids the "we said v1.13.8 but actually shipped seventh" confusion that ate two retrospective passes on the roadmap.
|
||||||
|
- **Scope-revise the proposal before dispatching.** v1.13.19-html-artifact-panes flipped mid-design from "auto-bias to HTML for >100 lines" to "Markdown default, HTML on request" — the proposal got rewritten before recon. Far cheaper than discovering the wrong approach in implementation. The "brainstorm before code" discipline.
|
||||||
|
- **Recon-first dispatch finds 25–30% more sites than the roadmap inventory.** v1.13.20 recon caught 2 extra dual-write sites (chats.ts fork-clone + 2 in tool-phase.ts) and an extra fixture file. v1.13.19 recon corrected which `Pane` type to extend. Skipping recon to save a step doesn't.
|
||||||
|
- **Adversarial reviews catch what test suites miss.** v1.13.19 reviewer caught silent error-promotion in `openInPane`; v1.13.20 reviewer caught a `RETURNING tool_calls, tool_results` clause that crashes in production but slips past green tests. Both are routine code-reviewer dispatches; both saved a same-day hotfix. **Two-stage review (spec then quality) is non-negotiable when shipping fast.**
|
||||||
|
- **Calendar-gated waits are production-safety hedges that don't apply here.** v1.13.20 originally said "wait one week of production traffic on v1.13.1 before dropping columns." Sam called it out: single-user self-hosted, no rollback constraint, code-level audit + DB COUNT query is the actual safety check. Dropped the wait. Don't ritualize production-grade hedges in a single-user codebase.
|
||||||
|
|
||||||
### v1.13 retrospective (what shipped)
|
### v1.13 retrospective (what shipped)
|
||||||
|
|
||||||
@@ -625,7 +546,21 @@ After the 2026-05-22 retag, the v1.13.x cleanup line in `vMAJOR.MINOR.PATCH-slug
|
|||||||
- **v1.13.5** — opencode truncate.ts port + view_truncated_output tool. Tagged on `f8fc5db`.
|
- **v1.13.5** — opencode truncate.ts port + view_truncated_output tool. Tagged on `f8fc5db`.
|
||||||
- **v1.13.6** — compaction head-assembly audit + reasoning fix. Closed the Q3 reasoning gap from v1.13.1-C. Tagged on `81d837c`.
|
- **v1.13.6** — compaction head-assembly audit + reasoning fix. Closed the Q3 reasoning gap from v1.13.1-C. Tagged on `81d837c`.
|
||||||
- **v1.13.7** — stability bundle: includeUsage fix + trim guards + payload filter + budget bump. Surfaces tokens (closes a v1.13.1-A latent regression where `result.usage` resolved empty), kills the empty-bubble + ActionRow noise between tool calls on single-tool-call turns, and unblocks Continue after cap-hit on chats that have trailing empty/failed assistants.
|
- **v1.13.7** — stability bundle: includeUsage fix + trim guards + payload filter + budget bump. Surfaces tokens (closes a v1.13.1-A latent regression where `result.usage` resolved empty), kills the empty-bubble + ActionRow noise between tool calls on single-tool-call turns, and unblocks Continue after cap-hit on chats that have trailing empty/failed assistants.
|
||||||
- **v1.13.2 deferred** — at least one week of production traffic on v1.13.1 before dropping legacy columns. Dual-write is rollback insurance.
|
- **v1.13.6 (numbering re-aligned)** — system-prompt prefix verify-and-measure batch (originally numbered v1.13.8 in the planning doc). Reframed mid-design from "add a `system_prompt_cache` table" to "instrument-and-prove" after recon showed input-layer mtime caches already achieve byte-stable prefixes. Smoke confirmed zero drift across 5 turns; dropped the planned DB table.
|
||||||
|
- **v1.13.7-compaction-trigger** — 0.85×ctx_max early trigger (planned as v1.13.8 / v1.13.9).
|
||||||
|
- **v1.13.8-tool-cost** — `tool_cost_stats` SQL view + AgentPicker tooltip surfacing (planned as v1.13.9 / v1.13.10).
|
||||||
|
- **v1.13.9-agentlint** — instruction-file AgentLint pass (planned as part of v1.13.11 skills audit; split into its own batch when it grew larger than fitting).
|
||||||
|
- **v1.13.10-openspec** — `openspec/changes/<slug>/{proposal,tasks,design}.md` batch-doc structure adoption.
|
||||||
|
- **v1.13.11-tools** — tiered tool loading via `BOOCODE_TOOLS=core|standard|all` env (~30 LoC; was a far-future optional item, slotted in).
|
||||||
|
- **v1.13.12-ws-schemas** + **v1.13.13-ws-publish** — Zod schemas for all 27 wire-format frames, `publishFrame`/`publishUserFrame` wrappers, ~80 publish sites converted (planned as v1.13.10 / v1.13.11).
|
||||||
|
- **v1.13.14-skills-audit** — 26 skills vendored + audited via 5 parallel agent teams; 14 kept, 11 dropped, 1 migrated to BOOCHAT.md/BOOCODER.md. Codeminer42 rules-vs-recipes framing applied.
|
||||||
|
- **v1.13.15-codecontext-synth** — forced second-inference synthesis pass for codecontext overview tools (truncation-aware extraction; auto-fetched top-N files + project docs under 32k payload budget).
|
||||||
|
- **v1.13.16-xml-parser** — Anthropic `<invoke>` parser support + Levenshtein unknown-tool recovery hints (qwen3.6 drift to Claude Code-style tool names).
|
||||||
|
- **v1.13.17-cross-repo-reads** — `request_read_access` tool + per-session `allowed_read_paths` grants; `pathGuard` extraRoots; reuses the `ask_user_input` pause/resume mechanism.
|
||||||
|
- **v1.13.18-codecontext-file-path** — `resolveProjectPath` in `codecontext_client.ts` realpath-resolves `file_path` the same way `target_dir` was already resolved.
|
||||||
|
- **v1.13.19-html-artifact-panes** — pane-based artifact viewer (Markdown default + HTML on request). Scope-revised mid-design from auto-bias-HTML to Markdown-default. `<!DOCTYPE html>` detection adds `message_parts.kind='html_artifact'` row; iframe sandbox `allow-scripts allow-clipboard-write allow-downloads` (no `allow-same-origin`); CSP `connect-src 'none'` + `X-Content-Type-Options: nosniff` + `Content-Security-Policy: sandbox` defense-in-depth. Pane state is reference-only — content fetched on mount to keep jsonb small.
|
||||||
|
- **v1.13.20-drop-legacy-cols** — final strangler-fig step. 10 dual-write sites stripped (recon caught 2 beyond the original v1.13.2 inventory). `messages_with_parts` simplified to parts-only via `CREATE OR REPLACE` before column DROPs (Postgres ordering constraint). Adversarial-review catch: `discard_stale` had `RETURNING tool_calls, tool_results` — fixed via two-step UPDATE-then-SELECT-from-view. `Message` type retains the fields, populated by the view. v1.12.1 cleanup DO block removed.
|
||||||
|
- **`v1.13` umbrella** — tagged on the same commit as v1.13.20 (`211e903`). AI SDK v6 + parts-table migration complete.
|
||||||
|
|
||||||
### Pre-v1.13 architectural decisions (still load-bearing)
|
### Pre-v1.13 architectural decisions (still load-bearing)
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ Rules:
|
|||||||
## Refactorer
|
## Refactorer
|
||||||
---
|
---
|
||||||
temperature: 0.3
|
temperature: 0.3
|
||||||
|
steps: 5
|
||||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes]
|
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes]
|
||||||
description: Proposes refactors for clarity, deduplication, or decoupling. Read-only — outputs plans, not edits.
|
description: Proposes refactors for clarity, deduplication, or decoupling. Read-only — outputs plans, not edits.
|
||||||
---
|
---
|
||||||
@@ -97,6 +98,7 @@ Codecontext usage:
|
|||||||
## Architect
|
## Architect
|
||||||
---
|
---
|
||||||
temperature: 0.5
|
temperature: 0.5
|
||||||
|
steps: 20
|
||||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes]
|
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes]
|
||||||
description: Designs new features, modules, or architectural changes. Outputs a build plan.
|
description: Designs new features, modules, or architectural changes. Outputs a build plan.
|
||||||
---
|
---
|
||||||
|
|||||||
9
data/mcp.json
Normal file
9
data/mcp.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"context7": {
|
||||||
|
"type": "streamableHttp",
|
||||||
|
"url": "https://mcp.context7.com/mcp",
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
CODECONTEXT_URL: http://codecontext:8080
|
CODECONTEXT_URL: http://codecontext:8080
|
||||||
CONTAINER_GUIDANCE_FILE: /app/BOOCHAT.md
|
CONTAINER_GUIDANCE_FILE: /app/BOOCHAT.md
|
||||||
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode
|
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boochat
|
||||||
volumes:
|
volumes:
|
||||||
- /opt:/opt
|
- /opt:/opt
|
||||||
- /opt/projects:/opt/projects:rw
|
- /opt/projects:/opt/projects:rw
|
||||||
@@ -41,7 +41,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode
|
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boochat
|
||||||
volumes:
|
volumes:
|
||||||
- /opt:/opt:rw
|
- /opt:/opt:rw
|
||||||
- /home/samkintop:/home/samkintop:rw
|
- /home/samkintop:/home/samkintop:rw
|
||||||
@@ -50,6 +50,28 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- boocode_net
|
- boocode_net
|
||||||
|
|
||||||
|
boocoder:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/coder/Dockerfile
|
||||||
|
container_name: boocoder
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "100.114.205.53:9502:3000"
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
CONTAINER_GUIDANCE_FILE: /app/BOOCODER.md
|
||||||
|
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boochat
|
||||||
|
volumes:
|
||||||
|
- /opt:/opt:rw
|
||||||
|
- /opt/projects:/opt/projects:rw
|
||||||
|
- ./data:/data
|
||||||
|
- /opt/boocode/BOOCODER.md:/app/BOOCODER.md:ro
|
||||||
|
depends_on:
|
||||||
|
- boocode_db
|
||||||
|
networks:
|
||||||
|
- boocode_net
|
||||||
|
|
||||||
boocode_db:
|
boocode_db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: boocode_db
|
container_name: boocode_db
|
||||||
@@ -57,7 +79,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: boocode
|
POSTGRES_USER: boocode
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
POSTGRES_DB: boocode
|
POSTGRES_DB: boochat
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:5500:5432"
|
- "127.0.0.1:5500:5432"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
185
openspec/changes/v1.13.17-cross-repo-reads/proposal.md
Normal file
185
openspec/changes/v1.13.17-cross-repo-reads/proposal.md
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
# v1.13.17-cross-repo-reads — on-demand read access to another repo (draft, 2026-05-22)
|
||||||
|
|
||||||
|
BooChat sessions are scoped to one project root. When the agent needs context from another repo (e.g. `/opt/forks/codecontext` to investigate a dependency), `pathGuard` rejects every read tool and the agent has no recovery path.
|
||||||
|
|
||||||
|
This batch adds a reactive `ask_user_input`-style flow that the agent triggers on `PathScopeError`. User approves once per session per project root; subsequent reads under that root succeed without further prompting.
|
||||||
|
|
||||||
|
## Trigger flow
|
||||||
|
|
||||||
|
1. Model emits `view_file("/opt/forks/codecontext/go.mod")` while session is scoped to `/opt/boocode`.
|
||||||
|
2. `pathGuard` throws `PathScopeError`. Existing tool wrapper catches it and returns the error to the model. **The error message now ends with a hint:** `"Use request_read_access(path, reason) to ask the user for permission."`
|
||||||
|
3. Model self-issues `request_read_access("/opt/forks/codecontext/go.mod", "investigating codecontext fork to write design doc")` on the next turn.
|
||||||
|
4. The new tool emits a pending tool-call frame (same pause mechanism as `ask_user_input`); inference loop pauses.
|
||||||
|
5. Frontend renders approve/deny chips with the path + reason.
|
||||||
|
6. User picks Allow → append the grant root to `session.allowed_read_paths`, resume inference, tool returns `"granted: /opt/forks/codecontext"`. Model retries the original `view_file` on the next turn.
|
||||||
|
7. User picks Deny → tool returns `"denied"` without mutating session state; model decides what to do next.
|
||||||
|
|
||||||
|
## Decisions (draft — override in dispatch if different)
|
||||||
|
|
||||||
|
### D1. Grant unit = nearest registered project root, then nearest path-whitelist ancestor, then refuse
|
||||||
|
|
||||||
|
When user approves access to `/opt/forks/codecontext/go.mod`:
|
||||||
|
- If a row in `projects.path` is an ancestor of the requested path → grant the project's root path.
|
||||||
|
- Else if `PROJECT_ROOT_WHITELIST` env (default `/opt`) is an ancestor and the immediate child dir of the whitelist looks like a repo root (`.git/`, `package.json`, `go.mod`, or `Cargo.toml` present) → grant that immediate child dir (e.g. `/opt/forks/codecontext`).
|
||||||
|
- Else → refuse without prompting. Tool returns `"denied: path outside permitted scope"`. No user prompt fires.
|
||||||
|
|
||||||
|
Why: granting the literal path is too narrow (next file in the same repo re-prompts). Granting an arbitrary parent dir over-scopes. The nearest repo-shaped directory is the natural unit.
|
||||||
|
|
||||||
|
### D2. Persistence = per-session, no expiry
|
||||||
|
|
||||||
|
`sessions.allowed_read_paths` is the source of truth. Grants stick until the session is archived. A new session in the same project re-prompts on the first cross-repo read.
|
||||||
|
|
||||||
|
Why: per-chat is too granular for the typical workflow (Sam investigates the same fork across multiple chats in one investigation session). Per-project is too broad (different sessions in the same project might have different scope needs). Per-session is the natural unit and matches `session.web_search_enabled`'s scope.
|
||||||
|
|
||||||
|
### D3. Secret-file deny list applies across all grant roots
|
||||||
|
|
||||||
|
`is_secret_path` in `secret_guard.ts` filters filenames (`.env`, `*.pem`, `credentials.json`, etc.) regardless of which root they're under. The check is post-`pathGuard`, so it already runs on the resolved path. No change needed.
|
||||||
|
|
||||||
|
### D4. Revocation UI = chat-settings panel + automatic clear on archive
|
||||||
|
|
||||||
|
- Settings panel under the session-info popover: lists current `allowed_read_paths` with a per-row delete button.
|
||||||
|
- Session archive deletes the row (no need to clear allowed_read_paths separately — the row goes).
|
||||||
|
- No expiry timer.
|
||||||
|
|
||||||
|
Optional v1.13.18 follow-up if Sam wants it: a `/clear_grants` slash command for power users. Out of scope for v1.13.17.
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- v1.13.17: session-scoped cross-repo read grants. Populated via the
|
||||||
|
-- request_read_access tool's approve path; never written by other code.
|
||||||
|
ALTER TABLE sessions
|
||||||
|
ADD COLUMN IF NOT EXISTS allowed_read_paths text[] NOT NULL DEFAULT ARRAY[]::text[];
|
||||||
|
```
|
||||||
|
|
||||||
|
No CHECK constraint — values are absolute paths validated at write time against the projects table + whitelist heuristic.
|
||||||
|
|
||||||
|
## New tool: `request_read_access`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// apps/server/src/services/request_read_access.ts (new)
|
||||||
|
|
||||||
|
export const requestReadAccessInput = z.object({
|
||||||
|
path: z.string().min(1),
|
||||||
|
reason: z.string().min(1).max(500),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const requestReadAccess: ToolDef<...> = {
|
||||||
|
name: 'request_read_access',
|
||||||
|
description:
|
||||||
|
'Ask the user for read-only access to a path outside the current ' +
|
||||||
|
'session\'s project scope. Use when pathGuard rejected a read ' +
|
||||||
|
'attempt and the path is plausibly under another known repo. ' +
|
||||||
|
'Returns "granted: <root>" or "denied".',
|
||||||
|
inputSchema: requestReadAccessInput,
|
||||||
|
jsonSchema: { ... },
|
||||||
|
category: 'read_only',
|
||||||
|
async execute(input, projectRoot) {
|
||||||
|
// Validate path: must be absolute, must be under PROJECT_ROOT_WHITELIST
|
||||||
|
// (default /opt), must NOT already be under the session's primary
|
||||||
|
// projectRoot (silly to ask for what's already in scope).
|
||||||
|
// Validation failures return sentinel without prompting the user.
|
||||||
|
|
||||||
|
// Emit pending-grant tool result (parallel of ask_user_input's pause
|
||||||
|
// sentinel). Inference loop pauses on this kind=pending_grant marker.
|
||||||
|
// User picks Allow/Deny via a new POST /api/messages/:id/grant endpoint.
|
||||||
|
// On Allow: derive grant root per D1 + UPDATE sessions SET
|
||||||
|
// allowed_read_paths = array_append(allowed_read_paths, <root>);
|
||||||
|
// resume inference; tool returns "granted: <root>".
|
||||||
|
// On Deny: resume immediately; tool returns "denied".
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Registered in `ALL_TOOLS` + `READ_ONLY_TOOL_NAMES`. Available to all agents by default (no agent's `tools` whitelist needs to be updated to grant access — the tool registry's filter is per-agent).
|
||||||
|
|
||||||
|
## `pathGuard` extension
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// apps/server/src/services/path_guard.ts — current signature:
|
||||||
|
// pathGuard(projectRoot, requestedPath): Promise<string>
|
||||||
|
//
|
||||||
|
// Extended:
|
||||||
|
// pathGuard(projectRoot, requestedPath, extraRoots?: string[]): Promise<string>
|
||||||
|
//
|
||||||
|
// Tries primary projectRoot first; on PathScopeError, walks extraRoots and
|
||||||
|
// returns the first one that resolves the requestedPath inside its tree.
|
||||||
|
// Throws PathScopeError if no root accepts.
|
||||||
|
```
|
||||||
|
|
||||||
|
Every tool that calls `pathGuard` (currently `view_file`, `list_dir`, `grep`, `find_files`, `view_truncated_output`) threads `session.allowed_read_paths` through `executeToolCall`. The `Session` interface already flows through `TurnArgs`; tool-phase just needs to forward `session.allowed_read_paths` as the third arg.
|
||||||
|
|
||||||
|
## Pause/resume infrastructure reuse
|
||||||
|
|
||||||
|
The pending-grant pause uses the **same mechanism as `ask_user_input`**:
|
||||||
|
- Tool insert with `payload.output = null` + `payload.kind = 'pending_grant'`.
|
||||||
|
- `pausingForUserInput` branch in `tool-phase.ts` is widened to also catch pending grants.
|
||||||
|
- `chat_status` flips to `waiting_for_input` per the v1.12.1 5-state model.
|
||||||
|
|
||||||
|
New endpoint `POST /api/messages/:tool_msg_id/grant` (parallel of the existing `/answer`):
|
||||||
|
- Body: `{ decision: 'allow' | 'deny' }`.
|
||||||
|
- Resolves grant root per D1 if Allow. UPDATEs `sessions.allowed_read_paths`. UPDATEs tool message with output. Resumes inference via existing enqueue path.
|
||||||
|
|
||||||
|
## Frontend changes (in scope; small)
|
||||||
|
|
||||||
|
- `MessageBubble.tsx`: render `pending_grant` tool messages with Allow/Deny chips + the path + reason text. Wires to `api.messages.grant(toolMsgId, decision)`.
|
||||||
|
- New API client method `api.messages.grant`.
|
||||||
|
- Settings popover: `allowed_read_paths` list with per-row delete (calls `PATCH /api/sessions/:id` with the modified array).
|
||||||
|
|
||||||
|
## Hard rules
|
||||||
|
|
||||||
|
- No git commit, no git push, no git pull during dispatch. Sam commits manually.
|
||||||
|
- Backup every file before edit per the standard convention.
|
||||||
|
- TS strict, no `any`.
|
||||||
|
- No new deps.
|
||||||
|
- Schema migration is **additive only** (ADD COLUMN IF NOT EXISTS), idempotent on re-run.
|
||||||
|
- Tool is **read-only** — no path under `allowed_read_paths` can ever be written by BooChat (no write tools registered today; this is a structural guarantee).
|
||||||
|
- Secret-file deny list still runs unconditionally on resolved paths.
|
||||||
|
|
||||||
|
## Stop checkpoints
|
||||||
|
|
||||||
|
1. After recon (read existing path_guard + ask_user_input + answer endpoint patterns): stop, hand back the recon report.
|
||||||
|
2. After code edits, before schema migration applies: stop, hand back the diff.
|
||||||
|
3. After schema migration applies in dev: stop, run smoke plan, report.
|
||||||
|
|
||||||
|
## Smoke plan
|
||||||
|
|
||||||
|
1. **Approve flow.** Send a chat in a `/opt/boocode` session asking the agent to investigate `/opt/forks/codecontext/go.mod`. Confirm:
|
||||||
|
- `pathGuard` throws on the first attempt; tool result includes the `request_read_access` hint.
|
||||||
|
- Agent calls `request_read_access`; tool-call frame lands; chat status flips to `waiting_for_input`.
|
||||||
|
- Frontend renders Allow/Deny chips with the path + reason.
|
||||||
|
- Pick Allow → grant root resolves to `/opt/forks/codecontext` (per D1); `sessions.allowed_read_paths` shows the entry; agent retries `view_file` successfully on the next turn.
|
||||||
|
2. **Deny flow.** Same setup; pick Deny. Confirm session state unchanged, tool returns `"denied"`, agent gives up or asks differently.
|
||||||
|
3. **Persistence.** In the same session, a second `view_file` against a different file under `/opt/forks/codecontext/` succeeds without re-prompting.
|
||||||
|
4. **Cross-session isolation.** Open a fresh session in the boocode project, try the same path — re-prompts (allowed_read_paths is empty on the new session).
|
||||||
|
5. **Secret-file deny still fires.** Approve access to a repo that contains a `.env` file. Try `view_file('/opt/forks/some-repo/.env')`. Confirm refused via `is_secret_path`, not via pathGuard scope.
|
||||||
|
6. **Out-of-scope refusal.** Try `request_read_access('/etc/passwd', 'system file')`. Tool validates against the whitelist + repo-shape heuristic, returns `"denied: path outside permitted scope"` without prompting the user.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- New `request_read_access` tool + `POST /api/messages/:id/grant` endpoint shipped.
|
||||||
|
- `path_guard.ts` extended; all read tools forward `allowed_read_paths`.
|
||||||
|
- `MessageBubble.tsx` renders pending-grant bubbles; settings popover lists + clears grants.
|
||||||
|
- Schema migration applied (sessions.allowed_read_paths).
|
||||||
|
- Smoke plan green.
|
||||||
|
- v1.13.17-cross-repo-reads tag + CHANGELOG entry + roadmap retrospective bullet.
|
||||||
|
|
||||||
|
## Files expected to touch
|
||||||
|
|
||||||
|
- `apps/server/src/schema.sql` — new column
|
||||||
|
- `apps/server/src/services/request_read_access.ts` — NEW
|
||||||
|
- `apps/server/src/services/path_guard.ts` — extra-roots param + helpful PathScopeError message
|
||||||
|
- `apps/server/src/services/tools.ts` — register the new tool, update view_file / list_dir / grep / find_files / view_truncated_output to thread allowed_read_paths
|
||||||
|
- `apps/server/src/services/inference/tool-phase.ts` — pause-on-pending-grant branch (alongside ask_user_input)
|
||||||
|
- `apps/server/src/routes/messages.ts` — new `/grant` endpoint
|
||||||
|
- `apps/server/src/types/api.ts` — `Session.allowed_read_paths`
|
||||||
|
- `apps/web/src/api/client.ts` — `api.messages.grant`
|
||||||
|
- `apps/web/src/api/types.ts` — `Session.allowed_read_paths`
|
||||||
|
- `apps/web/src/components/MessageBubble.tsx` — render pending_grant chips
|
||||||
|
- `apps/web/src/components/` — settings-popover grants list (file TBD during impl)
|
||||||
|
|
||||||
|
Estimate: ~120 LoC across backend + frontend + schema. Single batch.
|
||||||
|
|
||||||
|
## Open questions for dispatch
|
||||||
|
|
||||||
|
The four design decisions above are my recommendations. Override any of them in the dispatch and I'll update the proposal before recon. Most likely-overridable: **D1** (grant unit — you may want exact-path-only for tighter scoping, accepting the re-prompt cost) and **D4** (revocation UI — you may want it deferred entirely).
|
||||||
46
openspec/changes/v1.13.18-codecontext-file-path/design.md
Normal file
46
openspec/changes/v1.13.18-codecontext-file-path/design.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# v1.13.18 — design notes
|
||||||
|
|
||||||
|
## Resolver contract
|
||||||
|
|
||||||
|
`resolveProjectPath(projectRoot: string, rawPath: string): Promise<string>`
|
||||||
|
|
||||||
|
1. **Trim check** — `rawPath.trim() === ''` throws `INVALID_FILE_PATH`. This is defensive code; the Zod `.trim().min(1)` in required-`file_path` wrappers catches empty paths before the shim. For optional-`file_path` wrappers, the caller guard `file_path.trim() !== ''` prevents `resolveProjectPath` from being reached at all when the string is empty or whitespace-only.
|
||||||
|
|
||||||
|
2. **Absolute branch** — `isAbsolute(rawPath)` uses the candidate as-is; otherwise `resolve(projectRoot, rawPath)` anchors it.
|
||||||
|
|
||||||
|
3. **realpath with ENOENT fallthrough** — `realpath(candidate)` resolves symlinks and normalises the path. On `ENOENT` (file doesn't exist), the un-realpathed absolute is used as the forwarded value. Any other error (EACCES, EBADF, etc.) re-throws immediately.
|
||||||
|
|
||||||
|
4. **Escape check** — `resolved !== projectRoot && !resolved.startsWith(projectRoot + sep)`. Uses `path.sep` not a string literal `'/'` so the check is platform-safe (Windows posture, forward compatibility).
|
||||||
|
|
||||||
|
5. **Return** — the resolved absolute path, which replaces `req.args['file_path']` in `argsToSend`.
|
||||||
|
|
||||||
|
The guard in `callCodecontext` only invokes `resolveProjectPath` when `typeof req.args['file_path'] === 'string' && req.args['file_path'].trim() !== ''`. Wrappers that don't include `file_path` in their args object are unaffected.
|
||||||
|
|
||||||
|
## Error-shape parity rationale
|
||||||
|
|
||||||
|
The `target_dir` escape error message is: `target_dir <targetDir> escapes project root <resolvedProject>`.
|
||||||
|
|
||||||
|
The `file_path` escape error message is: `file_path <rawPath> escapes project root <projectRoot>`.
|
||||||
|
|
||||||
|
The template is byte-identical except for the field name prefix. This is intentional:
|
||||||
|
|
||||||
|
- The existing escape error regex `/escapes project root/` used in tests and potentially in log alerting applies to both error types without special-casing.
|
||||||
|
- A model receiving either error message can apply the same self-correction: the escape check is the same invariant (`path starts with project root + sep`), so the same remediation applies (use a path inside the project).
|
||||||
|
- Keeping the shapes uniform reduces cognitive overhead when reading logs that mix both error types.
|
||||||
|
|
||||||
|
## ENOENT fallthrough rationale
|
||||||
|
|
||||||
|
When a `file_path` doesn't exist on disk, `resolveProjectPath` forwards the un-realpathed absolute path to the sidecar. The sidecar responds with its own error: `"file not found: <path>"` (or `"File not found in graph: <path>"`).
|
||||||
|
|
||||||
|
The alternative — re-implementing the "file not found" check in the resolver — would:
|
||||||
|
1. Diverge from the sidecar's canonical error language, producing two different "not found" messages depending on whether the file existed at realpath time.
|
||||||
|
2. Conflict with future scenarios where the sidecar's graph is stale (file existed at index time but was deleted, or vice versa). The sidecar's error is always authoritative.
|
||||||
|
3. Add no user-visible value: the model can self-correct on either "file not found" message by checking the path.
|
||||||
|
|
||||||
|
The resolver's job is path safety (scope enforcement) and path normalisation (relative → absolute). Existence checking is the sidecar's job.
|
||||||
|
|
||||||
|
## `codecontext_tools.test.ts` impact
|
||||||
|
|
||||||
|
The existing `get_file_analysis forwards file_path` test in `codecontext_tools.test.ts` passes `'apps/server/src/index.ts'` as a relative `file_path` and asserts it reaches the wire unchanged. After this fix the path is resolved to `join(projectDir, 'apps/server/src/index.ts')`. The test now fails.
|
||||||
|
|
||||||
|
This test file is outside this batch's allowed file list. Sam should update the test assertion to expect the resolved absolute path, or create the file in the test tmpdir and assert the full resolved path. The fix is a one-liner: change `file_path: 'apps/server/src/index.ts'` to `file_path: join(projectDir, 'apps/server/src/index.ts')` in the `expect(body).toMatchObject(...)` call, and create the file before the call (so realpath succeeds).
|
||||||
36
openspec/changes/v1.13.18-codecontext-file-path/proposal.md
Normal file
36
openspec/changes/v1.13.18-codecontext-file-path/proposal.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# v1.13.18 — codecontext file_path resolver
|
||||||
|
|
||||||
|
Fixes a silent failure that caused all four `file_path`-taking codecontext wrappers to return "file not found" whenever the model passed a relative path.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
BooCode's codecontext sidecar (`codecontext_client.ts`) already realpath-resolves `target_dir` before forwarding it to the HTTP shim. It did not do the same for `file_path`. The sidecar's internal file index is keyed on absolute paths, so any relative path from the model produced a JSON error response:
|
||||||
|
|
||||||
|
```
|
||||||
|
{"error":"file not found: apps/server/src/services/inference/turn.ts","result":null}
|
||||||
|
```
|
||||||
|
|
||||||
|
This was observed repeatedly in the 2026-05-22 docker logs (17:56 UTC window) — the model passed relative paths on every `get_file_analysis` tool call and received no useful output, burning tool budget on dead calls.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Four wrappers take a `file_path` argument:
|
||||||
|
|
||||||
|
- `tools/codecontext/get_file_analysis.ts` — `file_path` required
|
||||||
|
- `tools/codecontext/get_symbol_info.ts` — `file_path` optional
|
||||||
|
- `tools/codecontext/get_dependencies.ts` — `file_path` optional
|
||||||
|
- `tools/codecontext/get_semantic_neighborhoods.ts` — `file_path` optional
|
||||||
|
|
||||||
|
Fix lands in one place: `callCodecontext` in `codecontext_client.ts`. A new `resolveProjectPath` helper is inserted at the args-spread site and invoked whenever `file_path` is present and non-empty. All four wrappers benefit automatically; no per-wrapper edits required.
|
||||||
|
|
||||||
|
Zod `.trim()` is added to all four `file_path` schema entries so that whitespace-padded paths from the model are cleaned before they reach the resolver.
|
||||||
|
|
||||||
|
## Decision: single resolver over per-wrapper edits
|
||||||
|
|
||||||
|
Four wrappers, one shared code path. Per-wrapper edits would require four edits and make it easy to miss one. The `callCodecontext` shim already owns `target_dir` validation; `file_path` validation belongs there too for symmetry.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No changes to the `target_dir` resolver — it already works correctly.
|
||||||
|
- No extension to wrappers that do not take `file_path` (`get_codebase_overview`, `get_framework_analysis`, `search_symbols`, `watch_changes`).
|
||||||
|
- No fix for the unrelated RPC errors and Go map-race warnings visible in the codecontext sidecar logs — those are upstream bugs.
|
||||||
57
openspec/changes/v1.13.18-codecontext-file-path/tasks.md
Normal file
57
openspec/changes/v1.13.18-codecontext-file-path/tasks.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# v1.13.18 tasks
|
||||||
|
|
||||||
|
## B1 — Backups
|
||||||
|
|
||||||
|
- [x] `apps/server/src/services/codecontext_client.ts.bak-v1.13.18-20260522`
|
||||||
|
- [x] `apps/server/src/services/tools/codecontext/get_file_analysis.ts.bak-v1.13.18-20260522`
|
||||||
|
- [x] `apps/server/src/services/tools/codecontext/get_symbol_info.ts.bak-v1.13.18-20260522`
|
||||||
|
- [x] `apps/server/src/services/tools/codecontext/get_dependencies.ts.bak-v1.13.18-20260522`
|
||||||
|
- [x] `apps/server/src/services/tools/codecontext/get_semantic_neighborhoods.ts.bak-v1.13.18-20260522`
|
||||||
|
|
||||||
|
## B2 — Resolver implementation in `codecontext_client.ts`
|
||||||
|
|
||||||
|
- [x] Import `isAbsolute`, `resolve`, `sep` from `node:path` (alongside existing `join`)
|
||||||
|
- [x] Add `resolveProjectPath(projectRoot, rawPath)` helper — trim check, isAbsolute branch, realpath with ENOENT fallthrough, escape check
|
||||||
|
- [x] Wire into `callCodecontext` at args-spread site — guard on `file_path.trim() !== ''`
|
||||||
|
- [x] Error-shape parity verified: `file_path <raw> escapes project root <root>` mirrors `target_dir <dir> escapes project root <root>`
|
||||||
|
|
||||||
|
## B3 — Zod `.trim()` on wrapper schemas
|
||||||
|
|
||||||
|
- [x] `get_file_analysis.ts` — `z.string().trim().min(1)`
|
||||||
|
- [x] `get_symbol_info.ts` — `z.string().trim().optional()`
|
||||||
|
- [x] `get_dependencies.ts` — `z.string().trim().optional()`
|
||||||
|
- [x] `get_semantic_neighborhoods.ts` — `z.string().trim().optional()`
|
||||||
|
|
||||||
|
## B4 — Tests
|
||||||
|
|
||||||
|
- [x] Added `describe('callCodecontext — file_path resolution', ...)` to `codecontext_client.test.ts`
|
||||||
|
- [x] Case 1: relative path resolves to absolute inside project root
|
||||||
|
- [x] Case 2: absolute path inside project root passes through
|
||||||
|
- [x] Case 3: relative escape (`../../etc/passwd`) rejected with `escapes project root`
|
||||||
|
- [x] Case 4: absolute path outside project root rejected
|
||||||
|
- [x] Case 5: nonexistent file (ENOENT) forwarded as un-realpath'd absolute
|
||||||
|
- [x] Case 6: empty string skipped by guard (treated as not provided)
|
||||||
|
- [x] Case 7: wrapper without `file_path` — resolver not invoked, no `file_path` in wire body
|
||||||
|
- [x] All 17 tests in `codecontext_client.test.ts` pass
|
||||||
|
|
||||||
|
## B5 — Typecheck + smoke
|
||||||
|
|
||||||
|
- [x] `npx tsc --noEmit -p apps/server` — 0 errors
|
||||||
|
- [x] Before-fix smoke (relative path): `{"error":"file not found: apps/server/src/services/inference/turn.ts","result":null}`
|
||||||
|
- [x] Before-fix smoke (absolute path): returns `Lines: 330 / Symbols: 48` as expected
|
||||||
|
|
||||||
|
## B6 — Test asserting old buggy behavior updated
|
||||||
|
|
||||||
|
- [x] `apps/server/src/services/__tests__/codecontext_tools.test.ts` — assertion at line 73 updated from `file_path: 'apps/server/src/index.ts'` to `file_path: join(projectDir, 'apps/server/src/index.ts')` to match the new resolved-absolute contract.
|
||||||
|
|
||||||
|
## B7 — OpenSpec docs
|
||||||
|
|
||||||
|
- [x] `openspec/changes/v1.13.18-codecontext-file-path/proposal.md`
|
||||||
|
- [x] `openspec/changes/v1.13.18-codecontext-file-path/tasks.md`
|
||||||
|
- [x] `openspec/changes/v1.13.18-codecontext-file-path/design.md`
|
||||||
|
|
||||||
|
## B8 — Review-pass defence-in-depth (P2 fixes from adversarial review)
|
||||||
|
|
||||||
|
- [x] `codecontext_client.ts:71` — absolute branch now goes through `resolve()` to normalise dot-segments. Closes the ENOENT-fallthrough escape gap where `<projectRoot>/../etc/x` would prefix-match `<projectRoot>/` literally.
|
||||||
|
- [x] `codecontext_client.test.ts` — added Case 8 (absolute path with `..` resolving outside root, ENOENT branch) and Case 9 (in-project symlink whose target sits outside root). 19 tests pass.
|
||||||
|
- [x] Updated `resolveProjectPath` docstring to reflect the new normalisation step.
|
||||||
126
openspec/changes/v1.13.20-drop-legacy-cols/proposal.md
Normal file
126
openspec/changes/v1.13.20-drop-legacy-cols/proposal.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# v1.13.20-drop-legacy-cols — drop messages.tool_calls + messages.tool_results
|
||||||
|
|
||||||
|
Final phase of the v1.13.0 strangler-fig migration. Removes the dual-write into `messages.tool_calls` / `messages.tool_results` JSON columns and drops the columns themselves. After this batch, `message_parts` is the only source of truth for tool-call and tool-result data.
|
||||||
|
|
||||||
|
Tag `v1.13` (umbrella) ships on the same commit per the original roadmap entry.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
v1.13.0 (AI SDK v6 migration) introduced `message_parts` as the new canonical store for tool calls, tool results, reasoning, text, synthesis, and now html_artifact. To stay safe during the migration, every write site also dual-wrote to the legacy `messages.tool_calls` / `messages.tool_results` JSON columns, and `messages_with_parts` view COALESCEs over both. Reads have been migrated; dual-writes are pure overhead at this point.
|
||||||
|
|
||||||
|
Verification query (per the original v1.13.2 plan) returns `0 / 0` orphan rows. Today's DB is also empty (0 messages on the live instance), so the COUNT query alone is weakly informative — the safety check shifts to a code-level audit: every dual-write site listed in the v1.13.2 roadmap entry must be located and its parts-write half kept, JSON-column half removed.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### S1. Remove dual-write from every site
|
||||||
|
|
||||||
|
Per the v1.13.2 roadmap entry, dual-writes live at:
|
||||||
|
|
||||||
|
- `services/inference/tool-phase.ts` — 3 sites
|
||||||
|
- `services/inference/error-handler.ts` — `finalizeCompletion`
|
||||||
|
- `routes/skills.ts` — 2 sites
|
||||||
|
- `routes/messages.ts` — answer flow
|
||||||
|
- `routes/chats.ts` — fork flow
|
||||||
|
|
||||||
|
Implementer must grep for every UPDATE / INSERT that touches `tool_calls` or `tool_results` columns and verify it has a paired `insertParts(...)` call. Keep the parts write, remove the column write. If a site only writes to the JSON column with no parts pair — STOP and escalate (would indicate a bug in the v1.13.0 dual-write rollout we haven't caught).
|
||||||
|
|
||||||
|
### S2. Simplify `messages_with_parts` view
|
||||||
|
|
||||||
|
Current view COALESCEs parts-table rows over legacy JSON columns to support pre-v1.13.0 history. After this batch, the JSON columns no longer exist — drop the COALESCE fallbacks. The view should read only from `message_parts` joined to `messages`.
|
||||||
|
|
||||||
|
### S3. Drop the columns
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE messages DROP COLUMN tool_calls;
|
||||||
|
ALTER TABLE messages DROP COLUMN tool_results;
|
||||||
|
```
|
||||||
|
|
||||||
|
Idempotent via `IF EXISTS`. Apply unconditionally on startup (matches the rest of `schema.sql`'s shape).
|
||||||
|
|
||||||
|
### S4. Remove from API types
|
||||||
|
|
||||||
|
`Message` interface in `apps/server/src/types/api.ts` AND `apps/web/src/api/types.ts` — drop `tool_calls?` and `tool_results?` fields. The API boundary is unchanged because every consumer already reads parts-derived values through `messages_with_parts`. Mirror byte-for-byte.
|
||||||
|
|
||||||
|
### S5. Drop the stale `messages_status_check` cleanup DO block from v1.12.1 if still present
|
||||||
|
|
||||||
|
Per the v1.13.2 roadmap entry, there's a v1.12.1 `DO $$ DROP CONSTRAINT messages_status_check` block that was meant to clean up the old anonymous constraint. If still present in `schema.sql`, remove — it's been one-shot effective.
|
||||||
|
|
||||||
|
### S6. Update test fixtures
|
||||||
|
|
||||||
|
`inference.test.ts` and `compaction.test.ts` (and any other test file the grep finds) construct Message-shaped fixtures with `tool_calls: null, tool_results: null` literals. Rewrite ~30 fixtures to construct via `message_parts` rows where the test actually exercises tool calls. For tests that don't exercise tool calls at all, just drop the now-absent fields.
|
||||||
|
|
||||||
|
`partsFromAssistantMessage` and `partsFromToolMessage` helpers in `parts.ts` currently take `tool_calls` and `tool_results` as args (because that's what the legacy Message shape carried). Keep their input shapes — they're useful constructors. The change is at the call sites, not the helpers.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- **No changes to `message_parts` schema.** It's correct as-is.
|
||||||
|
- **No changes to the `messages_with_parts` view name or interface.** Just the implementation simplifies.
|
||||||
|
- **No removal of `partsFromAssistantMessage` / `partsFromToolMessage`.** They're useful as constructors; their job becomes producing parts from raw ToolCall/ToolResult objects, not from a legacy Message row.
|
||||||
|
- **No frontend changes beyond the type mirror.** Web reads parts via `messages_with_parts` already.
|
||||||
|
- **No reads from the legacy columns in any code path.** Verify with grep.
|
||||||
|
|
||||||
|
## Hard rules
|
||||||
|
|
||||||
|
- No git commits during dispatch. Sam commits manually (handled by controller after all dispatches done).
|
||||||
|
- Backups: every modified file → `.bak-v1.13.20-20260523`.
|
||||||
|
- TS strict, no `any`.
|
||||||
|
- No new deps.
|
||||||
|
- Schema migration: additive-or-destructive but idempotent (`IF EXISTS` on the column drops).
|
||||||
|
- Run the full server test suite after — must be green.
|
||||||
|
- Frontend: `tsc -p apps/web/tsconfig.app.json --noEmit` + `pnpm -C apps/web build` clean.
|
||||||
|
|
||||||
|
## Stop checkpoints
|
||||||
|
|
||||||
|
1. **After recon** (grep-driven inventory of dual-write call sites + read sites still touching the legacy columns): stop, hand back inventory. The roadmap listed 7+ sites; verify nothing's been missed.
|
||||||
|
2. **After code edits, before schema migration**: stop, hand back diff + test results. Confirm the parts write at every former dual-write site still happens.
|
||||||
|
3. **After schema migration applies in dev**: stop, run tests, run a fresh `applySchema()` cycle (boot twice), confirm idempotent.
|
||||||
|
|
||||||
|
## Smoke plan
|
||||||
|
|
||||||
|
1. **Fresh boot.** Restart the boocode container, confirm `applySchema()` completes without error.
|
||||||
|
2. **Idempotent boot.** Restart again, confirm no error on the second pass (column DROP IF EXISTS is a no-op).
|
||||||
|
3. **Send a chat that triggers a tool call.** Confirm:
|
||||||
|
- Assistant message lands with content + reasoning + tool_call parts (all in `message_parts`).
|
||||||
|
- Tool result lands as a `tool_result` part.
|
||||||
|
- `messages_with_parts` returns the same shape the frontend expects (verify by reading the live chat in the UI).
|
||||||
|
4. **DB inspection.** `\d messages` — confirm `tool_calls` and `tool_results` columns are gone.
|
||||||
|
5. **Compaction roundtrip.** Trigger a compaction-eligible turn (long context); confirm the rolling summary still anchors correctly and uses parts as input.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- All dual-write sites converted to parts-only writes.
|
||||||
|
- View simplified, columns dropped, types updated.
|
||||||
|
- Test suite green.
|
||||||
|
- Frontend typecheck + build clean.
|
||||||
|
- Smoke green.
|
||||||
|
- Tagged `v1.13.20-drop-legacy-cols` AND the umbrella `v1.13` on the same commit.
|
||||||
|
- CHANGELOG.md entry + roadmap retrospective bullet.
|
||||||
|
|
||||||
|
## Files expected to touch
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- `apps/server/src/schema.sql` — DROP columns + simplify view + remove v1.12.1 cleanup block
|
||||||
|
- `apps/server/src/services/inference/tool-phase.ts` — remove 3 dual-write sites
|
||||||
|
- `apps/server/src/services/inference/error-handler.ts` — remove dual-write in `finalizeCompletion`
|
||||||
|
- `apps/server/src/routes/skills.ts` — remove 2 dual-write sites
|
||||||
|
- `apps/server/src/routes/messages.ts` — remove dual-write in answer flow
|
||||||
|
- `apps/server/src/routes/chats.ts` — remove dual-write in fork
|
||||||
|
- `apps/server/src/types/api.ts` — drop `tool_calls?` / `tool_results?` from Message
|
||||||
|
- `apps/server/src/services/__tests__/inference.test.ts` — fixture rewrites
|
||||||
|
- `apps/server/src/services/__tests__/compaction.test.ts` — fixture rewrites
|
||||||
|
- `apps/server/src/services/__tests__/parts.test.ts` — likely some fixture updates
|
||||||
|
- `apps/server/src/services/__tests__/tool_cost_stats.test.ts` — likely some fixture updates
|
||||||
|
- `apps/server/src/services/__tests__/system-prompt.test.ts` — likely some fixture updates
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- `apps/web/src/api/types.ts` — mirror Message change
|
||||||
|
|
||||||
|
**Docs:**
|
||||||
|
- `BOOCHAT.md` — no change expected (rules don't mention the legacy columns)
|
||||||
|
- `boocode_roadmap.md` — retrospective bullet
|
||||||
|
- `CHANGELOG.md` — new section
|
||||||
|
- `CLAUDE.md` — drop the v1.13.0 dual-write notes that no longer apply (audit the surrounding paragraphs)
|
||||||
|
|
||||||
|
## Estimate
|
||||||
|
|
||||||
|
~150 LoC net (mostly deletions). Mechanical work — same per-batch shape as v1.13.18.
|
||||||
104
openspec/changes/v1.13.20-drop-legacy-cols/tasks.md
Normal file
104
openspec/changes/v1.13.20-drop-legacy-cols/tasks.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# v1.13.20-drop-legacy-cols tasks
|
||||||
|
|
||||||
|
## B1 — Recon (STOP after this step)
|
||||||
|
|
||||||
|
- [ ] Grep `apps/server/src/**/*.ts` for every `tool_calls` and `tool_results` mention. Categorize each hit as:
|
||||||
|
- **dual-write** (an UPDATE / INSERT that writes the JSON column)
|
||||||
|
- **read** (a SELECT that reads the JSON column, or code that destructures it from a row)
|
||||||
|
- **type-only** (interface / type field reference)
|
||||||
|
- **test fixture** (literal in a test file)
|
||||||
|
- **comment / docs**
|
||||||
|
- [ ] Confirm the v1.13.2 roadmap inventory is complete:
|
||||||
|
- tool-phase.ts: 3 sites
|
||||||
|
- error-handler.ts (`finalizeCompletion`): 1 site
|
||||||
|
- routes/skills.ts: 2 sites
|
||||||
|
- routes/messages.ts (answer flow): 1 site
|
||||||
|
- routes/chats.ts (fork): 1 site
|
||||||
|
- Any extras the grep finds: list them
|
||||||
|
- [ ] Confirm no READ sites still touching the legacy columns (everything should go through `messages_with_parts`). If reads remain, flag them — they need to migrate to the view BEFORE dropping the columns.
|
||||||
|
- [ ] Hand back inventory as a per-file table: file, line, kind (dual-write / read / type / fixture), action (delete / migrate-to-view / type-prune).
|
||||||
|
|
||||||
|
## B2 — Backups
|
||||||
|
|
||||||
|
- [ ] `cp <file> <file>.bak-v1.13.20-20260523` for every file in B1's action list before editing.
|
||||||
|
|
||||||
|
## B3 — Remove dual-writes
|
||||||
|
|
||||||
|
- [ ] Remove the JSON-column UPDATE / INSERT at every site identified in B1 as a dual-write. Keep the paired `insertParts(...)` call.
|
||||||
|
- [ ] If a site only writes the JSON column with no parts pair (would indicate a bug from v1.13.0) — STOP, report as BLOCKED.
|
||||||
|
- [ ] Verify by grep: zero remaining writes to `tool_calls` or `tool_results` outside of `schema.sql` and test fixtures.
|
||||||
|
|
||||||
|
## B4 — Simplify `messages_with_parts` view
|
||||||
|
|
||||||
|
- [ ] Open `schema.sql`. Find the view definition.
|
||||||
|
- [ ] Drop the COALESCE fallbacks that read `m.tool_calls` / `m.tool_results` from `messages`.
|
||||||
|
- [ ] View now reads only from `message_parts` joined to `messages`.
|
||||||
|
- [ ] Confirm view's output column shapes are unchanged: `tool_calls jsonb[]`, `tool_results jsonb` single object, `reasoning_parts jsonb[]`.
|
||||||
|
|
||||||
|
## B5 — Drop columns
|
||||||
|
|
||||||
|
- [ ] `ALTER TABLE messages DROP COLUMN IF EXISTS tool_calls;`
|
||||||
|
- [ ] `ALTER TABLE messages DROP COLUMN IF EXISTS tool_results;`
|
||||||
|
- [ ] Idempotent on re-run.
|
||||||
|
- [ ] Apply order in `schema.sql`: AFTER the view is updated (view depends on the columns; can't drop a column referenced by a view).
|
||||||
|
- [ ] Actually verify the order — if the view references the columns, you must drop the view first OR change it before the ALTER.
|
||||||
|
|
||||||
|
## B6 — Remove v1.12.1 cleanup block
|
||||||
|
|
||||||
|
- [ ] Find the `DO $$ DROP CONSTRAINT messages_status_check` block in `schema.sql` (likely near the messages CHECK constraints).
|
||||||
|
- [ ] Confirm it's safe to remove (the constraint should have been dropped long ago).
|
||||||
|
- [ ] Delete the block.
|
||||||
|
|
||||||
|
## B7 — Type pruning
|
||||||
|
|
||||||
|
- [ ] `apps/server/src/types/api.ts` — remove `tool_calls?` and `tool_results?` from the `Message` interface.
|
||||||
|
- [ ] `apps/web/src/api/types.ts` — mirror byte-for-byte.
|
||||||
|
- [ ] Search for any other type references — `ToolCallsField`, `ToolResultsField`, etc.
|
||||||
|
|
||||||
|
## B8 — Test fixture updates
|
||||||
|
|
||||||
|
- [ ] Run `pnpm -C apps/server test` to see what breaks.
|
||||||
|
- [ ] For each failing test that constructs a `Message` literal with `tool_calls: null` / `tool_results: null` — remove those fields.
|
||||||
|
- [ ] For tests that exercised tool-call behavior via the legacy columns, rewrite to construct via `message_parts` rows.
|
||||||
|
- [ ] Confirm: `pnpm -C apps/server test` — all green.
|
||||||
|
|
||||||
|
## B9 — Type / build verification
|
||||||
|
|
||||||
|
- [ ] `npx tsc --noEmit -p apps/server` — 0 errors.
|
||||||
|
- [ ] `npx tsc -p apps/web/tsconfig.app.json --noEmit` — 0 errors.
|
||||||
|
- [ ] `pnpm -C apps/web build` — green.
|
||||||
|
|
||||||
|
## B10 — STOP checkpoint, hand back diff
|
||||||
|
|
||||||
|
- [ ] Hand controller the diff for backend changes + test results.
|
||||||
|
|
||||||
|
## B11 — Schema deploy
|
||||||
|
|
||||||
|
- [ ] `docker compose up --build -d` rebuilds with new schema.
|
||||||
|
- [ ] Boot twice in sequence — confirm idempotent (column DROP IF EXISTS is a no-op on the second boot).
|
||||||
|
- [ ] `docker exec boocode_db psql -U boocode -d boocode -c "\d messages"` — confirm columns absent.
|
||||||
|
- [ ] `docker logs boocode 2>&1 | tail -50` — confirm no schema errors.
|
||||||
|
|
||||||
|
## B12 — Smoke
|
||||||
|
|
||||||
|
- [ ] Live-smoke: send a chat that triggers at least one tool call. Confirm:
|
||||||
|
- [ ] Assistant message renders with content + tool_call ActionRow.
|
||||||
|
- [ ] Tool result renders.
|
||||||
|
- [ ] No console errors in browser or `docker logs boocode`.
|
||||||
|
- [ ] Trigger a compaction-eligible turn (long context). Confirm rolling summary anchors correctly.
|
||||||
|
|
||||||
|
## B13 — Docs
|
||||||
|
|
||||||
|
- [ ] `CHANGELOG.md` entry for v1.13.20-drop-legacy-cols.
|
||||||
|
- [ ] `boocode_roadmap.md` retrospective bullet on the v1.13.2 section (note the slug rename and ship date).
|
||||||
|
- [ ] `CLAUDE.md` — drop the v1.13.0 dual-write notes that no longer apply. Audit the surrounding paragraphs.
|
||||||
|
|
||||||
|
## B14 — Tag + push + rebuild
|
||||||
|
|
||||||
|
- [ ] `git add` only the v1.13.20 batch files (per CLAUDE.md convention).
|
||||||
|
- [ ] `git commit` with HEREDOC commit message.
|
||||||
|
- [ ] `git tag v1.13.20-drop-legacy-cols` AND `git tag v1.13` (umbrella, per original v1.13.2 plan).
|
||||||
|
- [ ] Push: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin main`
|
||||||
|
- [ ] Push both tags.
|
||||||
|
- [ ] `docker compose up --build -d`.
|
||||||
|
- [ ] Curl health check.
|
||||||
72
openspec/changes/v1.14-outer-loop/design.md
Normal file
72
openspec/changes/v1.14-outer-loop/design.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# v1.14.0-outer-loop — design decisions
|
||||||
|
|
||||||
|
Answers to the dispatch's blocking questions, resolved 2026-05-23.
|
||||||
|
|
||||||
|
## D1. Step cap — what replaces MAX_TOOL_LOOP_DEPTH?
|
||||||
|
|
||||||
|
`MAX_TOOL_LOOP_DEPTH` never existed — no hard recursion depth guard was ever in the codebase. Safety came from budget (50 tool calls) + doom-loop (3 identical calls).
|
||||||
|
|
||||||
|
**Decision:** introduce `MAX_STEPS = 200` as a hard ceiling. Per-agent cap via `agent.steps` is the primary knob. Resolution: `effectiveCap = Math.min(agent.steps ?? Infinity, MAX_STEPS)`.
|
||||||
|
|
||||||
|
**Rationale:** Sam reports BooChat gets stuck at 50 tool calls (the budget) too often. The step cap should be generous — 200 is 4x the current de-facto ceiling. Budget (50 tool calls total across all steps) remains a separate concern and is not changed in this batch.
|
||||||
|
|
||||||
|
Note: "step" ≠ "tool call." One step = one stream iteration that may produce multiple parallel tool calls. Budget counts individual tool calls; step cap counts iterations. At 200 steps with average 1-2 tool calls per step, the budget (50) will fire well before the step cap in most scenarios. The step cap is a safety ceiling for cases where the model makes many 1-tool-call iterations.
|
||||||
|
|
||||||
|
## D2. step_finish — emit or not?
|
||||||
|
|
||||||
|
**Decision:** No `step_finish` part. The next `step_start` (or assistant message completion) implicitly ends the previous step.
|
||||||
|
|
||||||
|
**Rationale:** opencode only emits `step_start`. Less noise in parts, simpler code. If UI ever needs step durations, compute from the timestamps of consecutive `step_start` parts.
|
||||||
|
|
||||||
|
## D3. Step-cap hit — sentinel or quiet?
|
||||||
|
|
||||||
|
**Decision:** Write a sentinel summary on step-cap hit. Visible to the user in chat, same as budget-exhaustion's `runCapHitSummary`.
|
||||||
|
|
||||||
|
**Implementation:** Extend `runCapHitSummary` to accept a `reason: 'budget' | 'step_cap'` parameter (or add a parallel `runStepCapSummary`). The sentinel metadata kind stays `cap_hit` — frontend `CapHitSentinel` component already renders it. The sentinel's text distinguishes the two cases ("Tool budget exhausted" vs "Step limit reached").
|
||||||
|
|
||||||
|
## D4. agent.steps = 0
|
||||||
|
|
||||||
|
**Decision:** `steps: 0` means "no tool calls allowed." The loop body never executes. The assistant can only respond with text.
|
||||||
|
|
||||||
|
**Implementation:** When `effectiveCap === 0`, skip the loop entirely. Stream the first assistant turn (text-only), finalize, return. The model receives no tools in the request payload when `steps: 0` (or equivalently, tools are passed but the loop never enters the tool-execution branch).
|
||||||
|
|
||||||
|
Actually, cleaner: `steps: 0` means the loop cap is 0. The while condition `stepNumber < effectiveCap` is false on the first check. The stream phase still runs (the model produces a text response), but if it emits tool calls they're ignored and the turn finalizes as text-only. This may produce a confusing response if the model's text references tool results it never got — but `steps: 0` is an explicit constraint the agent author chose. Document in AGENTS.md parser validation.
|
||||||
|
|
||||||
|
## D5. Synthesis success terminates the loop?
|
||||||
|
|
||||||
|
**Decision:** Yes. `break` out of the loop after synthesis success. Preserves current behavior (synthesis replaces the recursive call; no further iterations).
|
||||||
|
|
||||||
|
**Rationale:** The synthesis pass produces a self-contained summary turn. Continuing the loop after synthesis would let the model issue more tool calls on top of a synthesis summary, which is semantically wrong — the synthesis IS the final answer for that tool call batch.
|
||||||
|
|
||||||
|
## D6. executeToolPhase return struct
|
||||||
|
|
||||||
|
The recursive call at `tool-phase.ts:342` is currently the last thing `executeToolPhase` does (after creating the next assistant row). After the conversion, `executeToolPhase` returns a struct the loop body reads:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ToolPhaseResult {
|
||||||
|
action: 'continue' | 'paused' | 'synthesis_done';
|
||||||
|
toolCallCount: number;
|
||||||
|
toolCalls: ToolCall[];
|
||||||
|
nextAssistantId: string | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `continue` → loop continues; `nextAssistantId` is the new assistant message's UUID.
|
||||||
|
- `paused` → user-input or grant pause; loop breaks. `nextAssistantId` is null.
|
||||||
|
- `synthesis_done` → synthesis succeeded; loop breaks. `nextAssistantId` is null (synthesis wrote its own parts).
|
||||||
|
|
||||||
|
The loop body then:
|
||||||
|
1. Updates `toolsUsed += result.toolCallCount`
|
||||||
|
2. Appends `result.toolCalls` to `recentToolCalls`
|
||||||
|
3. Sets `assistantMessageId = result.nextAssistantId` for the next iteration
|
||||||
|
4. Increments `stepNumber`
|
||||||
|
5. Checks `result.action` — if not `continue`, breaks.
|
||||||
|
|
||||||
|
## D7. Budget vs steps interaction
|
||||||
|
|
||||||
|
Budget counts **individual tool calls** across the entire turn. Steps counts **loop iterations**. They are orthogonal:
|
||||||
|
|
||||||
|
- Budget fires when `toolsUsed >= resolveToolBudget(agent)` (currently 50 for read-only). Checked at the top of each iteration.
|
||||||
|
- Step cap fires when `stepNumber >= effectiveCap`. Checked by the loop condition.
|
||||||
|
|
||||||
|
Both produce a sentinel summary. A turn can be terminated by whichever fires first. In practice, budget (50 tool calls) fires before step cap (200 steps) unless the model produces many 0-tool-call iterations (which shouldn't happen — 0 tool calls means non-tool finish, which exits the loop via the `break` path).
|
||||||
112
openspec/changes/v1.14-outer-loop/proposal.md
Normal file
112
openspec/changes/v1.14-outer-loop/proposal.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# v1.14.0-outer-loop — explicit outer agent loop
|
||||||
|
|
||||||
|
Replace the ad-hoc `executeToolPhase → runAssistantTurn` recursion with an explicit `while` loop. A **step** is one stream-and-tool-execute iteration; a step can contain multiple parallel tool calls. The loop terminates on non-tool finish OR step-cap hit OR doom-loop OR budget exhaustion OR abort OR synthesis success.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The current recursion works but has two problems: (a) stack depth grows linearly with tool iterations — 50 nested async frames is fragile, (b) there's no explicit step counter, so there's no per-agent step cap and no step-boundary instrumentation. BooChat also gets stuck at 50 tool calls (the budget ceiling) more often than it should — the new `MAX_STEPS = 200` hard ceiling lets the loop run much longer before the step cap fires, while the existing budget (50 tool calls) remains a separate concern.
|
||||||
|
|
||||||
|
## Recon findings (verified 2026-05-23)
|
||||||
|
|
||||||
|
- `runAssistantTurn` at `turn.ts:144-147` is the recursive entry. Returns `Promise<void>`.
|
||||||
|
- `executeToolPhase` at `tool-phase.ts:89-96` calls back into `runAssistantTurn` at `tool-phase.ts:342`.
|
||||||
|
- Recursion terminates on: non-tool finish, budget exhaustion (`args.toolsUsed >= budget`), doom-loop (3 identical calls via `detectDoomLoop`), user-input pause (ask_user_input / request_read_access), synthesis success, stream error, abort.
|
||||||
|
- **No existing hard recursion depth limit** — `MAX_TOOL_LOOP_DEPTH` does not exist. Safety comes from budget (50) + doom-loop (3 identical).
|
||||||
|
- `TurnArgs` defined in `turn.ts:127-141`, not `types.ts`. Fields: `sessionId`, `chatId`, `assistantMessageId`, `toolsUsed`, `recentToolCalls`, `signal`. All mutable fields are threaded through the recursive call.
|
||||||
|
- Synthesis pipeline (`synthesisPipeline.ts`) is a branch in `executeToolPhase` — if synthesis succeeds, recursion is skipped.
|
||||||
|
- `step_start` already in the `message_parts.kind` CHECK constraint. No schema change needed.
|
||||||
|
- `agents.ts` does NOT currently parse a `steps` field. Needs adding to `ParsedFrontmatter`.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### S1. Outer loop in `turn.ts`
|
||||||
|
|
||||||
|
Convert the recursive chain to a `while (stepNumber < effectiveCap)` loop:
|
||||||
|
|
||||||
|
```
|
||||||
|
let stepNumber = 0
|
||||||
|
while (stepNumber < effectiveCap) {
|
||||||
|
// doom-loop check
|
||||||
|
// budget check
|
||||||
|
// emit step_start part
|
||||||
|
// stream phase (executeStreamPhase)
|
||||||
|
// if no tool calls → finalize, break
|
||||||
|
// tool phase (executeToolPhase — now returns, doesn't recurse)
|
||||||
|
// if paused (user input / grant) → break
|
||||||
|
// if synthesis succeeded → break
|
||||||
|
// create next assistant message row
|
||||||
|
// increment stepNumber, update toolsUsed, append recentToolCalls
|
||||||
|
}
|
||||||
|
// if stepNumber >= effectiveCap → sentinel summary
|
||||||
|
```
|
||||||
|
|
||||||
|
`effectiveCap = Math.min(agent.steps ?? Infinity, MAX_STEPS)` where `MAX_STEPS = 200`.
|
||||||
|
|
||||||
|
### S2. `executeToolPhase` becomes non-recursive
|
||||||
|
|
||||||
|
Remove the `runAssistantTurn` call at `tool-phase.ts:342`. Instead, return a result indicating what happened: `{action: 'continue' | 'paused' | 'synthesis_done', toolsUsed, recentToolCalls, nextAssistantId}`. The caller (the while loop) uses the action to decide whether to continue or break.
|
||||||
|
|
||||||
|
### S3. `agent.steps` field
|
||||||
|
|
||||||
|
`agents.ts:ParsedFrontmatter` gains `steps?: number`. Parser extracts it from YAML frontmatter (integer ≥ 0). `steps: 0` means "no tool calls allowed" — loop body never executes; assistant responds text-only.
|
||||||
|
|
||||||
|
### S4. Step-boundary events
|
||||||
|
|
||||||
|
At the top of each loop iteration, emit a `step_start` part with payload `{step_number, started_at}`. Uses `insertParts` into the current assistant message. No `step_finish` — the next `step_start` (or message completion) implicitly ends the previous step.
|
||||||
|
|
||||||
|
### S5. Doom-loop migration
|
||||||
|
|
||||||
|
`detectDoomLoop` check moves from `runAssistantTurn` (top of function, pre-stream) to the top of the while-loop body (same logical position). Same predicate, same threshold (3). Same `runDoomLoopSummary` call. Control flow changes from `return` (unwinding recursion) to `break` (exiting loop).
|
||||||
|
|
||||||
|
### S6. Step-cap sentinel
|
||||||
|
|
||||||
|
When `stepNumber >= effectiveCap`, write a sentinel summary like the existing `runCapHitSummary`. Reuse `runCapHitSummary` with a reason parameter distinguishing "budget exhaustion" from "step cap hit", or create a parallel `runStepCapSummary`. The sentinel makes the cap visible in chat.
|
||||||
|
|
||||||
|
### S7. AGENTS.md updates
|
||||||
|
|
||||||
|
Add `steps:` to each agent in `data/AGENTS.md`:
|
||||||
|
- Refactorer: `steps: 5`
|
||||||
|
- Architect: `steps: 20`
|
||||||
|
- All others: unset (infinity — bounded only by `MAX_STEPS = 200`)
|
||||||
|
|
||||||
|
### S8. Tests
|
||||||
|
|
||||||
|
New test file `apps/server/src/services/__tests__/outer-loop.test.ts` covering:
|
||||||
|
- Clean finish (stream returns non-tool, loop exits after 1 iteration)
|
||||||
|
- Step-cap hit (loop exits at cap, sentinel written)
|
||||||
|
- Doom-loop break (3 identical calls, sentinel written)
|
||||||
|
- Budget exhaustion (toolsUsed >= budget, cap-hit sentinel written)
|
||||||
|
- Abort mid-step (signal fires, loop exits)
|
||||||
|
- `steps: 0` edge case (no loop iterations, text-only response)
|
||||||
|
- Synthesis success (loop exits after synthesis)
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No frontend changes. `step_start` parts surface via `messages_with_parts` automatically; UI doesn't render them in v1.14.
|
||||||
|
- No `output_schema` / `exit_expression` / `execution_strategy` AGENTS.md fields.
|
||||||
|
- No per-step snapshot for revert (v2.0 BooCoder concern).
|
||||||
|
- No changes to budget constants (50 / 10 / 50). That's a separate concern.
|
||||||
|
- No `repairToolCall` changes.
|
||||||
|
- No compaction changes.
|
||||||
|
|
||||||
|
## Hard rules
|
||||||
|
|
||||||
|
- No git commit, push. Sam commits.
|
||||||
|
- Backup before editing.
|
||||||
|
- TS strict, no `any`.
|
||||||
|
- Doom-loop threshold stays at 3.
|
||||||
|
- 332+ existing tests still pass + new outer-loop tests.
|
||||||
|
|
||||||
|
## Files expected to touch
|
||||||
|
|
||||||
|
- `apps/server/src/services/inference/turn.ts` — recursion → loop
|
||||||
|
- `apps/server/src/services/inference/tool-phase.ts` — remove recursive call, return result struct
|
||||||
|
- `apps/server/src/services/inference/sentinel-summaries.ts` — step-cap sentinel (or extend cap-hit)
|
||||||
|
- `apps/server/src/services/agents.ts` — parse `steps` field
|
||||||
|
- `data/AGENTS.md` — add `steps:` to Refactorer + Architect
|
||||||
|
- `apps/server/src/services/__tests__/outer-loop.test.ts` — NEW
|
||||||
|
- `apps/server/src/services/inference/index.ts` — re-export if new types needed
|
||||||
|
|
||||||
|
## Estimate
|
||||||
|
|
||||||
|
~300 LoC net (turn.ts refactor + tool-phase return struct + agents parser + tests). The conversion is structural, not behavioral — every exit path is preserved, just expressed as loop control flow instead of recursion unwinding.
|
||||||
82
openspec/changes/v1.14-outer-loop/tasks.md
Normal file
82
openspec/changes/v1.14-outer-loop/tasks.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# v1.14.0-outer-loop tasks
|
||||||
|
|
||||||
|
## B1 — Backups
|
||||||
|
|
||||||
|
- [ ] `turn.ts`, `tool-phase.ts`, `sentinel-summaries.ts`, `agents.ts`, `data/AGENTS.md`
|
||||||
|
|
||||||
|
## B2 — agents.ts: parse `steps` field
|
||||||
|
|
||||||
|
- [ ] Add `steps?: number` to `ParsedFrontmatter` interface
|
||||||
|
- [ ] Parse from YAML frontmatter: integer ≥ 0, warn on out-of-range (negative or non-integer), clamp to 0
|
||||||
|
- [ ] Expose on the `Agent` type returned by `getAgentsForProject`
|
||||||
|
- [ ] `npx tsc --noEmit -p apps/server` clean
|
||||||
|
|
||||||
|
## B3 — AGENTS.md: add `steps:` to Refactorer + Architect
|
||||||
|
|
||||||
|
- [ ] `data/AGENTS.md` — Refactorer: `steps: 5`
|
||||||
|
- [ ] `data/AGENTS.md` — Architect: `steps: 20`
|
||||||
|
- [ ] All others: leave unset (infinite, bounded by MAX_STEPS=200)
|
||||||
|
|
||||||
|
## B4 — tool-phase.ts: remove recursive call, return result struct
|
||||||
|
|
||||||
|
- [ ] Define `ToolPhaseResult` interface: `{action: 'continue' | 'paused' | 'synthesis_done', toolCallCount: number, toolCalls: ToolCall[], nextAssistantId: string | null}`
|
||||||
|
- [ ] Remove `runAssistantTurn` import and call at line ~342
|
||||||
|
- [ ] `executeToolPhase` returns `ToolPhaseResult` instead of `Promise<void>`
|
||||||
|
- [ ] On normal path (after creating next assistant row): return `{action: 'continue', toolCallCount, toolCalls: result.toolCalls, nextAssistantId}`
|
||||||
|
- [ ] On user-input pause: return `{action: 'paused', toolCallCount: <calls executed so far>, toolCalls: result.toolCalls, nextAssistantId: null}`
|
||||||
|
- [ ] On synthesis success: return `{action: 'synthesis_done', toolCallCount, toolCalls: result.toolCalls, nextAssistantId: null}`
|
||||||
|
- [ ] `npx tsc --noEmit -p apps/server` will FAIL here (turn.ts still expects void) — expected, fixed in B5
|
||||||
|
|
||||||
|
## B5 — turn.ts: recursion → while loop
|
||||||
|
|
||||||
|
- [ ] Add `MAX_STEPS = 200` constant
|
||||||
|
- [ ] Resolve `effectiveCap = Math.min(agent?.steps ?? Infinity, MAX_STEPS)` at the top of `runAssistantTurn`
|
||||||
|
- [ ] Convert `runAssistantTurn` body into a `while (stepNumber < effectiveCap)` loop:
|
||||||
|
- Top of loop: doom-loop check (move from current position; `break` instead of `return`)
|
||||||
|
- Top of loop: budget check (move from current position; `break` instead of `return`, but still call `runCapHitSummary` before break)
|
||||||
|
- Emit `step_start` part via `insertParts` with payload `{step_number: stepNumber, started_at: new Date().toISOString()}`
|
||||||
|
- Call `executeStreamPhase`
|
||||||
|
- If no tool calls → `finalizeCompletion`, `break`
|
||||||
|
- Call `executeToolPhase` (now returns `ToolPhaseResult`)
|
||||||
|
- If `result.action !== 'continue'` → `break`
|
||||||
|
- Update `toolsUsed += result.toolCallCount`
|
||||||
|
- Update `recentToolCalls = [...recentToolCalls, ...result.toolCalls]`
|
||||||
|
- Update `assistantMessageId = result.nextAssistantId!`
|
||||||
|
- Increment `stepNumber`
|
||||||
|
- [ ] After loop: if `stepNumber >= effectiveCap` → call step-cap sentinel (B6)
|
||||||
|
- [ ] `effectiveCap === 0` edge case: the while condition is immediately false; stream the first turn text-only (the stream phase at the top of the function runs once before the loop — OR handle this by structuring the loop as do-while, OR handle by pre-checking and skipping tools from the request). Pick the cleanest approach.
|
||||||
|
- [ ] Remove `TurnArgs` from the module export if it's no longer threaded through recursion — OR keep it and populate from loop locals. (Design note: `TurnArgs` is still used by `executeStreamPhase`, `executeToolPhase`, `sentinel-summaries.ts`, `error-handler.ts`. Keep the interface; populate from loop locals each iteration.)
|
||||||
|
- [ ] `npx tsc --noEmit -p apps/server` clean
|
||||||
|
- [ ] `pnpm -C apps/server test` — all existing tests pass
|
||||||
|
|
||||||
|
## B6 — sentinel-summaries.ts: step-cap sentinel
|
||||||
|
|
||||||
|
- [ ] Add `runStepCapSummary` (or extend `runCapHitSummary` with a `reason` param)
|
||||||
|
- [ ] Write a sentinel with `metadata.kind = 'cap_hit'` (same as budget) so `CapHitSentinel` UI renders it
|
||||||
|
- [ ] Sentinel text distinguishes "Step limit reached (N steps)" from "Tool budget exhausted (N calls)"
|
||||||
|
- [ ] Called from the post-loop check in turn.ts (B5)
|
||||||
|
|
||||||
|
## B7 — Tests
|
||||||
|
|
||||||
|
- [ ] NEW `apps/server/src/services/__tests__/outer-loop.test.ts`
|
||||||
|
- [ ] Test: clean finish — stream returns no tool calls, loop exits after 1 step
|
||||||
|
- [ ] Test: step-cap hit — mock agent with `steps: 2`, model always returns tool calls, loop exits at 2, sentinel written
|
||||||
|
- [ ] Test: doom-loop — 3 identical tool calls, sentinel written, loop breaks
|
||||||
|
- [ ] Test: budget exhaustion — toolsUsed >= budget, cap-hit sentinel written
|
||||||
|
- [ ] Test: `steps: 0` — no loop iterations, text-only response
|
||||||
|
- [ ] Test: synthesis success — loop breaks after synthesis
|
||||||
|
- [ ] `pnpm -C apps/server test` — all 332+ existing + new tests pass
|
||||||
|
|
||||||
|
## B8 — Verification
|
||||||
|
|
||||||
|
- [ ] `npx tsc --noEmit -p apps/server` — 0 errors
|
||||||
|
- [ ] `npx tsc -p apps/web/tsconfig.app.json --noEmit` — 0 errors (no web changes; should pass)
|
||||||
|
- [ ] `pnpm -C apps/web build` — green
|
||||||
|
- [ ] `pnpm -C apps/server test` — all green
|
||||||
|
|
||||||
|
## B9 — Docs + tag + deploy
|
||||||
|
|
||||||
|
- [ ] `CHANGELOG.md` entry for v1.14.0-outer-loop
|
||||||
|
- [ ] `boocode_roadmap.md` retrospective bullet on the v1.14 section
|
||||||
|
- [ ] `CLAUDE.md` updates: mention the outer loop, MAX_STEPS, agent.steps in the inference/ section
|
||||||
|
- [ ] Commit, tag `v1.14.0-outer-loop`, push, rebuild
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user