Compare commits

..

2 Commits

Author SHA1 Message Date
ONLY-yours a823410bba 🐛 fix(gateway): guard against stale operation after token refresh
Re-check the topic's running operationId after the async token-refresh
await. A newer executeGatewayAgent call may have taken over for the
same topic during that wait, which would cause two concurrent streams.
Bail early if the operationId no longer matches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 10:38:30 +08:00
ONLY-yours fbda59ff28 🐛 fix(agent-builder): explicitly sync editing agent ID to chatStore
The Agent Builder reads the wrong agent's context because
`getChatStoreState().activeAgentId` — which the chat service uses to
build `agentBuilderContext` — can drift from the agent currently open in
the profile editor under certain timing conditions (SWR cache hits,
navigation order, React effect scheduling).

Fix: `AgentBuilderProvider` now accepts an `editingAgentId` prop and
writes it to `chatStore.activeAgentId` in a `useEffect`. This makes
the data flow explicit instead of relying on `AgentIdSync` alone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 20:12:52 +08:00
2671 changed files with 33508 additions and 207076 deletions
+2 -2
View File
@@ -51,7 +51,7 @@ export interface GlobalServerConfig {
### 3. Assemble Server Config (if new domain)
In `apps/server/src/globalConfig/index.ts`:
In `src/server/globalConfig/index.ts`:
```typescript
import { <domain>Env } from '@/envs/<domain>';
@@ -97,7 +97,7 @@ AI_IMAGE_DEFAULT_IMAGE_NUM: z.coerce.number().min(1).max(20).optional(),
// packages/types/src/serverConfig.ts
image?: PartialDeep<UserImageConfig>;
// apps/server/src/globalConfig/index.ts
// src/server/globalConfig/index.ts
image: cleanObject({ defaultImageNum: imageEnv.AI_IMAGE_DEFAULT_IMAGE_NUM }),
// src/store/user/slices/common/action.ts
+9 -9
View File
@@ -1,6 +1,6 @@
---
name: agent-runtime-hooks
description: 'Agent runtime lifecycle hooks. Use for before/after tool or step hooks, tool mocks, human intervention, sub-agent calls, context compression, evals, tracing, callAgent, or lifecycle events.'
description: "Agent runtime lifecycle hooks for observing and intercepting agent execution. Use when adding hooks to agent operations, mocking tool calls, logging step events, handling human intervention, sub-agent calls, context compression, or building eval/tracing integrations. Triggers on 'hooks', 'beforeToolCall', 'afterToolCall', 'beforeStep', 'afterStep', 'onComplete', 'onError', 'tool mock', 'agent lifecycle', 'human intervention', 'callAgent', 'compact'."
user-invocable: false
---
@@ -50,14 +50,14 @@ execAgent({ hooks })
## Key Files
| File | Role |
| --------------------------------------------------------------- | ------------------------------------------------------ |
| `packages/agent-runtime/src/types/hooks.ts` | Type definitions (AgentHookType, all event interfaces) |
| `apps/server/src/services/agentRuntime/hooks/types.ts` | Server-side types (AgentHook, re-exports) |
| `apps/server/src/services/agentRuntime/hooks/HookDispatcher.ts` | Registration, dispatch, dispatchBeforeToolCall |
| `apps/server/src/modules/AgentRuntime/RuntimeExecutors.ts` | Tool/Compact/HumanIntervention hook dispatch |
| `apps/server/src/services/agentRuntime/AgentRuntimeService.ts` | Step hooks + HumanIntervention resume/reject |
| `apps/server/src/services/aiAgent/index.ts` | CallAgent hook dispatch |
| File | Role |
| ---------------------------------------------------------- | ------------------------------------------------------ |
| `packages/agent-runtime/src/types/hooks.ts` | Type definitions (AgentHookType, all event interfaces) |
| `src/server/services/agentRuntime/hooks/types.ts` | Server-side types (AgentHook, re-exports) |
| `src/server/services/agentRuntime/hooks/HookDispatcher.ts` | Registration, dispatch, dispatchBeforeToolCall |
| `src/server/modules/AgentRuntime/RuntimeExecutors.ts` | Tool/Compact/HumanIntervention hook dispatch |
| `src/server/services/agentRuntime/AgentRuntimeService.ts` | Step hooks + HumanIntervention resume/reject |
| `src/server/services/aiAgent/index.ts` | CallAgent hook dispatch |
## Registration Flow
+19 -19
View File
@@ -1,6 +1,6 @@
---
name: agent-signal
description: 'Build or extend LobeHub Agent Signal pipelines. Use for signal sources, signal/action types, policies, middleware, workflow handoff, dedupe, scope behavior, or observability.'
description: Build or extend LobeHub Agent Signal pipelines for background or quiet agent work driven by event sources, semantic signals, and action handlers. Use when adding a new Agent Signal source, signal or action type, policy, middleware handler, workflow handoff, dedupe or scope behavior, or observability around `src/server/services/agentSignal/**`, `packages/agent-signal`, or `packages/observability-otel/src/modules/agent-signal`.
---
# Agent Signal
@@ -26,9 +26,9 @@ Agent Signal has one consistent shape:
Read:
- `apps/server/src/services/agentSignal/index.ts`
- `apps/server/src/workflows/agentSignal/index.ts`
- `apps/server/src/workflows/agentSignal/run.ts`
- `src/server/services/agentSignal/index.ts`
- `src/server/workflows/agentSignal/index.ts`
- `src/server/workflows/agentSignal/run.ts`
## Core Model
@@ -48,11 +48,11 @@ Keep the boundaries strict:
## Implementation Workflow
1. Decide whether the use case is synchronous or quiet background work.
2. Define or reuse a source type in `apps/server/src/services/agentSignal/sourceTypes.ts`.
3. Define or reuse signal and action types in `apps/server/src/services/agentSignal/policies/types.ts`.
2. Define or reuse a source type in `src/server/services/agentSignal/sourceTypes.ts`.
3. Define or reuse signal and action types in `src/server/services/agentSignal/policies/types.ts`.
4. Implement handlers with `defineSourceHandler`, `defineSignalHandler`, or `defineActionHandler`.
5. Bundle handlers with `defineAgentSignalHandlers(...)`.
6. Register the policy in `apps/server/src/services/agentSignal/policies/index.ts` and pass it into the runtime factory if needed.
6. Register the policy in `src/server/services/agentSignal/policies/index.ts` and pass it into the runtime factory if needed.
7. Add or update ingress code that emits or enqueues the source event.
8. Add observability and tests before considering the flow complete.
@@ -63,19 +63,19 @@ Keep the boundaries strict:
`packages/agent-signal/src/base/builders.ts`
`packages/agent-signal/src/base/types.ts`
- Server-owned runtime and middleware:
`apps/server/src/services/agentSignal/runtime/AgentSignalRuntime.ts`
`apps/server/src/services/agentSignal/runtime/AgentSignalScheduler.ts`
`apps/server/src/services/agentSignal/runtime/middleware.ts`
`apps/server/src/services/agentSignal/runtime/context.ts`
`src/server/services/agentSignal/runtime/AgentSignalRuntime.ts`
`src/server/services/agentSignal/runtime/AgentSignalScheduler.ts`
`src/server/services/agentSignal/runtime/middleware.ts`
`src/server/services/agentSignal/runtime/context.ts`
- Existing policy example:
`apps/server/src/services/agentSignal/policies/analyzeIntent/index.ts`
`apps/server/src/services/agentSignal/policies/analyzeIntent/feedbackSatisfaction.ts`
`apps/server/src/services/agentSignal/policies/analyzeIntent/feedbackDomain.ts`
`apps/server/src/services/agentSignal/policies/analyzeIntent/feedbackAction.ts`
`apps/server/src/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
`src/server/services/agentSignal/policies/analyzeIntent/index.ts`
`src/server/services/agentSignal/policies/analyzeIntent/feedbackSatisfaction.ts`
`src/server/services/agentSignal/policies/analyzeIntent/feedbackDomain.ts`
`src/server/services/agentSignal/policies/analyzeIntent/feedbackAction.ts`
`src/server/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
- Observability:
`apps/server/src/services/agentSignal/observability/projector.ts`
`apps/server/src/services/agentSignal/observability/traceEvents.ts`
`src/server/services/agentSignal/observability/projector.ts`
`src/server/services/agentSignal/observability/traceEvents.ts`
`packages/observability-otel/src/modules/agent-signal/index.ts`
## Implementation Rules
@@ -86,7 +86,7 @@ Keep the boundaries strict:
- Use stable ids and idempotency keys when the same source can arrive more than once.
- Preserve scope discipline. The runtime uses `scopeKey` to serialize related background work.
- Prefer the dedicated shared package types and builders from `@lobechat/agent-signal` for normalized nodes and result contracts.
- Add focused tests near the touched runtime, policy, or store module. Existing tests under `apps/server/src/services/agentSignal/**/__tests__` are the reference pattern.
- Add focused tests near the touched runtime, policy, or store module. Existing tests under `src/server/services/agentSignal/**/__tests__` are the reference pattern.
## References
@@ -32,9 +32,9 @@ source node
Read:
- `apps/server/src/services/agentSignal/index.ts`
- `apps/server/src/services/agentSignal/sources/index.ts`
- `apps/server/src/services/agentSignal/runtime/AgentSignalScheduler.ts`
- `src/server/services/agentSignal/index.ts`
- `src/server/services/agentSignal/sources/index.ts`
- `src/server/services/agentSignal/runtime/AgentSignalScheduler.ts`
## Package Boundaries
@@ -56,7 +56,7 @@ Read:
- `packages/agent-signal/src/types/events.ts`
- `packages/agent-signal/src/types/builtin.ts`
### `apps/server/src/services/agentSignal`
### `src/server/services/agentSignal`
Treat this as the server-owned implementation layer.
@@ -89,11 +89,11 @@ Examples:
Define source payloads in:
- `apps/server/src/services/agentSignal/sourceTypes.ts`
- `src/server/services/agentSignal/sourceTypes.ts`
Build normalized sources in:
- `apps/server/src/services/agentSignal/sources/buildSource.ts`
- `src/server/services/agentSignal/sources/buildSource.ts`
- `packages/agent-signal/src/base/builders.ts`
### Signal
@@ -109,7 +109,7 @@ Examples from `analyzeIntent`:
Define server-owned signal types in:
- `apps/server/src/services/agentSignal/policies/types.ts`
- `src/server/services/agentSignal/policies/types.ts`
### Action
@@ -157,9 +157,9 @@ When a user asks for "the procedure", document the flow above and point to the e
Read:
- `apps/server/src/services/agentSignal/sources/index.ts`
- `apps/server/src/services/agentSignal/runtime/context.ts`
- `apps/server/src/services/agentSignal/constants.ts`
- `src/server/services/agentSignal/sources/index.ts`
- `src/server/services/agentSignal/runtime/context.ts`
- `src/server/services/agentSignal/constants.ts`
Use `enqueueAgentSignalSourceEvent(...)` when the work should stay quiet and out-of-band. That path:
@@ -172,8 +172,8 @@ This is the preferred path when the UI request should finish immediately and the
Read:
- `apps/server/src/workflows/agentSignal/index.ts`
- `apps/server/src/workflows/agentSignal/run.ts`
- `src/server/workflows/agentSignal/index.ts`
- `src/server/workflows/agentSignal/run.ts`
## Existing Example: `analyzeIntent`
@@ -192,8 +192,8 @@ agent.user.message
Read:
- `apps/server/src/services/agentSignal/policies/analyzeIntent/index.ts`
- `apps/server/src/services/agentSignal/policies/analyzeIntent/feedbackSatisfaction.ts`
- `apps/server/src/services/agentSignal/policies/analyzeIntent/feedbackDomain.ts`
- `apps/server/src/services/agentSignal/policies/analyzeIntent/feedbackAction.ts`
- `apps/server/src/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
- `src/server/services/agentSignal/policies/analyzeIntent/index.ts`
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackSatisfaction.ts`
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackDomain.ts`
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackAction.ts`
- `src/server/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
@@ -2,7 +2,7 @@
## Fluent Registration API
Use the middleware helpers in `apps/server/src/services/agentSignal/runtime/middleware.ts`.
Use the middleware helpers in `src/server/services/agentSignal/runtime/middleware.ts`.
They provide:
@@ -32,7 +32,7 @@ The context gives you:
Read:
- `apps/server/src/services/agentSignal/runtime/context.ts`
- `src/server/services/agentSignal/runtime/context.ts`
## Return Contracts
@@ -48,7 +48,7 @@ Return one of these shapes:
Read:
- `packages/agent-signal/src/base/types.ts`
- `apps/server/src/services/agentSignal/runtime/AgentSignalScheduler.ts`
- `src/server/services/agentSignal/runtime/AgentSignalScheduler.ts`
## Policy Composition Pattern
@@ -72,8 +72,8 @@ That bundle is later passed into the runtime via:
Read:
- `apps/server/src/services/agentSignal/policies/index.ts`
- `apps/server/src/services/agentSignal/policies/analyzeIntent/index.ts`
- `src/server/services/agentSignal/policies/index.ts`
- `src/server/services/agentSignal/policies/analyzeIntent/index.ts`
## Source Handler Pattern
@@ -81,7 +81,7 @@ Use a source handler when you are interpreting a producer event into semantic si
Reference:
- `apps/server/src/services/agentSignal/policies/analyzeIntent/feedbackSatisfaction.ts`
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackSatisfaction.ts`
Pattern:
@@ -114,8 +114,8 @@ Use a signal handler when one semantic state should branch into more semantic st
References:
- `apps/server/src/services/agentSignal/policies/analyzeIntent/feedbackDomain.ts`
- `apps/server/src/services/agentSignal/policies/analyzeIntent/feedbackAction.ts`
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackDomain.ts`
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackAction.ts`
Pattern:
@@ -148,7 +148,7 @@ Use an action handler when the runtime should do actual work.
Reference:
- `apps/server/src/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
- `src/server/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
Pattern:
@@ -186,9 +186,9 @@ Keep these rules:
Use this split:
- external event payloads:
`apps/server/src/services/agentSignal/sourceTypes.ts`
`src/server/services/agentSignal/sourceTypes.ts`
- policy-owned signal and action payloads:
`apps/server/src/services/agentSignal/policies/types.ts`
`src/server/services/agentSignal/policies/types.ts`
- normalized shared node contracts:
`packages/agent-signal/src/base/types.ts`
@@ -216,10 +216,10 @@ Prefer focused tests near the touched code.
Useful references:
- `apps/server/src/services/agentSignal/runtime/__tests__/AgentSignalRuntime.test.ts`
- `apps/server/src/services/agentSignal/__tests__/index.integration.test.ts`
- `apps/server/src/services/agentSignal/policies/analyzeIntent/__tests__/*`
- `apps/server/src/services/agentSignal/policies/analyzeIntent/actions/__tests__/*`
- `src/server/services/agentSignal/runtime/__tests__/AgentSignalRuntime.test.ts`
- `src/server/services/agentSignal/__tests__/index.integration.test.ts`
- `src/server/services/agentSignal/policies/analyzeIntent/__tests__/*`
- `src/server/services/agentSignal/policies/analyzeIntent/actions/__tests__/*`
Test at the smallest level that proves the behavior:
@@ -24,9 +24,9 @@ After runtime execution, the service projects one compact observability model fr
Read:
- `apps/server/src/services/agentSignal/observability/projector.ts`
- `apps/server/src/services/agentSignal/observability/traceEvents.ts`
- `apps/server/src/services/agentSignal/observability/store.ts`
- `src/server/services/agentSignal/observability/projector.ts`
- `src/server/services/agentSignal/observability/traceEvents.ts`
- `src/server/services/agentSignal/observability/store.ts`
Projection outputs:
@@ -58,7 +58,7 @@ Workflow-triggered runs do not naturally pass through the normal foreground runt
Read:
- `apps/server/src/workflows/agentSignal/run.ts`
- `src/server/workflows/agentSignal/run.ts`
Use that path when:
@@ -77,8 +77,8 @@ Check:
Read:
- `apps/server/src/services/agentSignal/index.ts`
- `apps/server/src/services/agentSignal/sources/index.ts`
- `src/server/services/agentSignal/index.ts`
- `src/server/services/agentSignal/sources/index.ts`
### The signal exists but no action runs
@@ -98,8 +98,8 @@ Check:
Reference:
- `apps/server/src/services/agentSignal/policies/actionIdempotency.ts`
- `apps/server/src/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
- `src/server/services/agentSignal/policies/actionIdempotency.ts`
- `src/server/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
### Background runs are hard to discover
+3 -3
View File
@@ -1,6 +1,6 @@
---
name: agent-tracing
description: 'Agent tracing CLI for execution snapshots. Use for agent-tracing, traces, snapshots, LLM call inspection, context engine data, agent step analysis, or execution debugging.'
description: "Agent tracing CLI for inspecting agent execution snapshots. Use when user mentions 'agent-tracing', 'trace', 'snapshot', wants to debug agent execution, inspect LLM calls, view context engine data, or analyze agent steps. Triggers on agent debugging, trace inspection, or execution analysis tasks."
user-invocable: false
---
@@ -216,6 +216,6 @@ When using `--messages`, the output shows three sections (if context engine data
## Integration Points
- **Recording**: `apps/server/src/services/agentRuntime/AgentRuntimeService.ts` — in the `executeStep()` method, after building `stepPresentationData`, writes partial snapshot in dev mode
- **Context engine capture**: `apps/server/src/modules/AgentRuntime/RuntimeExecutors.ts` — in `call_llm` executor, after `serverMessagesEngine()` returns, calls `ctx.tracingContextEngine(input, output)`. `AgentRuntimeService.executeStep` buffers it per step and passes it to `traceRecorder.appendStep` as the typed `contextEngine` field (kept off the `events` array to stay out of Redis state).
- **Recording**: `src/server/services/agentRuntime/AgentRuntimeService.ts` — in the `executeStep()` method, after building `stepPresentationData`, writes partial snapshot in dev mode
- **Context engine capture**: `src/server/modules/AgentRuntime/RuntimeExecutors.ts` — in `call_llm` executor, after `serverMessagesEngine()` returns, calls `ctx.tracingContextEngine(input, output)`. `AgentRuntimeService.executeStep` buffers it per step and passes it to `traceRecorder.appendStep` as the typed `contextEngine` field (kept off the `events` array to stay out of Redis state).
- **Store**: `FileSnapshotStore` reads/writes to `.agent-tracing/` relative to `process.cwd()`
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: builtin-tool
description: 'Build LobeHub builtin tool packages. Use when adding agent-callable tools, manifests, executors, runtimes, inspectors, renders, placeholders, streaming, interventions, portals, or tool registries.'
description: Build a new builtin tool package under `packages/builtin-tool-<name>/`. Use when adding a new agent-callable toolset, designing its API surface (manifest / ApiName / Params / State), implementing the Executor + ExecutionRuntime, building the Inspector / Render / Placeholder / Streaming / Intervention / Portal UI, or wiring a tool into the central registries (`packages/builtin-tools/src/{index,identifiers,inspectors,renders,placeholders,streamings,interventions,portals}.ts` and `src/store/tool/slices/builtin/executors/index.ts`). Triggers on "new builtin tool", "add a tool", "tool inspector", "tool render", "tool placeholder", "tool streaming", "tool intervention", "BuiltinToolManifest", "BaseExecutor", "ExecutionRuntime".
---
# Builtin Tool Authoring Guide
@@ -271,7 +271,7 @@ Lists in the same file you may need to touch:
- `defaultToolIds` — added to the agent's tool list by default
- `alwaysOnToolIds` — forced on regardless of user selection (use sparingly)
- `runtimeManagedToolIds` — enable state controlled by runtime, not user UI; **must mirror the rules map** in `apps/server/src/modules/Mecha/AgentToolsEngine/index.ts` and `src/helpers/toolEngineering/index.ts`
- `runtimeManagedToolIds` — enable state controlled by runtime, not user UI; **must mirror the rules map** in `src/server/modules/Mecha/AgentToolsEngine/index.ts` and `src/helpers/toolEngineering/index.ts`
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: chat-sdk
description: 'Build multi-platform chat bots with the chat SDK. Use for Slack, Teams, Google Chat, Discord, GitHub, Linear bots, webhooks, mentions, slash commands, cards, modals, or streaming responses.'
description: "Build multi-platform chat bots with the Chat SDK (`chat` npm package) — Slack, Teams, Google Chat, Discord, GitHub, Linear. Use when building a chat bot, handling mentions / messages / reactions / slash commands / cards / modals / streaming, setting up a webhook handler, or sending interactive cards / streaming AI responses to a chat platform. Triggers on `@chat-adapter`, 'chat sdk', 'chat bot', 'slack bot', 'teams bot', 'discord bot', 'webhook handler', 'cross-platform bot'."
user-invocable: false
---
+67 -21
View File
@@ -29,9 +29,10 @@ Standard workflow for verifying backend changes using the LobeHub CLI (`lh`) aga
## Quick Reference
All CLI dev commands run from `lobehub/apps/cli/`. Subsequent examples use `$CLI`:
All CLI dev commands run from `lobehub/apps/cli/`:
```bash
# Shorthand for all commands below
CLI="LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts"
```
@@ -39,14 +40,17 @@ CLI="LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts"
### Step 1: Ensure Dev Server is Running
Check if the dev server is already running:
```bash
curl -s -o /dev/null -w '%{http_code}' http://localhost:3011/ 2> /dev/null
```
- **If reachable**: skip to Step 2.
- **If unreachable**: start from cloud repo root:
- **If reachable** (returns any HTTP status): server is running. Skip to Step 2.
- **If unreachable**: start the server:
```bash
# From cloud repo root
pnpm run dev:next
```
@@ -57,37 +61,41 @@ lsof -ti:3011 | xargs kill
pnpm run dev:next
```
**Important:** Server-side code changes in the submodule (`lobehub/apps/server/src/`, `lobehub/src/server/`, `lobehub/packages/`) require a server restart. Next.js hot-reload may not pick up changes in submodule packages.
**Important:** Server-side code changes in the submodule (`lobehub/src/server/`, `lobehub/packages/`) require a server restart. Next.js hot-reload may not pick up changes in submodule packages.
### Step 2: Check CLI Authentication
Check if dev credentials already exist:
```bash
cat lobehub/apps/cli/.lobehub-dev/settings.json 2> /dev/null
```
- **If file exists and contains `"serverUrl": "http://localhost:3011"`**: skip to Step 3.
- **If missing or wrong server**: ask the user to run:
- **If file exists and contains `"serverUrl": "http://localhost:3011"`**: already authenticated. Skip to Step 3.
- **If file missing or points to wrong server**: login is needed. Ask the user to run:
```bash
! cd lobehub/apps/cli && LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server http://localhost:3011
```
> Login requires interactive browser authorization (OIDC Device Code Flow), so the user must run it themselves via `!` prefix. Credentials persist in `lobehub/apps/cli/.lobehub-dev/`.
> Login requires interactive browser authorization (OIDC Device Code Flow), so the user must run it themselves via `!` prefix. After login, credentials are saved to `lobehub/apps/cli/.lobehub-dev/` and persist across sessions.
### Step 3: Test with CLI Commands
CLI runs from source, so CLI-side code changes take effect immediately without rebuilding.
CLI runs from source (`bun src/index.ts`), so CLI-side code changes take effect immediately without rebuilding.
```bash
cd lobehub/apps/cli
$CLI <command>
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts <command>
```
### Step 4: Clean Up Test Data
Delete any test data created during verification:
```bash
$CLI task delete < id > -y
$CLI agent delete < id > -y
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts task delete < id > -y
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts agent delete < id > -y
```
## Common Testing Patterns
@@ -95,30 +103,51 @@ $CLI agent delete < id > -y
### Task System
```bash
# List tasks
$CLI task list
# Create test data with nesting
$CLI task create -n "Root Task" -i "Test instruction"
$CLI task create -n "Child Task" -i "Sub instruction" --parent T-1
# View task detail (tests getTaskDetail service)
$CLI task view T-1
# View task tree
$CLI task tree T-1
# Test lifecycle
$CLI task edit T-1 --status running
$CLI task comment T-1 -m "Test comment"
# Clean up
$CLI task delete T-1 -y
```
### Agent System
```bash
# List agents
$CLI agent list
# View agent detail
$CLI agent view <agent-id>
# Run agent (tests agent execution pipeline)
$CLI agent run <agent-id> -m "Test prompt"
```
### Document & Knowledge Base
```bash
# List documents
$CLI doc list
# Create and view
$CLI doc create -t "Test Doc" -c "Content here"
$CLI doc view <doc-id>
# Knowledge base
$CLI kb list
$CLI kb tree <kb-id>
```
@@ -126,13 +155,18 @@ $CLI kb tree <kb-id>
### Model & Provider
```bash
# List models and providers
$CLI model list
$CLI provider list
# Test provider connectivity
$CLI provider test <provider-id>
```
## Dev-Test Cycle
The standard cycle for backend development:
```
1. Make code changes (service/model/router/type)
|
@@ -143,22 +177,25 @@ $CLI provider test <provider-id>
lsof -ti:3011 | xargs kill && pnpm run dev:next
|
4. CLI verification (end-to-end)
$CLI <command>
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts <command>
|
5. Clean up test data
```
### When Server Restart is Needed
| Change Location | Restart? |
| ------------------------------------------------------- | -------- |
| `lobehub/apps/server/src/` (routers, services, modules) | Yes |
| `lobehub/src/server/` (agent-hono, workflows-hono) | Yes |
| `lobehub/packages/database/` (models) | Yes |
| `lobehub/packages/types/` | Yes |
| `lobehub/packages/prompts/` | Yes |
| `lobehub/apps/cli/` (CLI code) | No |
| `src/` (cloud overrides) | Yes |
| Change Location | Restart? |
| ----------------------------------------- | -------- |
| `lobehub/src/server/` (routers, services) | Yes |
| `lobehub/packages/database/` (models) | Yes |
| `lobehub/packages/types/` | Yes |
| `lobehub/packages/prompts/` | Yes |
| `lobehub/apps/cli/` (CLI code) | No |
| `src/` (cloud overrides) | Yes |
### When Server Restart is NOT Needed
CLI runs from source via `bun src/index.ts`, so any changes to `lobehub/apps/cli/src/` take effect immediately on next command invocation.
## Troubleshooting
@@ -170,3 +207,12 @@ $CLI provider test <provider-id>
| CLI shows old data/behavior | Server needs restart to pick up code changes |
| `EADDRINUSE` on port 3011 | Server already running; kill with `lsof -ti:3011 \| xargs kill` |
| Login opens wrong server | Must use `--server http://localhost:3011` flag (env var doesn't work) |
## Credential Isolation
| Mode | Credential Dir | Server |
| ---------- | -------------------------------- | ----------------- |
| Dev | `lobehub/apps/cli/.lobehub-dev/` | `localhost:3011` |
| Production | `~/.lobehub/` | `app.lobehub.com` |
The two environments are completely isolated. Dev mode credentials are gitignored.
+5 -5
View File
@@ -111,7 +111,7 @@ Generate video from text prompt. This is an async operation.
**Source**: `apps/cli/src/commands/generate/video.ts`
```bash
lh gen video "A cat playing piano" -m < model > -p < provider > [options]
lh gen video "A cat playing piano" -m <model> -p <provider> [options]
```
| Option | Description | Required |
@@ -259,13 +259,13 @@ Image and video generation use an async task pattern:
UUID from the `async_tasks` table, not `gen_xxx`
- Returns `{ status, error, generation }` (generation includes asset URLs on success)
- Before querying, calls `checkTimeoutTasks` which marks tasks as `error` if they have been
`pending` or `processing` for more than \~5 minutes (`ASYNC_TASK_TIMEOUT = 298s`)
`pending` or `processing` for more than ~5 minutes (`ASYNC_TASK_TIMEOUT = 298s`)
**Server routes**:
- `apps/server/src/routers/lambda/image/index.ts` — image creation (uses `authedProcedure` + `serverDatabase`)
- `apps/server/src/routers/lambda/video/index.ts` — video creation (uses `authedProcedure` + `serverDatabase`)
- `apps/server/src/routers/lambda/generation.ts` — status checking
- `src/server/routers/lambda/image/index.ts` — image creation (uses `authedProcedure` + `serverDatabase`)
- `src/server/routers/lambda/video/index.ts` — video creation (uses `authedProcedure` + `serverDatabase`)
- `src/server/routers/lambda/generation.ts` — status checking
- `packages/database/src/models/asyncTask.ts``AsyncTaskModel` including `checkTimeoutTasks`
**Note**: Image/video routes do NOT use the `keyVaults` middleware — they read API keys from the database via `initModelRuntimeFromDB` or `createAsyncCaller`.
@@ -1,6 +1,6 @@
---
name: data-fetching-architecture
description: 'LobeHub data-fetching pipeline guide. Use for service layer, Zustand store, SWR, lambdaClient, useClientDataSWR, useFetchXxx hooks, or migrating useEffect fetches.'
description: Standardized data-fetching pipeline guide — Service layer + Zustand Store + SWR. Use when implementing a data-fetching feature, creating a `xxxService`, adding a `useFetchXxx` hook, wiring `useClientDataSWR`, or migrating ad-hoc `useEffect + fetch` to the standard pipeline. Triggers on `lambdaClient`, `useClientDataSWR`, `xxxService`, `useFetchXxx`, 'data fetching', 'fetch architecture', 'service layer', 'SWR hook', 'migrate useEffect'.
user-invocable: false
---
+1 -61
View File
@@ -1,71 +1,11 @@
---
name: db-migrations
description: 'Use for Drizzle migrations: schema/table/column changes, migration generation or regeneration, sequence conflicts after rebase, idempotent SQL review, or migration renames.'
description: 'Use when generating or regenerating Drizzle migration files, changing database schema tables or columns, resolving migration sequence conflicts after rebase, reviewing migration SQL for idempotent patterns, or renaming migration files.'
user-invocable: false
---
# Database Migrations Guide
## Development-stage schema changes
Schema changes churn during feature development. When the schema changes before the migration has shipped, do not hand-edit the existing migration SQL to chase the new schema shape. Delete the draft migration artifacts added by this branch (SQL file, matching snapshot, and matching journal entry), then run the generator again and re-apply the normal migration review steps below.
For example, if this branch's draft migration is `0110_add_verify_tables_and_ai_infra_id`:
```bash
# 1. Delete the draft SQL and its snapshot
rm packages/database/migrations/0110_add_verify_tables_and_ai_infra_id.sql
rm packages/database/migrations/meta/0110_snapshot.json
# 2. Remove the matching 0110 entry from the journal's "entries" array
# packages/database/migrations/meta/_journal.json
# 3. Regenerate from the current schema
bun run db:generate
```
This keeps the generated SQL, snapshot, and journal aligned with the actual schema. Manual SQL edits are reserved for review-time hardening such as idempotent clauses, custom extension SQL, and meaningful filename/tag updates.
Before release, if a feature branch accumulated multiple development-only migrations, consolidate them into one migration when possible. Production does not need to replay every intermediate draft shape, and fewer migrations reduce deploy-time risk.
For example, if this branch added `0110`, `0111`, and `0112`, delete all three drafts and regenerate a single migration:
```bash
# 1. Delete every draft SQL and snapshot this branch added
rm packages/database/migrations/011{0,1,2}_*.sql
rm packages/database/migrations/meta/011{0,1,2}_snapshot.json
# 2. Remove the 0110/0111/0112 entries from the journal's "entries" array
# packages/database/migrations/meta/_journal.json
# 3. Regenerate one migration covering the full schema delta
bun run db:generate
```
Do not make a migration compatible with earlier development-only versions of the same branch. While the migration has not shipped, there is no production history to preserve. Fix local/dev databases directly with whatever SQL is simplest (drop the draft table, rename a column, delete draft rows), then regenerate the branch migration from the current schema.
For example, if an earlier draft on this branch created `signup_attempt_id` and you have since renamed it to `user_signup_log_id`, do not add a compatibility `ALTER ... RENAME` to the migration. Just fix the dev DB directly (see the `access-pg` skill for the `bun -e` + `pg` pattern), then regenerate:
```bash
# Fix the dev DB to match the new schema (simplest SQL wins)
set -a && source .env && set +a && bun -e '
import pg from "pg";
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
await client.query("ALTER TABLE user_signup_logs DROP COLUMN signup_attempt_id");
await client.end();
'
# Regenerate so the migration reflects only the final shape
bun run db:generate
```
After a migration has reached production or the target default branch, treat it as immutable: add a follow-up migration instead of rewriting it.
## Rebase conflicts
When a rebase conflicts in migration files, keep the upstream/default-branch migrations and remove all migrations introduced by the current feature branch. Complete the rebase, then regenerate this branch's migration from the rebased schema. This avoids merging two independent snapshots or hand-splicing journal entries.
## Step 1: Generate Migrations
```bash
+2 -2
View File
@@ -1,6 +1,6 @@
---
name: debug-package
description: 'LobeHub debug package and log namespace guide. Use when adding debug() logging, choosing lobe-* namespaces, troubleshooting DEBUG output, localStorage.debug, or log format specifiers.'
description: "Guide for the `debug` npm package and LobeHub log namespaces (lobe-server:*, lobe-desktop:*, lobe-client:*, lobe-*-router:*). Use whenever adding a `debug(...)` logger, picking a namespace for new server/desktop/client/router code, troubleshooting why DEBUG=lobe-* logs don't show up, or when the user asks to 'add logging', 'add a logger', 'instrument this', 'trace this call', 'why isn't my log printing', or mentions `debug(`, `DEBUG=`, `localStorage.debug`, or log format specifiers like %O / %o / %s / %d in a LobeHub codebase."
user-invocable: false
---
@@ -57,7 +57,7 @@ process.env.DEBUG = 'lobe-*';
## Example
```typescript
// apps/server/src/routers/edge/market/index.ts
// src/server/routers/edge/market/index.ts
import debug from 'debug';
const log = debug('lobe-edge-router:market');
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: docs-changelog
description: 'Write website changelog pages under docs/changelog/*.mdx. Use for EN/ZH product update posts, changelog posts, update-log copy, or docs changelog edits; not GitHub Release notes.'
description: "Writing guide for website changelog pages under `docs/changelog/*.mdx` (NOT GitHub Release notes — those live in the `version-release` skill). Use when creating or editing a product update post in EN/ZH. Triggers on `docs/changelog/*.mdx`, 'changelog post', 'product update post', 'add a changelog', '更新日志', 'changelog 文案'."
---
# Docs Changelog Writing Guide
+4 -184
View File
@@ -1,29 +1,21 @@
---
name: drizzle
description: 'LobeHub Drizzle ORM schema and query style. Use for pgTable schemas, indexes, joins, inferred types, db.select/db.query, schema fields, foreign keys, junction tables, or postgres query patterns.'
description: "Drizzle ORM schema authoring and query style for LobeHub (postgres, strict mode). Use when editing anything under `src/database/schemas/`, defining `pgTable` columns/indexes/junction tables, spreading `...timestamps`, generating `createInsertSchema`/`$inferSelect`/`$inferInsert` types, writing `db.select().from(...).leftJoin(...)` queries, or deciding when to split a relational `with:` into two queries. Triggers on `pgTable`, `db.select`, `db.query`, `eq()`/`and()`/`inArray()`, `uniqueIndex`, `primaryKey`, `references({ onDelete })`, 'add a column', 'new table', 'foreign key', 'junction table', 'schema field'. For migration files specifically, see the `db-migrations` skill."
user-invocable: false
---
# Drizzle ORM Schema Style Guide
> **Adding a Model or Repository?** Ship a sibling test in the same PR — every new
> file under `packages/database/src/models/**` or `src/repositories/**` needs a
> matching `__tests__/<name>.test.ts`. See the **testing** skill
> (`.agents/skills/testing/references/db-model-test.md`) for the `getTestDB()`
> integration pattern, user-isolation tests, the BM25 `describe.skipIf(!isServerDB)`
> guard, and schema gotchas. CI's coverage patch gate won't reliably catch a brand-new
> untested file, so this is on you.
## Configuration
- Config: `drizzle.config.ts`
- Schemas: `packages/database/src/schemas/`
- Migrations: `packages/database/migrations/`
- Schemas: `src/database/schemas/`
- Migrations: `src/database/migrations/`
- Dialect: `postgresql` with `strict: true`
## Helper Functions
Location: `packages/database/src/schemas/_helpers.ts`
Location: `src/database/schemas/_helpers.ts`
- `timestamptz(name)`: Timestamp with timezone
- `createdAt()`, `updatedAt()`, `accessedAt()`: Standard timestamp columns
@@ -33,42 +25,16 @@ Location: `packages/database/src/schemas/_helpers.ts`
- **Tables**: Plural snake_case (`users`, `session_groups`)
- **Columns**: snake_case (`user_id`, `created_at`)
- **New tables**: Check nearby existing tables before naming a new one. Preserve
the established noun family and suffix. For example, if the user-scoped table
is `user_xxx_logs`, the workspace-scoped counterpart should be
`workspace_xxx_logs`, not `workspace_xxx_records` or another new synonym.
```typescript
// ✅ Good: follows the existing user/workspace table family.
export const userSignupLogs = pgTable('user_signup_logs', { ... });
export const workspaceSignupLogs = pgTable('workspace_signup_logs', { ... });
// ❌ Bad: introduces a new suffix for the same concept.
export const workspaceSignupRecords = pgTable('workspace_signup_records', { ... });
```
## Column Definitions
### Primary Keys
Do not use auto-incrementing primary keys (`serial`, `bigserial`, generated
identity columns). They create sequence-state problems during cross-database
migrations, restores, and data copy jobs. Prefer text IDs from application
generators (`idGenerator`, `createNanoId`) or `uuid` for internal tables.
Keep `$defaultFn(...)` when a table normally owns ID generation. Callers can
still pass an explicit `id`; the default only runs when the insert omits it. Do
not remove the default just because one flow needs to supply a request-scoped ID.
```typescript
// ✅ Good: app-generated text ID; explicit inserts can still override it.
id: text('id')
.primaryKey()
.$defaultFn(() => idGenerator('agents'))
.notNull(),
// ❌ Bad: sequence state is fragile across DB migrations and restores.
id: serial('id').primaryKey(),
```
ID prefixes make entity types distinguishable. For internal tables, use `uuid`.
@@ -87,80 +53,6 @@ userId: text('user_id')
...timestamps, // Spread from _helpers.ts
```
### Optional and Undefined Values
Do not introduce artificial sentinel strings for missing values, such as
`unknown`, unless the domain already has that explicit state and existing code
uses it consistently. Prefer nullable columns, optional TypeScript fields, or a
separate concrete status enum when the value is genuinely absent.
```typescript
// ✅ Good: absent until the final stage writes a real decision.
export type UserSignupLogFinalDecision = 'allow' | 'block' | 'error';
finalDecision: varchar('final_decision', { length: 32 }).$type<UserSignupLogFinalDecision>(),
// ❌ Bad: invents a new state that callers now need to handle everywhere.
export type UserSignupLogFinalDecision = 'allow' | 'block' | 'error' | 'unknown';
finalDecision: varchar('final_decision', { length: 32 })
.$type<UserSignupLogFinalDecision>()
.notNull()
.default('unknown');
```
### Field Descriptions
For columns whose meaning is not obvious from the name alone, add JSDoc on the
schema field. Include a concrete example when it clarifies the stored value or
the lifecycle moment that writes it. This is especially important for external
IDs, lifecycle statuses, denormalized snapshots, JSONB signals, and fields whose
name could mean either a request ID or a persisted row ID.
```typescript
// ✅ Good: explain the table's business object first, then only document
// non-obvious lifecycle or risk-control fields.
/**
* User signup logs - one row per signup flow, collecting stage-level
* risk-control decisions before and after the auth provider creates a user.
*/
export const userSignupLogs = pgTable('user_signup_logs', {
/** Final signup outcome reason, for example user_created, llm_block, or guard_error */
finalReason: text('final_reason'),
/** Aggregated risk level derived from stage decisions, for example block -> high */
riskLevel: varchar('risk_level', { length: 16 }).$type<UserSignupLogRiskLevel>(),
/** Ordered stage-level decisions and metadata grouped by signup review stage */
stageResults: jsonb('stage_results').$type<UserSignupLogStageResults>(),
});
// ❌ Bad: comments restate obvious column names without adding domain meaning.
/** User email */
email: text('email'),
```
### JSONB Types
Avoid `Record<string, unknown>` or similarly loose JSONB types for schema
columns. Define a concrete interface that describes the expected JSON shape, even
when most properties are optional. This keeps callers, migrations, and review
queries aligned on the same data contract.
```typescript
interface UserSignupLogMetadata {
payloadPath?: string;
requestPath?: string;
}
metadata: jsonb('metadata').$type<UserSignupLogMetadata>(),
```
```typescript
// ❌ Bad: hides the contract and makes downstream access untyped.
metadata: jsonb('metadata').$type<Record<string, unknown>>(),
```
### Indexes
```typescript
@@ -282,78 +174,6 @@ const rows = await this.db
.groupBy(agentEvalDatasets.id);
```
### Raw SQL and Advanced Queries
Prefer Drizzle builders whenever the query reads clearly with `select`,
`insert().select()`, `update().from()`, joins, CTEs, and `groupBy` — this keeps
table/column references tied to schema, so changes surface as TypeScript errors.
Within a builder, expression-level `sql<T>` is fine for features lacking a helper
(JSON path, casts, aggregates, `CASE`, `NOW()`). Row locks are clauses, not
expressions — use `.for('update')`, never raw `FOR UPDATE`.
Use `COALESCE` only when null-handling is part of required DB semantics (nullable
JSONB append/merge, "keep first non-null"). Don't scatter
`COALESCE(excluded.col, current.col)` across ordinary upsert scalars just to avoid
an update object — build `set` from defined values only, and hide any remaining
SQL behind named helpers (`appendJsonbArray`, `mergeJsonbObject`, `keepFirstValue`)
so the method reads as business intent, not SQL plumbing.
```typescript
// ✅ Scalars included only when present; SQL hidden behind a named helper.
const updateValues = compactUndefined({
email: record.email ?? undefined,
ip: record.ip ?? undefined,
});
await db.insert(userSignupLogs).values(values).onConflictDoUpdate({
set: { ...updateValues, stageResults: appendStageResult(stage, result), updatedAt: now },
target: userSignupLogs.id,
});
// ❌ Every scalar becomes SQL plumbing.
set: {
email: sql`COALESCE(excluded.email, ${userSignupLogs.email})`,
ip: sql`COALESCE(excluded.ip, ${userSignupLogs.ip})`,
}
```
When refactoring raw SQL:
- Preserve query shape on latency-sensitive paths. If raw SQL is one roundtrip,
don't split it into multiple depth-based queries just to drop `execute`.
- Use `$with(...)` + `insert().select()` / `update().from()` for multi-step
single-roundtrip writes Drizzle can express.
- Don't rely on `execute<MyRow>(sql...)` for safety — it types rows but doesn't keep
selected columns in sync with schema changes.
- If only a PostgreSQL feature Drizzle can't express works, keep the raw SQL and
tighten it: schema refs in interpolations, explicit user scope, a narrow row
interface, and regression tests.
Recursive CTEs are the canonical "keep raw" case — there's no clean `WITH RECURSIVE`
builder, and a rewrite would add depth-based roundtrips:
```typescript
interface TaskTreeRow {
id: string;
parent_task_id: string | null;
}
// execute<T> acceptable: no clean WITH RECURSIVE builder. Keep schema refs in the
// interpolations and scope every leg to the user.
const { rows } = await db.execute<TaskTreeRow>(sql`
WITH RECURSIVE task_tree AS (
SELECT ${tasks.id}, ${tasks.parentTaskId}
FROM ${tasks}
WHERE ${tasks.id} = ${rootTaskId} AND ${tasks.createdByUserId} = ${userId}
UNION ALL
SELECT ${tasks.id}, ${tasks.parentTaskId}
FROM ${tasks}
JOIN task_tree ON ${tasks.parentTaskId} = task_tree.id
WHERE ${tasks.createdByUserId} = ${userId}
)
SELECT * FROM task_tree
`);
```
### One-to-Many (Separate Queries)
When you need a parent record with its children, use two queries instead of relational `with:`:
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: heterogeneous-agent
description: 'Implement or debug LobeHub heterogeneous agents. Use for Claude Code/Codex adapters, external CLI agents, event mapping, IPC, persistence, tool-call chains, sessions, traces, or adapter bugs.'
description: Guide for implementing and debugging LobeHub heterogeneous agent integrations such as Claude Code, Codex, and future external CLI agents. Use when working on adapter event mapping, Electron IPC transport, renderer persistence, tool-call chaining, subagent threads, resume/session handling, or regressions like mixed multi-tool messages, broken step boundaries, stuck tool loading, and orphan tool messages. Triggers on 'heterogeneous agent', 'hetero agent', '异构 agent', 'claude code adapter', 'codex adapter', 'external agent CLI', '孤立 tool 消息', 'raw Codex trace', or adapter/executor bugs.
---
# Heterogeneous Agent Development
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: hotkey
description: 'Add or edit LobeHub keyboard shortcuts. Use for HotkeyEnum, HOTKEYS_REGISTRATION, combineKeys, useHotkeyById, tooltip hotkeys, shortcut scope, conflicts, or Cmd/Ctrl key combos.'
description: "Adding or editing keyboard shortcuts in LobeHub. Use when registering a new hotkey, changing a key combo, scoping a shortcut to chat vs global, or wiring a hotkey hook + tooltip. Covers the 5-step flow: add to `HotkeyEnum` in `src/types/hotkey.ts`, register in `HOTKEYS_REGISTRATION` (`src/const/hotkeys.ts`) with `combineKeys([Key.Mod, …])`, add i18n in `src/locales/default/hotkey.ts`, expose via `useHotkeyById` in `src/hooks/useHotkeys/`, and render `<Tooltip hotkey={…}>`. Triggers on `HotkeyEnum`, `HOTKEYS_REGISTRATION`, `useHotkeyById`, `combineKeys`, `Key.Mod`/`Key.Shift`, 'add a hotkey', 'add a shortcut', '加快捷键', '快捷键', 'Cmd+K', 'keyboard shortcut', 'hotkey scope', 'hotkey conflict'."
user-invocable: false
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: i18n
description: 'LobeHub i18n with react-i18next. Use for user-facing strings, locale keys, namespaces, useTranslation, t(), interpolation, zh-CN/en-US previews, hardcoded UI copy, or pnpm i18n.'
description: "LobeHub internationalization with react-i18next. Use when adding any user-facing string in `.tsx`/`.ts` files, creating or renaming a key under `src/locales/default/{namespace}.ts`, deciding the `{feature}.{context}.{action}` flat-key pattern, wiring a new namespace into `src/locales/default/index.ts`, or translating zh-CN/en-US JSON for dev preview. Triggers on `useTranslation`, `t('foo.bar')`, `i18next.t`, `{{variable}}` interpolation, hardcoded UI strings (zh or en) that should be extracted, 'add i18n', '加 i18n key', '翻译', 'locale key', 'namespace', 'pnpm i18n'."
user-invocable: false
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: linear
description: 'Linear issue management. Use for LOBE-xxx issues, Linear links, PRs referencing Linear, retrieving issues, updating status, completion comments, or sub-issue trees.'
description: "Linear issue management. Use when the user mentions LOBE-xxx issue IDs (e.g. LOBE-4540), says 'linear' / 'linear issue' / 'link linear', or when creating PRs that reference Linear issues. Covers retrieving issues, updating status, adding completion comments, and creating sub-issue trees."
user-invocable: false
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: microcopy
description: 'UI copy and microcopy guidelines. Use for user-facing copy, buttons, errors, empty states, onboarding, i18n wording, translation, or copy improvements in Chinese or English.'
description: UI copy and microcopy guidelines. Use when writing UI text, buttons, error messages, empty states, onboarding, or any user-facing copy. Triggers on i18n translation, UI text writing, or copy improvement tasks. Supports both Chinese and English.
user-invocable: false
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: modal
description: 'LobeHub imperative modal conventions. Use when creating or migrating modals, dialogs, popups, confirm flows, ModalHost wiring, createModal, confirmModal, useModalContext, or base-ui modal APIs.'
description: "LobeHub imperative-modal conventions. Use whenever creating, editing, opening, or migrating a modal/dialog/popup — prefer `createModal` / `confirmModal` / `useModalContext` from `@lobehub/ui/base-ui` (headless) over the legacy root `@lobehub/ui` `createModal` (antd Modal props) and over any declarative `open` state + `<Modal />` pattern. Covers required `ModalHost` mounting, the `Content` + `index.tsx` file layout, `content` vs `children` slot, i18n inside `createModal()` (`import { t } from 'i18next'`), and migration notes. Triggers on `createModal`, `confirmModal`, `useModalContext`, `ModalHost`, `antd Modal`, `<Modal open>`, 'open a modal', 'popup', 'dialog', 'confirm dialog', '弹框', '弹窗', '确认框', 'migrate to base-ui'."
user-invocable: false
---
+23 -24
View File
@@ -1,6 +1,6 @@
---
name: project-overview
description: 'LobeHub open-source monorepo architecture map. Use when locating code layers, understanding apps/packages/src layout, business stubs, project structure, or onboarding to the repository.'
description: "LobeHub open-source monorepo architecture map — flat `apps/` + `packages/@lobechat/*` + `src/` layout, per-layer location table, and `src/business/` stubs that the cloud repo overrides. Use when exploring an unfamiliar part of the codebase, locating where a layer lives (store / service / router / schema / etc.), or onboarding to the monorepo. Triggers on 'where does X live', 'project structure', 'monorepo layout', `src/business/` stub, 'architecture overview', '项目结构', '架构总览'."
user-invocable: false
---
@@ -56,8 +56,7 @@ git submodules.
├── apps/
│ ├── cli/ # LobeHub CLI
│ ├── desktop/ # Electron desktop app
── device-gateway/ # Device gateway service
│ └── server/ # Next.js-backed server: featureFlags, globalConfig, modules, routers, services, utils, workflows (`@/server/*` alias)
── device-gateway/ # Device gateway service
├── docs/ # changelog, development, self-hosting, usage
├── locales/ # en-US, zh-CN, ...
├── packages/ # ~80 @lobechat/* workspace packages — `ls` for the full set. Key ones:
@@ -86,32 +85,32 @@ git submodules.
├── business/ # Open-source stubs (client/server) — cloud repo provides real impls
├── features/ # Domain business components
├── store/ # ~30 zustand stores — `ls` for the full set
├── server/ # standalone-Hono server pieces only: agent-hono, workflows-hono (main backend lives in `apps/server`)
├── server/ # featureFlags, globalConfig, modules, routers, services, workflows, agent-hono
└── ... # components, hooks, layout, libs, locales, services, types, utils
```
## Architecture Map
| Layer | Location |
| ---------------- | -------------------------------------------------------- |
| UI Components | `src/components`, `src/features` |
| SPA Pages | `src/routes/` |
| React Router | `src/spa/router/` |
| Global Providers | `src/layout` |
| Zustand Stores | `src/store` |
| Client Services | `src/services/` |
| REST API | `src/app/(backend)/webapi` |
| tRPC Routers | `apps/server/src/routers/{async\|lambda\|mobile\|tools}` |
| Server Services | `apps/server/src/services` (can access DB) |
| Server Modules | `apps/server/src/modules` (no DB access) |
| Feature Flags | `apps/server/src/featureFlags` |
| Global Config | `apps/server/src/globalConfig` |
| DB Schema | `packages/database/src/schemas` |
| DB Model | `packages/database/src/models` |
| DB Repository | `packages/database/src/repositories` |
| Third-party | `src/libs` (analytics, oidc, etc.) |
| Builtin Tools | `packages/builtin-tool-*`, `packages/builtin-tools` |
| Open-source stub | `src/business/*`, `packages/business/*` (this repo) |
| Layer | Location |
| ---------------- | --------------------------------------------------- |
| UI Components | `src/components`, `src/features` |
| SPA Pages | `src/routes/` |
| React Router | `src/spa/router/` |
| Global Providers | `src/layout` |
| Zustand Stores | `src/store` |
| Client Services | `src/services/` |
| REST API | `src/app/(backend)/webapi` |
| tRPC Routers | `src/server/routers/{async\|lambda\|mobile\|tools}` |
| Server Services | `src/server/services` (can access DB) |
| Server Modules | `src/server/modules` (no DB access) |
| Feature Flags | `src/server/featureFlags` |
| Global Config | `src/server/globalConfig` |
| DB Schema | `packages/database/src/schemas` |
| DB Model | `packages/database/src/models` |
| DB Repository | `packages/database/src/repositories` |
| Third-party | `src/libs` (analytics, oidc, etc.) |
| Builtin Tools | `packages/builtin-tool-*`, `packages/builtin-tools` |
| Open-source stub | `src/business/*`, `packages/business/*` (this repo) |
## Data Flow
+1 -5
View File
@@ -1,6 +1,6 @@
---
name: react
description: 'LobeHub React component conventions. Use when editing TSX UI, choosing base-ui vs @lobehub/ui vs antd, styling with antd-style, routing, desktop variants, layouts, or component state.'
description: "LobeHub React component conventions — base-ui (`@lobehub/ui/base-ui`) first for headless primitives (Select, Modal, DropdownMenu, ContextMenu, Popover, ScrollArea, Switch, Toast, FloatingSheet), then `@lobehub/ui` root, antd as last resort; styling via `antd-style` `createStaticStyles` + `cssVar.*` (zero-runtime preferred over `createStyles` + `token`); routing via `react-router-dom` (not `next/link`). Use when writing or editing any `.tsx` under `src/**`. Triggers on `createStaticStyles`, `createStyles`, `cssVar`, `antd-style`, `Flexbox`, `Center`, `Select`, `Modal`, `Drawer`, `Button`, `Tooltip`, `DropdownMenu`, `ContextMenu`, `Popover`, `Switch`, `ScrollArea`, `Toast`, `FloatingSheet`, `Link`, `useNavigate`, `react-router-dom`, `next/link`, `desktopRouter`, `componentMap.desktop`, `.desktop.tsx`, `base-ui`, `@lobehub/ui/base-ui`, 'new component', 'new page', 'edit layout', 'add styles', 'zustand selector', '@lobehub/ui', 'antd import'."
user-invocable: false
---
@@ -53,10 +53,6 @@ For Modal specifically, see the dedicated **modal** skill — use the imperative
| Layout | Center, DraggablePanel, Flexbox, Grid, Header, MaskShadow |
| Navigation | Burger, Menu, SideNav, Tabs |
## State
When a feature component manages more than 3 pieces of state (`useState`/`useReducer`/derived state), extract the logic into a custom hook (e.g. `useXxx`). Keep the component focused on rendering — the hook holds state and handlers, so logic can be unit-tested without rendering the component.
## Layout
Use `Flexbox` and `Center` from `@lobehub/ui`. See `references/layout-kit.md` for full props and examples.
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: response-compliance
description: 'OpenResponses API compliance testing. Use for Response API endpoint tests, compliance runs, schema debugging, response api test, or openresponses test tasks.'
description: OpenResponses API compliance testing. Use when testing the Response API endpoint, running compliance tests, or debugging Response API schema issues. Triggers on 'compliance', 'response api test', 'openresponses test'.
---
# OpenResponses Compliance Test
+1 -2
View File
@@ -1,6 +1,6 @@
---
name: review-checklist
description: 'LobeHub code review checklist. Use when reviewing a PR, diff, or branch for console leftovers, return await, secrets, i18n, desktop router drift, UI imports, migrations, or cloud impact.'
description: "Common recurring mistakes in LobeHub code review — `console.*` leftovers, missing `return await`, hardcoded secrets, hardcoded i18n strings, desktop router pair drift, antd vs `@lobehub/ui`, non-idempotent migrations, cloud impact red flags. Use as a quick checklist when reviewing a PR, diff, or branch change. Triggers on 'code review', 'review the diff', 'review this PR', 'review changes', 'PR review checklist', '审一下', '审 PR'."
user-invocable: false
---
@@ -22,7 +22,6 @@ user-invocable: false
- Bug fixes must include tests covering the fixed scenario
- New logic (services, store actions, utilities) should have test coverage
- **New database Model/Repository** (`packages/database/src/models/**`, `src/repositories/**`) must ship a sibling `__tests__/<name>.test.ts` — incl. user-isolation tests; BM25 search guarded by `describe.skipIf(!isServerDB)` (see `/testing``db-model-test.md`)
- Existing tests still cover the changed behavior?
- Prefer `vi.spyOn` over `vi.mock` (see `/testing` skill)
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: skills-audit
description: 'Audit .agents/skills SKILL.md files. Use for recurring checks of duplicate, overlapping, stale, inconsistent, or broken skills and merge/delete candidates.'
description: Weekly audit of `.agents/skills/*/SKILL.md` — surfaces duplicate / overlapping / stale skills, inconsistent descriptions, broken cross-references, and merge/delete candidates. Run as a recurring health-check, not during normal feature work.
disable-model-invocation: true
argument-hint: '[--verbose | --apply]'
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: spa-routes
description: 'LobeHub SPA route architecture. Use when editing src/routes, src/features delegation, desktop/mobile/popup router configs, .desktop variants, route segments, redirects, or new pages.'
description: "SPA roots-vs-features split for LobeHub — thin route segments under `src/routes/` delegate to domain components under `src/features/`. Use when editing `src/routes/` segments, `src/spa/router/desktopRouter.config.tsx` or `desktopRouter.config.desktop.tsx` (MUST update both together — `desktopRouter.sync.test.tsx` enforces this), `mobileRouter.config.tsx`, `popupRouter.config.tsx`, any colocated `<name>.desktop.{ts,tsx}` variant (e.g. settings `componentMap.ts` × `componentMap.desktop.ts`, page-level `index.tsx` × `index.desktop.tsx`), or moving UI/logic between `routes/` and `features/`. Triggers on `desktopRouter.config`, `mobileRouter.config`, `popupRouter.config`, `componentMap.desktop`, `index.desktop.tsx`, `.desktop.tsx` variant, `src/routes/**`, `src/features/**`, 'add a route', 'new page', 'route segment', '路由'."
user-invocable: false
---
@@ -1,6 +1,6 @@
---
name: store-data-structures
description: 'LobeHub Zustand store data-shape patterns. Use when designing store state, list/detail splits, normalized maps, reducers, messagesMap, topicsMap, or choosing shared type sources.'
description: "Zustand store data-shape patterns for LobeHub — List vs Detail split, Map + Reducer, type definitions sourced from `@lobechat/types` (not `@lobechat/database`). Use when designing store state, choosing between Array (list) and `Record<string, Detail>` (detail map), or implementing a list/detail page pair. Triggers on `messagesMap`, `topicsMap`, `Record<string, Detail>`, 'list vs detail', 'store data shape', 'normalize state', 'state structure'."
user-invocable: false
---
+3 -9
View File
@@ -1,6 +1,6 @@
---
name: testing
description: 'Vitest testing guide. Use when writing or updating tests, fixing failing tests, improving coverage, debugging test issues, or setting up mocks.'
description: Testing guide using Vitest. Use when writing tests (.test.ts, .test.tsx), fixing failing tests, improving test coverage, or debugging test issues. Triggers on test creation, test debugging, mock setup, or test-related questions.
user-invocable: false
---
@@ -14,21 +14,15 @@ user-invocable: false
# Run specific test file
bunx vitest run --silent='passed-only' '[file-path]'
# Database package (client-db, PGlite — default, skips BM25/pg_search)
# Database package (client)
cd packages/database && bunx vitest run --silent='passed-only' '[file]'
# Database package (server-db, Postgres — BM25/pgvector parity, what CI measures coverage in)
# Database package (server)
cd packages/database && TEST_SERVER_DB=1 bunx vitest run --silent='passed-only' '[file]'
```
**Never run** `bun run test` - it runs all 3000+ tests (\~10 minutes).
> **Database models/repositories:** every new file under `packages/database/src/models/**`
> or `src/repositories/**` ships with a sibling `__tests__/<name>.test.ts` in the same PR.
> Use the real DB via `getTestDB()` (integration style), guard BM25/full-text-search blocks
> with `describe.skipIf(!isServerDB)`, and always test user-isolation. See
> `references/db-model-test.md` for setup, schema gotchas, and the client-vs-server-db split.
## Test Categories
| Category | Location | Config |
@@ -1,74 +1,95 @@
# Database Model Testing Guide
Test the `packages/database` Model and Repository layers.
Test `packages/database` Model layer.
> **Rule: every new Model or Repository ships with a sibling test in the same PR.**
> A new file under `src/models/**` or `src/repositories/**` must have a matching
> `__tests__/<name>.test.ts`. Coverage runs in server-db mode in CI and the patch
> gate will not always catch a brand-new untested file (a small new file barely
> moves the project total) — so this is a convention, not something CI guarantees.
> Start from the template: `packages/database/src/models/__tests__/_test_template.ts`.
## Two test environments: client-db vs server-db
`getTestDB()` (`src/core/getTestDB.ts`) returns different engines based on the
`TEST_SERVER_DB` env var:
| Mode | Engine | When | Notes |
| ----------------------- | ----------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **client-db** (default) | PGlite (in-memory) | `bunx vitest run` | Migration runner **skips any SQL containing `pg_search` / `bm25`** — the ParadeDB BM25 `@@@` operator does not exist here. |
| **server-db** | node-postgres → `DATABASE_TEST_URL` | `TEST_SERVER_DB=1` | CI uses the `paradedb/paradedb` image (has `pg_search`). **Coverage is measured in this mode** (`test:coverage``vitest.config.server.mts`, uploaded to Codecov). |
## Dual Environment Verification (Required)
```bash
# 1. Client environment (fast, default — what most local runs use)
cd packages/database && bunx vitest run --silent='passed-only' '[file]'
# 1. Client environment (fast)
cd packages/database && TEST_SERVER_DB=0 bunx vitest run --silent='passed-only' '[file]'
# 2. Server environment (BM25 / pg_search / pgvector parity, needs DATABASE_TEST_URL)
# 2. Server environment (compatibility)
cd packages/database && TEST_SERVER_DB=1 bunx vitest run --silent='passed-only' '[file]'
```
Implication: client-db coverage **under-counts** any code that needs BM25 (e.g.
`repositories/search/index.ts` reads near-0% locally but is fully covered in CI).
Don't chase those lines locally — confirm via CI/Codecov.
## User Permission Check - Security First 🔒
## BM25 / full-text search → `describe.skipIf(!isServerDB)`
Any method using the BM25 `@@@` operator or `sanitizeBm25` (keyword search:
`queryByKeyword`, `searchAgents`, userMemory lexical search, …) **throws under
PGlite** (often swallowed by a `catch` that returns `[]`, so the test silently
fails with empty results). Guard those blocks so they only run in server-db:
**Critical security requirement**: All user data operations must include permission checks.
```typescript
// BM25 search requires the pg_search extension (ParadeDB), not available in PGlite
const isServerDB = process.env.TEST_SERVER_DB === '1';
describe.skipIf(!isServerDB)('queryByKeyword', () => {
/* ... */
// ❌ DANGEROUS: Missing permission check
update = async (id: string, data: Partial<MyModel>) => {
return this.db
.update(myTable)
.set(data)
.where(eq(myTable.id, id)) // Only checks ID
.returning();
};
// ✅ SECURE: Permission check included
update = async (id: string, data: Partial<MyModel>) => {
return this.db
.update(myTable)
.set(data)
.where(
and(
eq(myTable.id, id),
eq(myTable.userId, this.userId), // ✅ Permission check
),
)
.returning();
};
```
## Test File Structure
```typescript
// @vitest-environment node
describe('MyModel', () => {
describe('create', () => {
/* ... */
});
describe('queryAll', () => {
/* ... */
});
describe('update', () => {
it('should update own records');
it('should NOT update other users records'); // 🔒 Security
});
describe('delete', () => {
it('should delete own records');
it('should NOT delete other users records'); // 🔒 Security
});
describe('user isolation', () => {
it('should enforce user data isolation'); // 🔒 Core security
});
});
```
Convention already used in `session.test.ts`, `topic.query.test.ts`,
`message.query.test.ts`, `home/index.test.ts`, `repositories/search/index.test.ts`.
## Setup boilerplate
Top-of-file pattern (see `_test_template.ts` for the full version). Use real DB
integration via `getTestDB()`**not a mocked `vi.fn()` db**; the integration
style exercises real SQL and gives far deeper coverage.
## Security Test Example
```typescript
import { eq } from 'drizzle-orm';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
it('should not update records of other users', async () => {
const [otherUserRecord] = await serverDB
.insert(myTable)
.values({ userId: 'other-user', data: 'original' })
.returning();
import { getTestDB } from '../../core/getTestDB';
import { users } from '../../schemas';
import type { LobeChatDatabase } from '../../type';
import { MyModel } from '../myModel';
const result = await myModel.update(otherUserRecord.id, { data: 'hacked' });
const serverDB: LobeChatDatabase = await getTestDB(); // top-level await is fine
expect(result).toBeUndefined();
const unchanged = await serverDB.query.myTable.findFirst({
where: eq(myTable.id, otherUserRecord.id),
});
expect(unchanged?.data).toBe('original');
});
```
const userId = 'my-model-test-user';
## Data Management
```typescript
const userId = 'test-user';
const otherUserId = 'other-user';
const myModel = new MyModel(serverDB, userId);
beforeEach(async () => {
await serverDB.delete(users);
@@ -76,99 +97,40 @@ beforeEach(async () => {
});
afterEach(async () => {
await serverDB.delete(users); // cascades to user-scoped rows
await serverDB.delete(users);
});
```
Some tests need the Node environment (pgvector, server-only deps) — add
`// @vitest-environment node` as the first line when required.
## User permission check — security first 🔒
**Every user-data operation must be ownership-scoped.** Always add a test proving
another user cannot read/update/delete the row.
## Foreign Key Handling
```typescript
// ✅ SECURE: ownership in the WHERE clause
update = async (id: string, data: Partial<MyModel>) =>
this.db
.update(myTable)
.set(data)
.where(and(eq(myTable.id, id), eq(myTable.userId, this.userId)))
.returning();
```
```typescript
it('should NOT update another user's record', async () => {
const otherModel = new MyModel(serverDB, otherUserId);
const [row] = await otherModel.create({ data: 'original' });
await myModel.update(row.id, { data: 'hacked' });
const unchanged = await serverDB.query.myTable.findFirst({
where: eq(myTable.id, row.id),
});
expect(unchanged?.data).toBe('original');
});
```
## What to cover
Aim each model/repository as close to 100% as practical (excluding BM25):
- Every public method
- Both branches of conditionals; empty-list / `if (!x) return []` early returns
- Error fallbacks (e.g. decrypt/JSON-parse failure → `null`)
- Filters, pagination, ordering branches
- Ownership / user isolation, and workspace scoping if the model takes a `workspaceId`
## Schema gotchas (real traps that fail inserts or types)
- **`workspaces`** requires `{ id, name, slug, primaryOwnerId }` and has **no
`userId` column** — `insert(workspaces).values({ id, name, slug, primaryOwnerId })`.
- **uuid columns**: a "not found" test must pass a _valid_ UUID
(`'00000000-0000-0000-0000-000000000000'`); a random string raises a `22P02`
DB error instead of returning `undefined`/`null`.
- **Enum / `$type` columns** are type-checked: e.g. `files.source` is a
`FileSource` enum (`image_generation` | `page-editor` | `video_generation`),
not free text — passing `'upload'` is a type error.
- Read the table's schema in `src/schemas/` for `notNull` columns **without
defaults**; you must supply those on insert.
## Foreign key handling
```typescript
// ❌ Wrong: invalid foreign key
// ❌ Wrong: Invalid foreign key
const testData = { asyncTaskId: 'invalid-uuid', fileId: 'non-existent' };
// ✅ Use null
// ✅ Correct: Use null
const testData = { asyncTaskId: null, fileId: null };
// ✅ … or create the referenced row first
const [asyncTask] = await serverDB.insert(asyncTasks).values({ status: 'pending' }).returning();
testData.asyncTaskId = asyncTask.id;
// ✅ Or: Create referenced record first
beforeEach(async () => {
const [asyncTask] = await serverDB
.insert(asyncTasks)
.values({ id: 'valid-id', status: 'pending' })
.returning();
testData.asyncTaskId = asyncTask.id;
});
```
## Predictable sorting
## Predictable Sorting
```typescript
// ✅ Use explicit timestamps — never rely on insert order
// ✅ Use explicit timestamps
const oldDate = new Date('2024-01-01T10:00:00Z');
const newDate = new Date('2024-01-02T10:00:00Z');
await serverDB.insert(table).values([
{ ...data1, createdAt: new Date('2024-01-01T10:00:00Z') },
{ ...data2, createdAt: new Date('2024-01-02T10:00:00Z') },
{ ...data1, createdAt: oldDate },
{ ...data2, createdAt: newDate },
]);
// ❌ Don't rely on insert order
await serverDB.insert(table).values([data1, data2]); // Unpredictable
```
## Checking coverage of one file
```bash
# Per-file coverage; read the "Uncovered Line #s" column to find gaps
cd packages/database
bunx vitest run --coverage --silent='passed-only' '[test-file]' 2>&1 | grep '[sourceFile].ts'
```
## Before finishing
1. Tests pass: `bunx vitest run --silent='passed-only' '[file]'`
2. Types pass: `bun run type-check` (vitest uses esbuild and does **not**
type-check — a green test run can still have type errors).
+4 -4
View File
@@ -1,6 +1,6 @@
---
name: trpc-router
description: 'TRPC router development guide. Use when creating or modifying apps/server/src/routers, adding procedures, or implementing server-side API endpoints.'
description: TRPC router development guide. Use when creating or modifying TRPC routers (src/server/routers/**), adding procedures, or working with server-side API endpoints. Triggers on TRPC router creation, procedure implementation, or API endpoint tasks.
user-invocable: false
---
@@ -8,9 +8,9 @@ user-invocable: false
## File Location
- Routers: `apps/server/src/routers/lambda/<domain>.ts`
- Helpers: `apps/server/src/routers/lambda/_helpers/`
- Schemas: `apps/server/src/routers/lambda/_schema/`
- Routers: `src/server/routers/lambda/<domain>.ts`
- Helpers: `src/server/routers/lambda/_helpers/`
- Schemas: `src/server/routers/lambda/_schema/`
## Router Structure
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: typescript
description: 'LobeHub TypeScript style and type-safety guide. Use when editing TS/TSX/MTS, fixing types, choosing interface vs type, avoiding any/object, import type, async flow, or ts-expect-error.'
description: "TypeScript code style and type-safety guide for LobeHub. Read before writing or editing any `.ts` / `.tsx` / `.mts` — covers `interface` vs `type`, `Record<PropertyKey, unknown>` over `any`/`object`, `as const satisfies`, `@ts-expect-error` over `@ts-ignore`, `import type` (`separate-type-imports`), `async`/`await` + `Promise.all`, `for…of` over indexed `for`, and the no-silent-`.catch(() => fallback)` rule. Also use when reviewing type quality, deciding module augmentation (`declare module`) over `namespace`, or designing extensible types (e.g. `PipelineContext.metadata`). Triggers on any TypeScript file edit, 'fix the type', 'why is this `any`', 'should this be interface or type', 'eslint type-import', 'ts-expect-error'."
user-invocable: false
---
+2 -2
View File
@@ -1,6 +1,6 @@
---
name: upstash-workflow
description: 'LobeHub Upstash Workflow and QStash guide. Use for async workflows, process/paginate/execute fan-out, serve handlers, context.run/call/sleep, or workflow triggers.'
description: "Upstash Workflow + QStash implementation guide for LobeHub — 3-layer architecture (process → paginate → execute), fan-out patterns. Use when creating an async workflow, implementing fan-out (paginateexecute), or wiring `serve()` + `context.run` / `context.call` steps. Triggers on `serve()`, `context.run`, `context.call`, `context.sleep`, `qstash`, 'async workflow', 'fan-out workflow', 'QStash workflow'."
user-invocable: false
---
@@ -186,4 +186,4 @@ QSTASH_URL=https://custom-qstash.com
- [Upstash Workflow Documentation](https://upstash.com/docs/workflow)
- [QStash Documentation](https://upstash.com/docs/qstash)
- [Example Workflows in Codebase](<../../src/app/(backend)/api/workflows/>)
- [Workflow Classes](../../apps/server/src/workflows/)
- [Workflow Classes](../../src/server/workflows/)
@@ -177,7 +177,7 @@ This allows cloud to override specific modules while using lobehub defaults.
Place workflow class in cloud:
```text
lobehub-cloud/apps/server/src/workflows/featureName/index.ts
lobehub-cloud/src/server/workflows/featureName/index.ts
```
### Shared Workflows
@@ -185,7 +185,7 @@ lobehub-cloud/apps/server/src/workflows/featureName/index.ts
Place workflow class in lobehub, re-export in cloud if needed:
```text
lobehub/apps/server/src/workflows/featureName/index.ts
lobehub/src/server/workflows/featureName/index.ts
```
---
@@ -294,8 +294,8 @@ export { POST } from 'lobehub/src/app/(backend)/api/workflows/feature/*/route';
**Step 4**: Move workflow class to lobehub
```bash
mv lobehub-cloud/apps/server/src/workflows/feature \
lobehub/apps/server/src/workflows/
mv lobehub-cloud/src/server/workflows/feature \
lobehub/src/server/workflows/
```
**Step 5**: Update cloud imports
@@ -305,7 +305,7 @@ mv lobehub-cloud/apps/server/src/workflows/feature \
import { Workflow } from '@/server/workflows/feature';
// To
import { Workflow } from 'lobehub/apps/server/src/workflows/feature';
import { Workflow } from 'lobehub/src/server/workflows/feature';
```
---
@@ -326,7 +326,7 @@ lobehub-cloud/
│ ├── process-users/route.ts
│ ├── paginate-users/route.ts
│ └── generate-user/route.ts
└── apps/server/src/workflows/welcomePlaceholder/
└── src/server/workflows/welcomePlaceholder/
└── index.ts
```
@@ -4,7 +4,7 @@ Full code templates for the 3-layer architecture. Read this when actually writin
## Table of Contents
1. [Workflow Class](#workflow-class) — `apps/server/src/workflows/{workflowName}/index.ts`
1. [Workflow Class](#workflow-class) — `src/server/workflows/{workflowName}/index.ts`
2. [Layer 1: Entry Point](#layer-1-entry-point-process-) — `process-*` route
3. [Layer 2: Pagination](#layer-2-pagination-paginate-) — `paginate-*` route
4. [Layer 3: Execution](#layer-3-execution-execute--generate-) — `execute-*` / `generate-*` route
@@ -13,7 +13,7 @@ Full code templates for the 3-layer architecture. Read this when actually writin
## Workflow Class
**Location:** `apps/server/src/workflows/{workflowName}/index.ts`
**Location:** `src/server/workflows/{workflowName}/index.ts`
```typescript
import { Client } from '@upstash/workflow';
+41 -5
View File
@@ -1,6 +1,6 @@
---
name: zustand
description: 'LobeHub Zustand store conventions. Use when editing src/store, store slices, public/internal actions, dispatch actions, flattenActions, optimistic updates, selectors, maps, or class action migration.'
description: "LobeHub Zustand store conventions: public/internal/dispatch action layers, optimistic update pattern, slice composition via `flattenActions`, and class-based action migration. Use whenever working under `src/store/**`, adding a `createXxxSlice`, writing `internal_*` or `internal_dispatch*` actions, designing `messagesMap`/`topicsMap` reducers, refactoring a `StateCreator` object slice into a `XxxActionImpl` class, or debugging stale store reads. Triggers on `useChatStore`/`useUserStore`/`useGlobalStore`, `createStore`, `flattenActions`, `StoreSetter`, `internal_dispatch`, 'add an action', 'zustand selector', 'store slice', 'class action', 'optimistic update'."
user-invocable: false
---
@@ -177,12 +177,29 @@ export const chatGroupAction: StateCreator<
### Slices That Don't Currently Need `set`
When a slice doesn't write local state (e.g. it delegates to another store or just runs hooks), drop `#set` and mark the constructor param as `_set` with `void _set` to keep the `(set, get, api)` shape:
When a slice doesn't write local state at the moment — e.g. it reads context
from `#get()` and forwards calls to another store, or just runs hooks — drop
the `#set` field. Otherwise ESLint's `no-unused-vars` flags the unused private
field.
Mark the constructor's `set` param as `_set` and `void _set` it to keep the
`(set, get, api)` shape aligned with `StateCreator`. This is **a snapshot of
the current need, not a permanent contract** — if a later change needs `set`,
restore the `#set` field and use it; do not invent a workaround to keep the
"unused" form.
```ts
type Setter = StoreSetter<ConversationStore>;
export const toolSlice = (set: Setter, get: () => ConversationStore, _api?: unknown) =>
new ToolActionImpl(set, get, _api);
export class ToolActionImpl {
readonly #get: () => ConversationStore;
// Mark unused params with `_` prefix and `void _x` so the constructor still
// matches StateCreator's `(set, get, api)` shape without triggering unused
// diagnostics.
constructor(_set: Setter, get: () => ConversationStore, _api?: unknown) {
void _set;
void _api;
@@ -195,8 +212,27 @@ export class ToolActionImpl {
hooks.onToolCallComplete?.(id, undefined);
};
}
export type ToolAction = Pick<ToolActionImpl, keyof ToolActionImpl>;
```
- Drop `#set` when unused; restore it when a later edit needs `set` — re-adding costs nothing.
- Don't add `setNamespace` for slices that don't write state.
- Don't keep both old slice objects and class actions active at the same time during migration.
Rules of thumb:
- If a slice doesn't currently call `set`, drop `#set` (use `_set` + `void _set`
in the constructor). When a later edit needs `set`, restore `#set` and use it.
- Don't add `setNamespace` for slices that don't write state. Add it when the
slice starts writing state.
- Never leave `#set` declared but unused "for future use" — lint will fail and
re-adding it later costs nothing.
### Do / Don't
- **Do**: keep constructor signature aligned with `StateCreator` params `(set, get, api)`.
- **Do**: use `#private` to avoid `set/get` being exposed.
- **Do**: use `flattenActions` instead of spreading class instances.
- **Do**: drop `#set` (and use `_set` + `void _set` in the constructor) for
delegate-only slices that never write state — keeps lint green without
breaking the `(set, get, api)` shape.
- **Don't**: keep both old slice objects and class actions active at the same time.
- **Don't**: keep an unused `#set` field "for future use" — it fails ESLint and
re-adding it later costs nothing.
+1 -2
View File
@@ -1,7 +1,6 @@
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
locales/
apps/desktop/resources/locales/
**/__snapshots__/
**/fixtures/
packages/database/migrations/
src/database/migrations/
-28
View File
@@ -223,29 +223,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# The LobeChat agents market index url
# AGENTS_INDEX_URL=https://chat-agents.lobehub.com
# #######################################
# ######### Cloud Sandbox Service #######
# #######################################
# Sandbox provider for built-in code execution, shell, file operations, and export.
# Supported values: market, onlyboxes
# SANDBOX_PROVIDER=market
# Required when SANDBOX_PROVIDER=onlyboxes. Base URL of the Onlyboxes console API, without /api/v1.
# ONLYBOXES_BASE_URL=https://onlyboxes.example.com
# Required when SANDBOX_PROVIDER=onlyboxes. Must match Onlyboxes CONSOLE_JIT_SIGNING_KEY.
# ONLYBOXES_JIT_SIGNING_KEY=onlyboxes-jit-signing-secret
# Optional JIT token issuer. Defaults to APP_URL.
# ONLYBOXES_JIT_ISSUER=https://lobehub.example.com
# Optional JIT token TTL in seconds.
# ONLYBOXES_JIT_TTL_SEC=1800
# Optional terminal session lease in seconds for the Onlyboxes provider.
# ONLYBOXES_LEASE_TTL_SEC=900
# #######################################
# ########### Plugin Service ############
# #######################################
@@ -399,11 +376,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# Postgres database URL
# DATABASE_URL=postgres://username:password@host:port/database
# Optional: server-side timeout (in milliseconds) for a single SQL statement.
# When set, Postgres aborts any statement/idle transaction exceeding it, so a stuck
# query can't block indefinitely. Leave unset to keep Postgres' default of no timeout.
# DATABASE_STATEMENT_TIMEOUT=300000
# use `openssl rand -base64 32` to generate a key for the encryption of the database
# we use this key to encrypt the user api key and proxy url
# KEY_VAULTS_SECRET=xxxxx/xxxxxxxxxxxxxx=
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
runs-on: ubuntu-latest
name: Test Packages
env:
PACKAGES: '@lobechat/file-loaders @lobechat/prompts @lobechat/model-runtime @lobechat/web-crawler @lobechat/electron-server-ipc @lobechat/utils @lobechat/python-interpreter @lobechat/context-engine @lobechat/agent-runtime @lobechat/conversation-flow @lobechat/ssrf-safe-fetch @lobechat/memory-user-memory @lobechat/types @lobechat/trpc @lobechat/app-config @lobechat/locales @lobechat/env @lobechat/builtin-tool-lobe-agent model-bank @lobechat/agent-gateway-client @lobechat/agent-manager-runtime @lobechat/device-gateway-client @lobechat/device-identity @lobechat/eval-dataset-parser @lobechat/eval-rubric @lobechat/fetch-sse @lobechat/heterogeneous-agents'
PACKAGES: '@lobechat/file-loaders @lobechat/prompts @lobechat/model-runtime @lobechat/web-crawler @lobechat/electron-server-ipc @lobechat/utils @lobechat/python-interpreter @lobechat/context-engine @lobechat/agent-runtime @lobechat/conversation-flow @lobechat/ssrf-safe-fetch @lobechat/memory-user-memory @lobechat/types @lobechat/builtin-tool-lobe-agent model-bank'
steps:
- name: Checkout
+5 -14
View File
@@ -19,7 +19,7 @@ lobehub/
├── apps/
│ ├── desktop/ # Electron desktop app
│ ├── cli/ # LobeHub CLI
│ └── server/ # Server service
│ └── device-gateway/ # Device gateway service
├── packages/ # Shared packages (@lobechat/*)
│ ├── database/ # Database schemas, models, repositories
│ ├── agent-runtime/ # Agent runtime
@@ -115,23 +115,14 @@ cd packages/database && bunx vitest run --silent='passed-only' '[file]'
```
- Prefer `vi.spyOn` over `vi.mock`
### Type Checking
```bash
bun run type-check
```
- Tests must pass type check: `bun run type-check`
- After 2 failed fix attempts, stop and ask for help
### i18n
- Add keys to a namespace file under `src/locales/default/` (e.g. `agent.ts`, `auth.ts`)
- Ship en-US and zh-CN by hand in the same PR: write the English source in `src/locales/default/*.ts` and mirror it to `locales/en-US/`; hand-translate `locales/zh-CN/`. Leave all other locales to CI.
- Don't run `pnpm i18n` manually by default — a daily CI workflow (`auto-i18n.yml`) runs it and opens an automated translation PR for any missing keys.
- Run `pnpm i18n` manually only when your branch needs the translated locales immediately, instead of waiting for the daily job (slow; requires `OPENAI_API_KEY`). Note it only fills keys missing from other locales — value-only edits never need it.
### Code Style
- When a single file grows beyond \~800 lines, consider splitting it into multiple files (extract sub-components, hooks, helpers, or types). Smaller, focused files are friendly to humans and agents.
- For dev preview: translate `locales/zh-CN/` and `locales/en-US/`
- `pnpm i18n` is slow; run it manually when locale keys need updating (e.g. before opening a PR).
### Code Review
-29
View File
@@ -2,35 +2,6 @@
# Changelog
## [Version 2.2.1](https://github.com/lobehub/lobe-chat/compare/v0.0.0-nightly.pr15228.13999...v2.2.1)
<sup>Released on **2026-05-29**</sup>
#### ✨ Features
- **device**: device registry TRPC (register / list / update / remove).
- **bot**: add iMessage Desktop setup and bridge.
- **desktop**: show zoom level HUD on Cmd+/- and Cmd+0.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's improved
- **device**: device registry TRPC (register / list / update / remove), closes [#15299](https://github.com/lobehub/lobe-chat/issues/15299) ([671b252](https://github.com/lobehub/lobe-chat/commit/671b252))
- **bot**: add iMessage Desktop setup and bridge, closes [#15228](https://github.com/lobehub/lobe-chat/issues/15228) ([6d94635](https://github.com/lobehub/lobe-chat/commit/6d94635))
- **desktop**: show zoom level HUD on Cmd+/- and Cmd+0, closes [#15294](https://github.com/lobehub/lobe-chat/issues/15294) ([109545c](https://github.com/lobehub/lobe-chat/commit/109545c))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 2.2.0](https://github.com/lobehub/lobe-chat/compare/v2.1.59-canary.27...v2.2.0)
<sup>Released on **2026-05-18**</sup>
-8
View File
@@ -210,14 +210,6 @@ ENV NEXT_PUBLIC_S3_DOMAIN="" \
S3_ENABLE_PATH_STYLE="" \
S3_SET_ACL=""
# Cloud Sandbox
ENV SANDBOX_PROVIDER="" \
ONLYBOXES_BASE_URL="" \
ONLYBOXES_JIT_ISSUER="" \
ONLYBOXES_JIT_SIGNING_KEY="" \
ONLYBOXES_JIT_TTL_SEC="" \
ONLYBOXES_LEASE_TTL_SEC=""
# Model Variables
ENV \
# AI21
-88
View File
@@ -1,88 +0,0 @@
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
assertGoldenFinalState,
extractGoldenOutcomes,
} from './fixtures/agent-signal/assertGoldenFinalState';
/**
* E2E tests for `lh agent-signal trigger`.
*
* The "golden fixture" block runs fully offline — it is the structural
* regression baseline that the execAgent migration asserts
* against. The "live trigger" block requires a running server + authenticated
* CLI and is gated behind AGENT_SIGNAL_AGENT_ID (or AGENT_ID).
*
* Prerequisites for the live block:
* - `lh` (or LH_CLI_PATH) points at the built CLI
* - User is authenticated (`lh login`) against a dev server with Agent Signal enabled
* - AGENT_SIGNAL_AGENT_ID=<agentId> identifies a target agent the user owns
*/
const CLI = process.env.LH_CLI_PATH || 'lh';
const AGENT_ID = process.env.AGENT_SIGNAL_AGENT_ID || process.env.AGENT_ID;
const TIMEOUT = 60_000;
const goldenPath = fileURLToPath(
new URL('./fixtures/agent-signal/nightly-review.golden.json', import.meta.url),
);
const golden = JSON.parse(readFileSync(goldenPath, 'utf-8'));
function run(args: string): string {
return execSync(`${CLI} ${args}`, {
encoding: 'utf-8',
env: { ...process.env, PATH: `${process.env.HOME}/.bun/bin:${process.env.PATH}` },
timeout: TIMEOUT,
}).trim();
}
describe('agent-signal golden fixture - structural regression', () => {
it('captures a recognizable nightly-review source payload', () => {
expect(golden.source.sourceType).toBe('agent.nightly_review.requested');
expect(golden.source.payload.agentId).toBeTruthy();
expect(golden.source.payload.userId).toBeTruthy();
expect(golden.source.scopeKey).toContain('agent:');
});
it('extracts ideas / write outcomes / brief from finalState', () => {
const outcomes = extractGoldenOutcomes(golden.finalState);
expect(outcomes.ideas.length).toBeGreaterThanOrEqual(1);
expect(outcomes.writeOutcomes.length).toBeGreaterThanOrEqual(1);
expect(outcomes.brief).toBeDefined();
});
it('passes the shared structural assertion', () => {
expect(() => assertGoldenFinalState(golden.finalState)).not.toThrow();
});
it('rejects an empty finalState', () => {
expect(() => assertGoldenFinalState({ messages: [] })).toThrow(/artifact/i);
});
});
describe.skipIf(!AGENT_ID)('lh agent-signal trigger - live', () => {
it('triggers a nightly review and returns a workflow run id', () => {
const output = run(
`agent-signal trigger --source-type agent.nightly_review.requested --agent ${AGENT_ID} --json`,
);
const result = JSON.parse(output);
expect(result).toHaveProperty('accepted');
expect(result).toHaveProperty('scopeKey');
// When Agent Signal is enabled for the account, a workflow run id is returned.
if (result.accepted) {
expect(typeof result.workflowRunId).toBe('string');
expect(result.workflowRunId.length).toBeGreaterThan(0);
}
});
it('exits non-zero on an invalid source type', () => {
expect(() =>
run(`agent-signal trigger --source-type not.a.real.type --agent ${AGENT_ID}`),
).toThrow();
});
});
@@ -1,127 +0,0 @@
/**
* Standalone structural assertions for self-iteration finalState snapshots.
*
* Dependency-free on purpose: the execAgent migration PRs
* import this from server tests AND the CLI e2e suite, so it must not pull in
* vitest or any server-only module. Mirrors the `kind` discrimination used by
* `src/server/services/agentSignal/services/selfIteration/finalStateExtractor.ts`.
*/
export type ToolResultKind = 'artifact' | 'mutation' | 'read';
export interface ToolResultWithKind {
apiName?: string;
data: Record<string, unknown> | unknown;
kind: ToolResultKind;
toolCallId?: string;
}
export interface GoldenOutcomes {
/** The single brief mutation, if any (apiName matches /brief/i). */
brief?: ToolResultWithKind;
/** Artifact tool results whose apiName mentions an idea. */
ideas: ToolResultWithKind[];
/** Artifact tool results whose apiName mentions an intent. */
intents: ToolResultWithKind[];
/** Durable mutation tool results, excluding the brief. */
writeOutcomes: ToolResultWithKind[];
}
interface FinalStateLike {
messages?: unknown[];
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const parseContent = (content: unknown): unknown => {
if (typeof content !== 'string') return content;
try {
return JSON.parse(content);
} catch {
return content;
}
};
/** Extract every tool result of `kind` from a finalState, in message order. */
export const extractFromFinalState = (
finalState: FinalStateLike,
kind: ToolResultKind,
): ToolResultWithKind[] => {
const results: ToolResultWithKind[] = [];
for (const message of finalState.messages ?? []) {
if (!isRecord(message)) continue;
if (message.role !== 'tool') continue;
const content = parseContent(message.content);
const contentRecord = isRecord(content) ? content : undefined;
const pluginState = isRecord(message.pluginState) ? message.pluginState : undefined;
const resultKind = contentRecord?.kind ?? pluginState?.kind;
if (resultKind !== kind) continue;
results.push({
apiName: typeof message.apiName === 'string' ? message.apiName : undefined,
data: contentRecord ?? content,
kind,
toolCallId: typeof message.tool_call_id === 'string' ? message.tool_call_id : undefined,
});
}
return results;
};
const matchesApiName = (result: ToolResultWithKind, pattern: RegExp): boolean =>
typeof result.apiName === 'string' && pattern.test(result.apiName);
const briefText = (brief?: ToolResultWithKind): string => {
if (!brief || !isRecord(brief.data)) return '';
const summary = typeof brief.data.summary === 'string' ? brief.data.summary : '';
const body = typeof brief.data.body === 'string' ? brief.data.body : '';
return `${summary}${body}`.trim();
};
/** Partition a finalState into ideas / intents / writeOutcomes / brief buckets. */
export const extractGoldenOutcomes = (finalState: FinalStateLike): GoldenOutcomes => {
const artifacts = extractFromFinalState(finalState, 'artifact');
const mutations = extractFromFinalState(finalState, 'mutation');
const brief = mutations.find((m) => matchesApiName(m, /brief/i));
return {
brief,
ideas: artifacts.filter((a) => matchesApiName(a, /idea/i)),
intents: artifacts.filter((a) => matchesApiName(a, /intent/i)),
writeOutcomes: mutations.filter((m) => !matchesApiName(m, /brief/i)),
};
};
/**
* Structural regression assertion for a self-iteration finalState.
*
* Throws (with a descriptive message) when the run produced no structured
* output: it requires at least one artifact (idea or intent), at least one
* durable write outcome, and a non-empty brief. Never compares text verbatim.
*/
export const assertGoldenFinalState = (finalState: FinalStateLike): GoldenOutcomes => {
const outcomes = extractGoldenOutcomes(finalState);
const artifactCount = outcomes.ideas.length + outcomes.intents.length;
if (artifactCount < 1) {
throw new Error(`Expected >= 1 artifact (idea/intent) in finalState, found ${artifactCount}`);
}
if (outcomes.writeOutcomes.length < 1) {
throw new Error(
`Expected >= 1 write outcome (mutation) in finalState, found ${outcomes.writeOutcomes.length}`,
);
}
const text = briefText(outcomes.brief);
if (text.length === 0) {
throw new Error('Expected a non-empty brief in finalState, found none');
}
return outcomes;
};
@@ -1,61 +0,0 @@
{
"description": "Desensitized golden snapshot of one nightly-review self-iteration run. Used as a structural regression baseline by the execAgent migration which converges all agent execution paths (chat, self-iteration, memoryWriter, skillManagement) onto a single execAgent entry point. Assert structure, never byte-for-byte: the LLM output is non-deterministic.",
"finalState": {
"messages": [
{
"content": "Run the nightly self-review for the local window.",
"role": "user"
},
{
"apiName": "getEvidenceDigest",
"content": "{\"kind\":\"read\",\"topicCount\":3,\"messageCount\":42,\"window\":\"2026-05-30/2026-05-31\"}",
"role": "tool",
"tool_call_id": "call_read_1"
},
{
"apiName": "recordSelfReviewIdea",
"content": "{\"kind\":\"artifact\",\"idempotencyKey\":\"idea:pref:tone\",\"title\":\"Prefer concise replies\",\"rationale\":\"User repeatedly asked to shorten answers in topic tpc_demo\",\"risk\":\"low\"}",
"role": "tool",
"tool_call_id": "call_idea_1"
},
{
"apiName": "recordSelfReviewIdea",
"content": "{\"kind\":\"artifact\",\"idempotencyKey\":\"idea:skill:drizzle\",\"title\":\"Document Drizzle join helper\",\"rationale\":\"Recurring question about leftJoin usage\",\"risk\":\"medium\"}",
"role": "tool",
"tool_call_id": "call_idea_2"
},
{
"apiName": "writeMemory",
"content": "{\"kind\":\"mutation\",\"status\":\"applied\",\"resourceId\":\"mem_001\",\"summary\":\"Stored tone preference: prefer concise replies\"}",
"pluginState": { "kind": "mutation" },
"role": "tool",
"tool_call_id": "call_mut_1"
},
{
"apiName": "createSelfReviewBrief",
"content": "{\"kind\":\"mutation\",\"briefId\":\"brief_001\",\"summary\":\"Nightly review captured 2 ideas and wrote 1 memory.\",\"body\":\"## Highlights\\n- Prefer concise replies\\n- Document Drizzle join helper\"}",
"role": "tool",
"tool_call_id": "call_brief_1"
},
{
"content": "Nightly review complete. Captured 2 ideas and wrote 1 memory.",
"role": "assistant"
}
]
},
"source": {
"payload": {
"agentId": "agent_demo",
"localDate": "2026-05-30",
"requestedAt": "2026-05-31T04:00:00.000Z",
"reviewWindowEnd": "2026-05-31T04:00:00.000Z",
"reviewWindowStart": "2026-05-30T04:00:00.000Z",
"timezone": "UTC",
"userId": "user_demo"
},
"scopeKey": "agent:agent_demo:user:user_demo",
"sourceId": "nightly-review:user_demo:agent_demo:2026-05-30",
"sourceType": "agent.nightly_review.requested",
"timestamp": 1748664000000
}
}
+1 -7
View File
@@ -1,6 +1,6 @@
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
.\" Manual command details come from the Commander command tree.
.TH LH 1 "" "@lobehub/cli 0.0.27" "User Commands"
.TH LH 1 "" "@lobehub/cli 0.0.22" "User Commands"
.SH NAME
lh \- LobeHub CLI \- manage and connect to LobeHub services
.SH SYNOPSIS
@@ -65,9 +65,6 @@ Manage agents
.B agent\-group
Manage agent groups
.TP
.B agent\-signal
Inspect and trigger Agent Signal source events
.TP
.B bot
Manage bot integrations
.TP
@@ -113,9 +110,6 @@ Manage plugins
.B user
Manage user account and settings
.TP
.B verify
Manage the Agent Run delivery checker (criteria, rubrics, plans, results)
.TP
.B whoami
Display current user information
.TP
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/cli",
"version": "0.0.27",
"version": "0.0.22",
"type": "module",
"bin": {
"lh": "./dist/index.js",
@@ -33,7 +33,6 @@
"@lobechat/device-identity": "workspace:*",
"@lobechat/heterogeneous-agents": "workspace:*",
"@lobechat/local-file-shell": "workspace:*",
"@lobechat/tool-runtime": "workspace:*",
"@trpc/client": "^11.8.1",
"@types/node": "^22.13.5",
"@types/ws": "^8.18.1",
-3
View File
@@ -4,9 +4,6 @@ packages:
- '../../packages/device-identity'
- '../../packages/heterogeneous-agents'
- '../../packages/local-file-shell'
- '../../packages/tool-runtime'
- '../../packages/prompts'
- '../../packages/const'
- '../../packages/types'
- '../../packages/model-bank'
- '../../packages/business/const'
+2 -4
View File
@@ -13,7 +13,7 @@ interface CurrentUserResponse {
export async function getUserIdFromApiKey(apiKey: string, serverUrl?: string): Promise<string> {
const normalizedServerUrl = normalizeUrl(serverUrl) || resolveServerUrl();
const response = await fetch(`${normalizedServerUrl}/api/v1/users/me?includeCount=0`, {
const response = await fetch(`${normalizedServerUrl}/api/v1/users/me`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
@@ -23,9 +23,7 @@ export async function getUserIdFromApiKey(apiKey: string, serverUrl?: string): P
try {
body = (await response.json()) as CurrentUserResponse;
} catch {
throw new Error(
`Failed to parse response from ${normalizedServerUrl}/api/v1/users/me?includeCount=0.`,
);
throw new Error(`Failed to parse response from ${normalizedServerUrl}/api/v1/users/me.`);
}
if (!response.ok || body?.success === false) {
+1 -1
View File
@@ -20,7 +20,7 @@ interface ResolvedAuth {
/**
* Parse the `sub` claim from a JWT without verifying the signature.
*/
export function parseJwtSub(token: string): string | undefined {
function parseJwtSub(token: string): string | undefined {
try {
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString());
return payload.sub;
-129
View File
@@ -1,129 +0,0 @@
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../../api/client';
import { log } from '../../utils/logger';
/**
* Producer source types a developer may trigger manually for local testing.
* Mirrors `AGENT_SIGNAL_TRIGGER_SOURCE_TYPES` on the server; kept inline so the
* CLI bundle does not pull in server-only modules.
*/
const TRIGGER_SOURCE_TYPES = [
'agent.nightly_review.requested',
'agent.self_reflection.requested',
'agent.self_feedback_intent.declared',
'agent.user.message',
'tool.outcome.completed',
'tool.outcome.failed',
] as const;
type TriggerSourceType = (typeof TRIGGER_SOURCE_TYPES)[number];
export function registerAgentSignalCommand(program: Command) {
const agentSignal = program
.command('agent-signal')
.description('Inspect and trigger Agent Signal source events');
agentSignal
.command('trigger')
.description('Trigger an Agent Signal source event for the authenticated user')
.requiredOption(
'--source-type <type>',
`Source type to emit. One of:\n ${TRIGGER_SOURCE_TYPES.join('\n ')}`,
)
.option('--agent <agentId>', 'Target agent ID (required for agent-scoped source types)')
.option('--topic <topicId>', 'Topic ID to scope the event to')
.option('--payload-json <json>', 'JSON object shallow-merged over the default payload')
.option('--source-id <id>', 'Override the auto-derived dedupe source id')
.option('--scope-key <key>', 'Override the auto-derived scope key')
.option('--timestamp <ms>', 'Event timestamp in milliseconds')
.option('--json', 'Output JSON')
.action(
async (options: {
agent?: string;
json?: boolean;
payloadJson?: string;
scopeKey?: string;
sourceId?: string;
sourceType: string;
timestamp?: string;
topic?: string;
}) => {
const sourceType = options.sourceType as TriggerSourceType;
if (!TRIGGER_SOURCE_TYPES.includes(sourceType)) {
console.error(
`${pc.red('✗')} Invalid --source-type "${options.sourceType}". Expected one of: ${TRIGGER_SOURCE_TYPES.join(', ')}`,
);
process.exit(1);
return;
}
let payloadOverride: Record<string, unknown> | undefined;
if (options.payloadJson) {
try {
const parsed = JSON.parse(options.payloadJson);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('payload must be a JSON object');
}
payloadOverride = parsed as Record<string, unknown>;
} catch (error: any) {
console.error(`${pc.red('✗')} Failed to parse --payload-json: ${error.message}`);
process.exit(1);
return;
}
}
let timestamp: number | undefined;
if (options.timestamp !== undefined) {
timestamp = Number(options.timestamp);
if (!Number.isFinite(timestamp)) {
console.error(`${pc.red('✗')} --timestamp must be a number (milliseconds)`);
process.exit(1);
return;
}
}
log.debug(
'agent-signal trigger: sourceType=%s agent=%s topic=%s',
sourceType,
options.agent,
options.topic,
);
const client = await getTrpcClient();
try {
const result = await client.agentSignal.triggerSourceEvent.mutate({
agentId: options.agent,
payloadOverride,
scopeKey: options.scopeKey,
sourceId: options.sourceId,
sourceType,
timestamp,
topicId: options.topic,
});
if (options.json) {
console.log(JSON.stringify(result, null, 2));
return;
}
if (!result.accepted) {
console.log(
`${pc.yellow('!')} Agent Signal is disabled for this account — event was not enqueued (scopeKey: ${pc.bold(result.scopeKey)})`,
);
return;
}
console.log(`${pc.green('✓')} Triggered ${pc.bold(sourceType)}`);
console.log(` Scope key: ${result.scopeKey}`);
console.log(` Workflow run id: ${result.workflowRunId}`);
} catch (error: any) {
console.error(`${pc.red('✗')} Failed to trigger source event: ${error.message}`);
process.exit(1);
}
},
);
}
+16 -80
View File
@@ -347,33 +347,22 @@ export function registerAgentCommand(program: Command) {
const { serverUrl, headers, token, tokenType } = await getAgentStreamAuthInfo();
const agentGatewayUrl = options.sse ? undefined : resolveAgentGatewayUrl();
try {
if (agentGatewayUrl) {
await streamAgentEventsViaWebSocket({
gatewayUrl: agentGatewayUrl,
json: options.json,
operationId,
serverUrl,
token,
tokenType,
verbose: options.verbose,
});
} else {
const streamUrl = `${serverUrl}/api/agent/stream?operationId=${encodeURIComponent(operationId)}`;
await streamAgentEvents(streamUrl, headers, {
json: options.json,
verbose: options.verbose,
});
}
} catch (error) {
// The live stream (gateway WS / SSE) dropped before the run finished —
// the run is still executing server-side. Instead of failing, fall back
// to polling the run status until it reaches a terminal state.
if (options.json) throw error;
log.warn(
`Live stream unavailable (${(error as Error).message}). Polling run status every 10s…`,
);
await pollAgentRunStatus(client, operationId);
if (agentGatewayUrl) {
await streamAgentEventsViaWebSocket({
gatewayUrl: agentGatewayUrl,
json: options.json,
operationId,
serverUrl,
token,
tokenType,
verbose: options.verbose,
});
} else {
const streamUrl = `${serverUrl}/api/agent/stream?operationId=${encodeURIComponent(operationId)}`;
await streamAgentEvents(streamUrl, headers, {
json: options.json,
verbose: options.verbose,
});
}
},
);
@@ -637,56 +626,3 @@ function colorStatus(status: string): string {
}
}
}
const TERMINAL_RUN_STATUSES = new Set([
'completed',
'done',
'success',
'failed',
'error',
'cancelled',
'canceled',
'aborted',
]);
/**
* Fallback when the live stream (gateway WebSocket / SSE) drops before the run
* finishes: the run is still executing server-side, so poll its status every 10s
* until it reaches a terminal state (or is no longer tracked, which also means it
* has finished). Avoids hard-exiting on a transient gateway disconnect.
*/
async function pollAgentRunStatus(
client: Awaited<ReturnType<typeof getTrpcClient>>,
operationId: string,
): Promise<void> {
const POLL_MS = 10_000;
let lastStatus = '';
for (let i = 0; ; i++) {
if (i > 0) await new Promise((resolve) => setTimeout(resolve, POLL_MS));
let r: any;
try {
r = await client.aiAgent.getOperationStatus.query({ operationId } as any);
} catch (error) {
log.error(`Status poll failed: ${(error as Error).message}`);
process.exit(1);
}
if (!r) {
log.info('Run is no longer tracked — finished (or expired).');
return;
}
const status = r.status || r.state || 'unknown';
if (status !== lastStatus) {
lastStatus = status;
const steps = r.stepCount !== undefined ? ` · ${r.stepCount} step(s)` : '';
log.info(`Run status: ${colorStatus(status)}${steps}`);
}
if (TERMINAL_RUN_STATUSES.has(status)) {
if (r.error) log.error(`Run error: ${r.error}`);
return;
}
}
}
+20 -10
View File
@@ -8,8 +8,11 @@ import type {
ToolCallRequestMessage,
} from '@lobechat/device-gateway-client';
import { GatewayClient } from '@lobechat/device-gateway-client';
import type { IdentitySource } from '@lobechat/device-identity';
import { deriveDeviceId } from '@lobechat/device-identity';
import type { Command } from 'commander';
import { createLambdaClient } from '../api/client';
import { getValidToken } from '../auth/refresh';
import { resolveToken } from '../auth/resolveToken';
import { CLI_API_KEY_ENV } from '../constants/auth';
@@ -25,7 +28,6 @@ import {
stopDaemon,
writeStatus,
} from '../daemon/manager';
import { registerDevice, resolveDeviceIdentity } from '../device/register';
import { loadOrCreateConnectionId, loadSettings, normalizeUrl, saveSettings } from '../settings';
import { executeToolCall } from '../tools';
import { cleanupAllProcesses } from '../tools/shell';
@@ -196,7 +198,12 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
// Resolve a stable device identity. An explicit `--device-id` wins (lets a
// user pin a VM to a fixed identity); otherwise derive from the machine id so
// the same machine + user maps to one device across reconnects.
const identity = resolveDeviceIdentity(auth.userId, options.deviceId);
const identity: { deviceId: string; identitySource: IdentitySource } | undefined =
options.deviceId
? { deviceId: options.deviceId, identitySource: 'fallback' }
: auth.userId
? deriveDeviceId(auth.userId)
: undefined;
// Freeform channel label (`cli` by default); `LOBEHUB_CLI_CHANNEL` lets a
// dev build tag itself `cli-dev` so the gateway can prioritise / display it.
@@ -280,7 +287,6 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
result: {
content: result.content,
error: result.error,
state: result.state,
success: result.success,
},
});
@@ -400,15 +406,19 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
});
// Register this device in the server registry before opening the WS, so the
// row exists by the time the gateway reports it online. `lh login` already
// registers, but re-running here is cheap (idempotent upsert) and covers
// `--token` sessions that never went through login. Best-effort: a failure
// must not block the connection.
// row exists by the time the gateway reports it online. Best-effort: a
// failure must not block the connection.
if (identity) {
try {
// Reuse the already-resolved auth (respects `--token` mode) so we don't
// re-discover creds and exit when none are found.
await registerDevice(auth, identity);
// Reuse the already-resolved auth (respects `--token` mode) instead of
// getTrpcClient(), which re-discovers creds and exits when none are found.
const trpc = createLambdaClient(auth);
await trpc.device.register.mutate({
deviceId: identity.deviceId,
hostname: os.hostname(),
identitySource: identity.identitySource,
platform: process.platform,
});
} catch (err) {
error(`Device registration failed (non-fatal): ${(err as Error).message}`);
}
-223
View File
@@ -1,6 +1,3 @@
import { mkdtemp, readdir, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { PassThrough } from 'node:stream';
import { Command } from 'commander';
@@ -648,224 +645,4 @@ describe('hetero exec command', () => {
'finish',
]);
});
it('resets the per-message text accumulator at message boundaries (no cross-message duplication)', async () => {
// LOBE-10157 Bug 3: the `replace` snapshot accumulator must not span
// message boundaries. Two assistant messages separated by a
// stream_end/stream_start boundary must each snapshot only their OWN
// text — otherwise the second message re-emits the first's text verbatim.
const textSnapshots: string[] = [];
mockHeteroIngestMutate.mockImplementation(async ({ events }: any) => {
for (const e of events) {
if (e.type === 'stream_chunk' && e.data?.chunkType === 'text') {
textSnapshots.push(e.data.content);
}
}
return { ack: true };
});
mockSpawnAgent.mockReturnValue(
createFakeHandle({
events: [
{
data: { chunkType: 'text', content: 'first message' },
operationId: 'op-server',
stepIndex: 0,
timestamp: 1,
type: 'stream_chunk',
},
{ data: {}, operationId: 'op-server', stepIndex: 0, timestamp: 2, type: 'stream_end' },
{
data: { newStep: true, provider: 'claude-code' },
operationId: 'op-server',
stepIndex: 1,
timestamp: 3,
type: 'stream_start',
},
{
data: { chunkType: 'text', content: 'second message' },
operationId: 'op-server',
stepIndex: 1,
timestamp: 4,
type: 'stream_chunk',
},
{
data: { reason: 'success' },
operationId: 'op-server',
stepIndex: 1,
timestamp: 5,
type: 'agent_runtime_end',
},
],
exitCode: 0,
}),
);
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'hi',
'--topic',
'topic-1',
'--operation-id',
'op-server',
'--render',
'none',
]);
// Second snapshot carries ONLY the second message — not "first messagesecond message".
expect(textSnapshots).toEqual(['first message', 'second message']);
});
it('forwards subagent text raw (no snapshot coalescing, no cross-scope pollution of main text)', async () => {
// Subagent text is emitted as ONE full block per turn and the server's
// subagent path *appends* it (no snapshot semantics). It must therefore
// bypass the main-agent `replace`-snapshot coalescing: folding it into the
// shared accumulator would (a) splice main text into the subagent message
// and (b) make the server append a replace-snapshot → duplicated content.
const ingested: any[] = [];
mockHeteroIngestMutate.mockImplementation(async ({ events }: any) => {
for (const e of events) ingested.push(e);
return { ack: true };
});
const subagent = { parentToolCallId: 'task-1', subagentMessageId: 'msg-sub-1' };
mockSpawnAgent.mockReturnValue(
createFakeHandle({
events: [
// Main-agent streamed text delta (coalesced).
{
data: { chunkType: 'text', content: 'hello ' },
operationId: 'op-server',
stepIndex: 0,
timestamp: 1,
type: 'stream_chunk',
},
// Subagent full-block text — must pass through untouched.
{
data: { chunkType: 'text', content: 'I checked the files.', subagent },
operationId: 'op-server',
stepIndex: 0,
timestamp: 2,
type: 'stream_chunk',
},
{
data: {
chunkType: 'tools_calling',
toolsCalling: [
{
apiName: 'Bash',
arguments: '{"cmd":"ls"}',
id: 'tc-1',
identifier: 'bash',
type: 'default',
},
],
},
operationId: 'op-server',
stepIndex: 1,
timestamp: 3,
type: 'stream_chunk',
},
{
data: { reason: 'success' },
operationId: 'op-server',
stepIndex: 1,
timestamp: 4,
type: 'agent_runtime_end',
},
],
exitCode: 0,
}),
);
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'hi',
'--topic',
'topic-1',
'--operation-id',
'op-server',
'--render',
'none',
]);
const textEvents = ingested.filter(
(e) => e.type === 'stream_chunk' && e.data?.chunkType === 'text',
);
// Subagent text forwarded verbatim: keeps its subagent tag, original
// content, and is NOT converted into a replace snapshot.
const subagentText = textEvents.find((e) => e.data?.subagent);
expect(subagentText).toBeDefined();
expect(subagentText.data.content).toBe('I checked the files.');
expect(subagentText.data.snapshotMode).toBeUndefined();
// Main snapshot is untainted by the subagent block.
const mainText = textEvents.find((e) => !e.data?.subagent);
expect(mainText).toBeDefined();
expect(mainText.data.content).toBe('hello ');
expect(mainText.data.snapshotMode).toBe('replace');
expect(mainText.data.content).not.toContain('I checked');
});
it('--raw-dump writes a session folder with meta.json, wires onRawStdout, and tees stderr', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'hetero-rawdump-'));
mockSpawnAgent.mockReturnValue(
createFakeHandle({
events: [
{
data: { chunkType: 'text', content: 'hi' },
operationId: 'op-raw',
stepIndex: 0,
timestamp: 1,
type: 'stream_chunk',
},
],
exitCode: 0,
stderrChunks: ['warning: something happened\n'],
}),
);
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'hi',
'--operation-id',
'op-raw',
'--render',
'none',
'--raw-dump',
root,
]);
// The raw stdout tee is handed to spawnAgent (the package captures the
// pre-adapter bytes — exercised in spawnAgent.test.ts).
expect(typeof mockSpawnAgent.mock.calls[0][0].onRawStdout).toBe('function');
// One session folder per exec, keyed by the operation id.
const sessions = await readdir(root);
expect(sessions).toHaveLength(1);
expect(sessions[0]).toContain('op-raw');
const sessionDir = path.join(root, sessions[0]!);
const meta = JSON.parse(await readFile(path.join(sessionDir, 'meta.json'), 'utf8'));
expect(meta).toMatchObject({ agentType: 'claude-code', operationId: 'op-raw' });
// stderr is teed to the attempt's log file.
const stderrDump = await readFile(path.join(sessionDir, 'attempt-1.stderr.log'), 'utf8');
expect(stderrDump).toContain('warning: something happened');
});
});
+3 -133
View File
@@ -1,7 +1,6 @@
import { randomUUID } from 'node:crypto';
import { once } from 'node:events';
import { createWriteStream } from 'node:fs';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import type {
@@ -60,12 +59,6 @@ interface ExecOptions {
inputJson?: string;
operationId?: string;
prompt?: string;
/**
* When set, persist the agent process's RAW stdout/stderr (pre-adapter
* stream-json) under `<rawDump>/<timestamp>-<operationId>/` for debugging.
* Independent of `--render` and the server ingest path.
*/
rawDump?: string;
/**
* Output rendering mode.
* jsonl — emit each `AgentStreamEvent` as a JSONL line on stdout (default
@@ -224,25 +217,10 @@ class SerialServerIngester {
push(event: AgentStreamEvent): void {
if (this.fatalError) return;
// Text-snapshot coalescing is a MAIN-AGENT-ONLY transport optimization:
// it debounces the main agent's token-level text *deltas* into one
// `replace` snapshot to cut ingest calls. Subagent text is explicitly
// excluded (`!event.data?.subagent`) for two reasons:
// 1. Subagent text is emitted as ONE full block per turn (see
// claudeCode adapter `handleSubagentAssistant` — "the full block IS
// the only emission"), so there is nothing to coalesce.
// 2. `accumulatedText` is a single shared accumulator with no subagent
// scope. Folding subagent blocks in would (a) splice main-agent text
// into the subagent message via the shared buffer, and (b) emit a
// `replace` snapshot that the server's subagent path *appends*
// (`persistSubagentText` has no snapshot semantics) → duplicated /
// cross-scope content. Forwarding the raw block straight through lets
// the server append it exactly once, correctly.
if (
event.type === 'stream_chunk' &&
event.data?.chunkType === 'text' &&
typeof event.data?.content === 'string' &&
!event.data?.subagent
typeof event.data?.content === 'string'
) {
this.accumulatedText += event.data.content;
this.pendingTextEvent = event;
@@ -255,17 +233,6 @@ class SerialServerIngester {
}
this.queuePendingTextSnapshot();
// `accumulatedText` is a PER-MESSAGE accumulator: it coalesces the text
// deltas of the current assistant message into one `replace` snapshot.
// A new message boundary (`stream_start` / `stream_end`, emitted by the
// adapter's `openMainMessage`) must reset it — otherwise it spans the
// whole run and every later message's snapshot re-emits all prior
// messages' text verbatim, which the server then persists into the new
// DB message (LOBE-10157 Bug 3: cross-message text duplication). Reset
// AFTER flushing the just-ended message's pending snapshot above.
if (event.type === 'stream_start' || event.type === 'stream_end') {
this.accumulatedText = '';
}
this.enqueue(async () => {
await this.sink.ingest([event]);
});
@@ -313,77 +280,6 @@ class SerialServerIngester {
}
}
interface RawStreamDumpAttempt {
/** Flush + close both file streams. Resolves once the bytes are on disk. */
close: () => Promise<void>;
writeStderr: (chunk: Buffer) => void;
writeStdout: (chunk: Buffer) => void;
}
/**
* Persists the agent process's RAW stdout/stderr — the untouched stream-json,
* BEFORE the adapter — to disk for post-hoc debugging. The adapted/ingested
* view can't tell a CC-side empty `tool_result` apart from an adapter
* extraction bug; the raw dump can.
*
* Enabled via `lh hetero exec --raw-dump <dir>`. Each exec gets its own
* `<dir>/<timestamp>-<operationId>/` session folder; each spawn attempt (the
* resume retry is a second attempt) writes `<label>.stdout.jsonl` /
* `<label>.stderr.log`. Fully best-effort: any dump failure is logged and
* swallowed so it never affects the run or its exit code.
*
* Future: the server-side sandbox runner (`spawnHeteroSandbox`) and the
* desktop device path (`spawnLhHeteroExec`) can pass `--raw-dump` pointing at
* a collectable location to capture remote runs the same way.
*/
class RawStreamDump {
private constructor(private readonly dir: string) {}
static async create(
root: string,
operationId: string,
meta: Record<string, unknown>,
): Promise<RawStreamDump | undefined> {
try {
const safeTs = new Date().toISOString().replaceAll(/[.:]/g, '-');
const dir = path.join(path.resolve(root), `${safeTs}-${operationId}`);
await mkdir(dir, { recursive: true });
await writeFile(
path.join(dir, 'meta.json'),
`${JSON.stringify({ ...meta, operationId, startedAt: new Date().toISOString() }, null, 2)}\n`,
);
log.info(`Raw stream dump enabled → ${dir}`);
return new RawStreamDump(dir);
} catch (err) {
log.warn(
`Failed to initialize raw stream dump: ${err instanceof Error ? err.message : String(err)}`,
);
return undefined;
}
}
openAttempt(label: string): RawStreamDumpAttempt {
const stdout = createWriteStream(path.join(this.dir, `${label}.stdout.jsonl`));
const stderr = createWriteStream(path.join(this.dir, `${label}.stderr.log`));
// A failed dump write must never crash the run — drop write errors.
stdout.on('error', () => {});
stderr.on('error', () => {});
return {
close: () =>
Promise.all([
new Promise<void>((resolve) => stdout.end(() => resolve())),
new Promise<void>((resolve) => stderr.end(() => resolve())),
]).then(() => undefined),
writeStderr: (chunk: Buffer) => {
stderr.write(chunk);
},
writeStdout: (chunk: Buffer) => {
stdout.write(chunk);
},
};
}
}
const exec = async (options: ExecOptions): Promise<void> => {
if (!SUPPORTED_AGENT_TYPES.has(options.type)) {
log.error(
@@ -418,17 +314,6 @@ const exec = async (options: ExecOptions): Promise<void> => {
const operationId = options.operationId || randomUUID();
// Optional raw stream dump (pre-adapter stdout/stderr) for debugging.
let rawDump: RawStreamDump | undefined;
if (options.rawDump) {
rawDump = await RawStreamDump.create(options.rawDump, operationId, {
agentType: options.type,
cwd: options.cwd || process.cwd(),
resume: options.resume ?? null,
topicId: options.topic ?? null,
});
}
// Determine JSONL output mode.
// Explicit --render flag always wins. Otherwise: emit JSONL in standalone
// mode; suppress in server-ingest mode (sink handles the data path).
@@ -472,7 +357,6 @@ const exec = async (options: ExecOptions): Promise<void> => {
const runOneAgent = async (
spawnOpts: Parameters<typeof spawnAgent>[0],
interceptResumeErrors: boolean,
runLabel: string,
): Promise<{
code: number | null;
ingestError: boolean;
@@ -481,17 +365,12 @@ const exec = async (options: ExecOptions): Promise<void> => {
signal: NodeJS.Signals | null;
stderrContent: string;
}> => {
// One raw-dump file pair per spawn attempt (the resume retry is a second
// attempt). The stdout tee runs inside `spawnAgent` before the adapter.
const dumpAttempt = rawDump?.openAttempt(runLabel);
// `spawnAgent` is async and can reject DURING image normalization — fetch
// failures, missing local --image paths, decode errors.
let handle: Awaited<ReturnType<typeof spawnAgent>>;
try {
handle = await spawnAgent({ ...spawnOpts, onRawStdout: dumpAttempt?.writeStdout });
handle = await spawnAgent(spawnOpts);
} catch (err) {
await dumpAttempt?.close();
log.error('Failed to start agent:', err instanceof Error ? err.message : String(err));
process.exit(1);
}
@@ -508,7 +387,6 @@ const exec = async (options: ExecOptions): Promise<void> => {
if (stderrContent.length < STDERR_CAP) {
stderrContent += chunk.toString();
}
dumpAttempt?.writeStderr(chunk);
});
handle.stderr.pipe(process.stderr);
@@ -582,7 +460,6 @@ const exec = async (options: ExecOptions): Promise<void> => {
// best-effort
}
}
await dumpAttempt?.close();
process.exit(1);
} finally {
process.off('SIGINT', onSigint);
@@ -591,7 +468,6 @@ const exec = async (options: ExecOptions): Promise<void> => {
const { code, signal } = await handle.exit;
await stderrEnded;
await dumpAttempt?.close();
// Fallback stderr detection: CC may exit non-zero without emitting a
// result event (e.g. it writes to stderr and quits immediately).
@@ -627,7 +503,6 @@ const exec = async (options: ExecOptions): Promise<void> => {
resumeSessionId: options.resume,
},
interceptResume,
'attempt-1',
);
// ─── Auto-retry without --resume when the session cannot be used ─────────
@@ -656,7 +531,6 @@ const exec = async (options: ExecOptions): Promise<void> => {
// No resumeSessionId — start fresh
},
false, // no need to intercept resume errors on a fresh run
'attempt-2-noresume',
);
}
@@ -744,9 +618,5 @@ export function registerHeteroCommand(program: Command) {
'--render <mode>',
'Output mode: jsonl (emit events as JSONL on stdout) | none (suppress stdout). Defaults to jsonl in standalone, none in server-ingest mode.',
)
.option(
'--raw-dump <dir>',
'Persist the agent process RAW stdout/stderr (pre-adapter stream-json) under <dir>/<timestamp>-<operationId>/ for debugging. Each spawn attempt writes its own .stdout.jsonl / .stderr.log. Best-effort; never affects the run.',
)
.action(exec);
}
-26
View File
@@ -6,10 +6,8 @@ import type { Command } from 'commander';
import { getUserIdFromApiKey } from '../auth/apiKey';
import { saveCredentials } from '../auth/credentials';
import { parseJwtSub } from '../auth/resolveToken';
import { CLI_API_KEY_ENV } from '../constants/auth';
import { OFFICIAL_SERVER_URL } from '../constants/urls';
import { registerDevice, resolveDeviceIdentity } from '../device/register';
import { loadSettings, normalizeUrl, saveSettings } from '../settings';
import { log } from '../utils/logger';
@@ -215,30 +213,6 @@ export function registerLoginCommand(program: Command) {
},
);
// Register this device in the server registry right after auth, so
// the device row exists without waiting for a later `lh connect`
// (which only adds the channel-online step). Mirrors the desktop
// app, which registers on login. Best-effort: a failure here must
// not fail the login.
//
// Skip the `fallback` source: `lh login` has no `--device-id` and
// persists no fallback id, so a machine without a readable
// machine-id would derive a *fresh random* id on every login —
// registering it just spawns orphan device rows that never match
// the id a later `lh connect` resolves. Defer registration to
// `connect` in that case, where the same id is reused for the WS.
const identity = resolveDeviceIdentity(parseJwtSub(body.access_token));
if (identity && identity.identitySource !== 'fallback') {
try {
await registerDevice(
{ serverUrl, token: body.access_token, tokenType: 'jwt' },
identity,
);
} catch (err) {
log.warn(`Device registration failed (non-fatal): ${(err as Error).message}`);
}
}
log.info('Login successful! Credentials saved.');
return;
}
-90
View File
@@ -1,90 +0,0 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { registerVerifyCommand } from './verify';
const { mockTrpcClient } = vi.hoisted(() => ({
mockTrpcClient: {
verify: {
createRubric: { mutate: vi.fn() },
getRubric: { query: vi.fn() },
updateRubric: { mutate: vi.fn() },
},
},
}));
const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({
getTrpcClient: vi.fn(),
}));
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
vi.mock('../utils/logger', () => ({
log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
setVerbose: vi.fn(),
}));
describe('verify rubric config commands', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
mockTrpcClient.verify.createRubric.mutate.mockReset().mockResolvedValue({ id: 'rub-1' });
mockTrpcClient.verify.updateRubric.mutate.mockReset().mockResolvedValue(undefined);
mockTrpcClient.verify.getRubric.query.mockReset();
});
afterEach(() => consoleSpy.mockRestore());
const run = async (args: string[]) => {
const program = new Command();
program.exitOverride();
registerVerifyCommand(program);
await program.parseAsync(['node', 'lh', 'verify', ...args]);
};
it('passes maxRepairRounds config when creating a rubric', async () => {
await run(['rubric', 'create', '-t', 'Standard', '--max-repair-rounds', '3']);
expect(mockTrpcClient.verify.createRubric.mutate).toHaveBeenCalledWith({
config: { maxRepairRounds: 3 },
description: undefined,
title: 'Standard',
});
});
it('omits config when no max-repair-rounds flag is given', async () => {
await run(['rubric', 'create', '-t', 'Standard']);
expect(mockTrpcClient.verify.createRubric.mutate).toHaveBeenCalledWith({
config: undefined,
description: undefined,
title: 'Standard',
});
});
it('updates only the config when max-repair-rounds is passed', async () => {
await run(['rubric', 'update', 'rub-1', '--max-repair-rounds', '0']);
expect(mockTrpcClient.verify.updateRubric.mutate).toHaveBeenCalledWith({
id: 'rub-1',
value: { config: { maxRepairRounds: 0 } },
});
});
it('views a rubric and prints its repair-round config', async () => {
mockTrpcClient.verify.getRubric.query.mockResolvedValue({
config: { maxRepairRounds: 4 },
description: 'desc',
id: 'rub-1',
title: 'Standard',
});
await run(['rubric', 'view', 'rub-1']);
expect(mockTrpcClient.verify.getRubric.query).toHaveBeenCalledWith({ id: 'rub-1' });
const printed = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(printed).toContain('Standard');
expect(printed).toContain('4');
});
});
-455
View File
@@ -1,455 +0,0 @@
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../api/client';
import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format';
import { log } from '../utils/logger';
// ── Helpers ────────────────────────────────────────────────
type VerifierType = 'agent' | 'llm' | 'program';
type OnFail = 'auto_repair' | 'manual';
type Decision = 'accepted' | 'overridden' | 'rejected';
const VERIFIER_TYPES: VerifierType[] = ['program', 'agent', 'llm'];
const ON_FAIL: OnFail[] = ['manual', 'auto_repair'];
const DECISIONS: Decision[] = ['accepted', 'rejected', 'overridden'];
function parseConfig(raw?: string): Record<string, unknown> | undefined {
if (!raw) return undefined;
try {
return JSON.parse(raw);
} catch {
log.error('--config must be valid JSON');
process.exit(1);
}
}
function assertEnum<T extends string>(value: T | undefined, allowed: T[], flag: string): void {
if (value !== undefined && !allowed.includes(value)) {
log.error(`${flag} must be one of: ${allowed.join(', ')}`);
process.exit(1);
}
}
// ── Command Registration ───────────────────────────────────
export function registerVerifyCommand(program: Command) {
const verify = program
.command('verify')
.description('Manage the Agent Run delivery checker (criteria, rubrics, plans, results)');
// ════════════ criteria ════════════
const criterion = verify.command('criterion').description('Reusable pass/fail standards');
criterion
.command('list')
.description('List criteria')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (options: { json?: boolean | string }) => {
const client = await getTrpcClient();
const items = await client.verify.listCriteria.query();
if (options.json !== undefined) {
outputJson(items, typeof options.json === 'string' ? options.json : undefined);
return;
}
if (items.length === 0) return void console.log('No criteria found.');
printTable(
items.map((c) => [
c.id,
truncate(c.title, 60),
c.verifierType,
c.required ? 'gate' : 'soft',
c.onFail,
c.updatedAt ? timeAgo(c.updatedAt) : '',
]),
['ID', 'TITLE', 'TYPE', 'BLOCK', 'ON-FAIL', 'UPDATED'],
);
});
criterion
.command('create')
.description('Create a criterion')
.requiredOption('-t, --title <title>', 'Criterion title')
.requiredOption('--type <type>', `Verifier type (${VERIFIER_TYPES.join('|')})`)
.option('--on-fail <strategy>', `Action on failure (${ON_FAIL.join('|')})`)
.option('--soft', 'Non-blocking (required=false); defaults to blocking')
.option('--config <json>', 'Verifier config as JSON')
.option('--doc <id>', 'Linked guidance document id')
.action(
async (options: {
config?: string;
doc?: string;
onFail?: OnFail;
soft?: boolean;
title: string;
type: VerifierType;
}) => {
assertEnum(options.type, VERIFIER_TYPES, '--type');
assertEnum(options.onFail, ON_FAIL, '--on-fail');
const client = await getTrpcClient();
const result = await client.verify.createCriterion.mutate({
documentId: options.doc,
onFail: options.onFail,
required: options.soft ? false : undefined,
title: options.title,
verifierConfig: parseConfig(options.config),
verifierType: options.type,
});
console.log(`${pc.green('✓')} Created criterion ${pc.bold((result as any).id)}`);
},
);
criterion
.command('delete <id>')
.description('Delete a criterion')
.option('--yes', 'Skip confirmation')
.action(async (id: string, options: { yes?: boolean }) => {
if (!options.yes && !(await confirm(`Delete criterion ${id}?`)))
return void console.log('Cancelled.');
const client = await getTrpcClient();
await client.verify.deleteCriterion.mutate({ id });
console.log(`${pc.green('✓')} Deleted criterion ${pc.bold(id)}`);
});
// ════════════ rubrics ════════════
const rubric = verify.command('rubric').description('Named groups of criteria');
rubric
.command('list')
.description('List rubrics')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (options: { json?: boolean | string }) => {
const client = await getTrpcClient();
const items = await client.verify.listRubrics.query();
if (options.json !== undefined) {
outputJson(items, typeof options.json === 'string' ? options.json : undefined);
return;
}
if (items.length === 0) return void console.log('No rubrics found.');
printTable(
items.map((r) => [
r.id,
truncate(r.title, 60),
truncate(r.description || '', 60),
r.updatedAt ? timeAgo(r.updatedAt) : '',
]),
['ID', 'TITLE', 'DESCRIPTION', 'UPDATED'],
);
});
rubric
.command('create')
.description('Create a rubric')
.requiredOption('-t, --title <title>', 'Rubric title')
.option('-d, --description <text>', 'Rubric description')
.option('--max-repair-rounds <n>', 'Cap on automatic repair rounds (0-5)')
.action(async (options: { description?: string; maxRepairRounds?: string; title: string }) => {
const client = await getTrpcClient();
const result = await client.verify.createRubric.mutate({
config:
options.maxRepairRounds !== undefined
? { maxRepairRounds: Number(options.maxRepairRounds) }
: undefined,
description: options.description,
title: options.title,
});
console.log(`${pc.green('✓')} Created rubric ${pc.bold((result as any).id)}`);
});
rubric
.command('view <id>')
.description('Show a rubric and its run-policy config')
.option('--json [fields]', 'Output JSON')
.action(async (id: string, options: { json?: boolean | string }) => {
const client = await getTrpcClient();
const item = await client.verify.getRubric.query({ id });
if (!item) return void log.error('Rubric not found.');
if (options.json !== undefined) {
outputJson(item, typeof options.json === 'string' ? options.json : undefined);
return;
}
console.log(`${pc.bold('ID')} ${item.id}`);
console.log(`${pc.bold('Title')} ${item.title}`);
if (item.description) console.log(`${pc.bold('Description')} ${item.description}`);
const maxRepairRounds = (item.config as { maxRepairRounds?: number } | null)?.maxRepairRounds;
console.log(`${pc.bold('Repair rounds')} ${maxRepairRounds ?? pc.dim('default')}`);
});
rubric
.command('update <id>')
.description('Update a rubric (title / description / run-policy config)')
.option('-t, --title <title>', 'New title')
.option('-d, --description <text>', 'New description')
.option('--max-repair-rounds <n>', 'Cap on automatic repair rounds (0-5)')
.action(
async (
id: string,
options: { description?: string; maxRepairRounds?: string; title?: string },
) => {
const client = await getTrpcClient();
const value: {
config?: { maxRepairRounds?: number };
description?: string;
title?: string;
} = {};
if (options.title !== undefined) value.title = options.title;
if (options.description !== undefined) value.description = options.description;
if (options.maxRepairRounds !== undefined)
value.config = { maxRepairRounds: Number(options.maxRepairRounds) };
await client.verify.updateRubric.mutate({ id, value });
console.log(`${pc.green('✓')} Updated rubric ${pc.bold(id)}`);
},
);
rubric
.command('delete <id>')
.description('Delete a rubric')
.option('--yes', 'Skip confirmation')
.action(async (id: string, options: { yes?: boolean }) => {
if (!options.yes && !(await confirm(`Delete rubric ${id}?`)))
return void console.log('Cancelled.');
const client = await getTrpcClient();
await client.verify.deleteRubric.mutate({ id });
console.log(`${pc.green('✓')} Deleted rubric ${pc.bold(id)}`);
});
rubric
.command('criteria <rubricId>')
.description('List criteria in a rubric')
.option('--json [fields]', 'Output JSON')
.action(async (rubricId: string, options: { json?: boolean | string }) => {
const client = await getTrpcClient();
const items = await client.verify.getRubricCriteria.query({ rubricId });
if (options.json !== undefined) {
outputJson(items, typeof options.json === 'string' ? options.json : undefined);
return;
}
if (items.length === 0) return void console.log('No criteria in this rubric.');
printTable(
items.map((c: any) => [
c.id,
truncate(c.title, 60),
c.verifierType,
c.required ? 'gate' : 'soft',
]),
['ID', 'TITLE', 'TYPE', 'BLOCK'],
);
});
rubric
.command('set-criteria <rubricId> <criterionIds...>')
.description('Set the criteria a rubric aggregates (order preserved)')
.action(async (rubricId: string, criterionIds: string[]) => {
const client = await getTrpcClient();
await client.verify.setRubricCriteria.mutate({
criteria: criterionIds.map((criterionId, i) => ({ criterionId, sortOrder: i })),
rubricId,
});
console.log(
`${pc.green('✓')} Rubric ${pc.bold(rubricId)} now has ${criterionIds.length} criterion(s)`,
);
});
// ════════════ per-run plan ════════════
const plan = verify.command('plan').description('Per-run check plan lifecycle');
plan
.command('generate <operationId>')
.description('Generate a draft check plan for a run')
.requiredOption('--goal <goal>', "The run's task/instruction the plan must satisfy")
.option('--rubric <id>', 'Mounted rubric id')
.option('--criteria <ids>', 'Ad-hoc criterion ids (comma-separated)')
.option('--ai', 'Let the LLM propose additional criteria')
.option('--max-ai <n>', 'Max AI-proposed criteria')
.option('--model <model>', 'Model (required with --ai)')
.option('--provider <provider>', 'Provider (required with --ai)')
.option('--context <text>', 'Extra context for the AI prompt')
.option('--json [fields]', 'Output JSON')
.action(
async (
operationId: string,
options: {
ai?: boolean;
context?: string;
criteria?: string;
goal: string;
json?: boolean | string;
maxAi?: string;
model?: string;
provider?: string;
rubric?: string;
},
) => {
if (options.ai && (!options.model || !options.provider)) {
log.error('--ai requires --model and --provider');
process.exit(1);
}
const client = await getTrpcClient();
const items = await client.verify.generateDraftPlan.mutate({
context: options.context,
enableAiGeneration: options.ai,
goal: options.goal,
maxAiCriteria: options.maxAi ? Number.parseInt(options.maxAi, 10) : undefined,
modelConfig:
options.model && options.provider
? { model: options.model, provider: options.provider }
: undefined,
operationId,
verifyCriteriaIds: options.criteria
?.split(',')
.map((s) => s.trim())
.filter(Boolean),
verifyRubricId: options.rubric ?? null,
});
if (options.json !== undefined) {
outputJson(items, typeof options.json === 'string' ? options.json : undefined);
return;
}
console.log(`${pc.green('✓')} Draft plan: ${pc.bold(String(items.length))} item(s)`);
printTable(
items.map((i: any) => [
String(i.index),
truncate(i.title, 60),
i.verifierType,
i.required ? 'gate' : 'soft',
]),
['#', 'TITLE', 'TYPE', 'BLOCK'],
);
},
);
plan
.command('state <operationId>')
.description('Show the verify state (status + frozen plan) of a run')
.option('--json [fields]', 'Output JSON')
.action(async (operationId: string, options: { json?: boolean | string }) => {
const client = await getTrpcClient();
const state = await client.verify.getVerifyState.query({ operationId });
if (options.json !== undefined) {
outputJson(state, typeof options.json === 'string' ? options.json : undefined);
return;
}
if (!state) return void console.log('No verify state for this run.');
console.log(`${pc.bold('status')}: ${state.verifyStatus ?? pc.dim('(none)')}`);
console.log(
`${pc.bold('confirmed')}: ${state.verifyPlanConfirmedAt ? timeAgo(state.verifyPlanConfirmedAt) : pc.dim('no')}`,
);
const items = (state.verifyPlan ?? []) as any[];
console.log(`${pc.bold('plan')}: ${items.length} item(s)`);
if (items.length > 0)
printTable(
items.map((i) => [
String(i.index),
truncate(i.title, 60),
i.verifierType,
i.required ? 'gate' : 'soft',
]),
['#', 'TITLE', 'TYPE', 'BLOCK'],
);
});
plan
.command('confirm <operationId>')
.description('Freeze (confirm) the draft plan')
.action(async (operationId: string) => {
const client = await getTrpcClient();
await client.verify.confirmPlan.mutate({ operationId });
console.log(`${pc.green('✓')} Confirmed plan for run ${pc.bold(operationId)}`);
});
plan
.command('skip <operationId>')
.description('Skip verification for a run')
.action(async (operationId: string) => {
const client = await getTrpcClient();
await client.verify.skipPlan.mutate({ operationId });
console.log(`${pc.green('✓')} Skipped verification for run ${pc.bold(operationId)}`);
});
// ════════════ run / results ════════════
verify
.command('run <operationId>')
.description('Execute the confirmed plan against a deliverable (LLM judge)')
.requiredOption('--goal <goal>', "The run's task")
.requiredOption('--deliverable <text>', 'The output to judge')
.requiredOption('--model <model>', 'Judge model')
.requiredOption('--provider <provider>', 'Judge provider')
.option('--no-batch', 'Judge each item separately instead of one batched call')
.option('--json [fields]', 'Output JSON')
.action(
async (
operationId: string,
options: {
batch?: boolean;
deliverable: string;
goal: string;
json?: boolean | string;
model: string;
provider: string;
},
) => {
const client = await getTrpcClient();
const results = await client.verify.executeVerify.mutate({
batchLlm: options.batch,
deliverable: options.deliverable,
goal: options.goal,
modelConfig: { model: options.model, provider: options.provider },
operationId,
});
if (options.json !== undefined) {
outputJson(results, typeof options.json === 'string' ? options.json : undefined);
return;
}
printResults(results);
},
);
verify
.command('results <operationId>')
.description('List check results for a run')
.option('--json [fields]', 'Output JSON')
.action(async (operationId: string, options: { json?: boolean | string }) => {
const client = await getTrpcClient();
const results = await client.verify.listResults.query({ operationId });
if (options.json !== undefined) {
outputJson(results, typeof options.json === 'string' ? options.json : undefined);
return;
}
if (results.length === 0) return void console.log('No results yet.');
printResults(results);
});
// ════════════ feedback ════════════
verify
.command('decision <resultId> <decision>')
.description(`Record human feedback on a result (${DECISIONS.join('|')})`)
.action(async (resultId: string, decision: Decision) => {
assertEnum(decision, DECISIONS, 'decision');
const client = await getTrpcClient();
await client.verify.submitDecision.mutate({ decision, resultId });
console.log(`${pc.green('✓')} Recorded ${pc.bold(decision)} on result ${pc.bold(resultId)}`);
});
}
function printResults(results: any[]): void {
printTable(
results.map((r) => [
truncate(r.checkItemTitle || r.checkItemId, 50),
statusColor(r.status),
r.verdict ?? '',
r.confidence != null ? String(r.confidence) : '',
r.required ? 'gate' : 'soft',
truncate(r.suggestion || '', 40),
]),
['CHECK', 'STATUS', 'VERDICT', 'CONF', 'BLOCK', 'SUGGESTION'],
);
}
function statusColor(status: string): string {
if (status === 'passed') return pc.green(status);
if (status === 'failed') return pc.red(status);
if (status === 'running') return pc.yellow(status);
return pc.dim(status);
}
-40
View File
@@ -1,40 +0,0 @@
import os from 'node:os';
import type { DeviceIdentity } from '@lobechat/device-identity';
import { deriveDeviceId } from '@lobechat/device-identity';
import { createLambdaClient } from '../api/client';
/**
* Resolve a stable device identity. An explicit `--device-id` wins (lets a user
* pin a VM to a fixed identity); otherwise derive from the machine id so the
* same machine + user maps to one device across reconnects. Returns undefined
* when neither an explicit id nor a userId is available.
*/
export function resolveDeviceIdentity(
userId: string | undefined,
explicitDeviceId?: string,
): DeviceIdentity | undefined {
if (explicitDeviceId) return { deviceId: explicitDeviceId, identitySource: 'fallback' };
if (userId) return deriveDeviceId(userId);
return undefined;
}
/**
* Register this device in the server registry. Shared by `lh login` (so the
* device row exists right after auth) and `lh connect` (so the row exists
* before the WS opens). Best-effort by contract: callers should wrap this in a
* try/catch and treat any failure as non-fatal.
*/
export async function registerDevice(
auth: { serverUrl: string; token: string; tokenType: 'apiKey' | 'jwt' | 'serviceToken' },
identity: DeviceIdentity,
): Promise<void> {
const trpc = createLambdaClient(auth);
await trpc.device.register.mutate({
deviceId: identity.deviceId,
hostname: os.hostname(),
identitySource: identity.identitySource,
platform: process.platform,
});
}
-4
View File
@@ -4,7 +4,6 @@ import { Command } from 'commander';
import { registerAgentCommand } from './commands/agent';
import { registerAgentGroupCommand } from './commands/agent-group';
import { registerAgentSignalCommand } from './commands/agent-signal';
import { registerBotCommand } from './commands/bot';
import { registerCompletionCommand } from './commands/completion';
import { registerConfigCommand } from './commands/config';
@@ -34,7 +33,6 @@ import { registerTaskCommand } from './commands/task';
import { registerThreadCommand } from './commands/thread';
import { registerTopicCommand } from './commands/topic';
import { registerUserCommand } from './commands/user';
import { registerVerifyCommand } from './commands/verify';
const require = createRequire(import.meta.url);
const { version } = require('../package.json');
@@ -60,7 +58,6 @@ export function createProgram() {
registerMemoryCommand(program);
registerAgentCommand(program);
registerAgentGroupCommand(program);
registerAgentSignalCommand(program);
registerBotCommand(program);
registerGenerateCommand(program);
registerFileCommand(program);
@@ -76,7 +73,6 @@ export function createProgram() {
registerProviderCommand(program);
registerPluginCommand(program);
registerUserCommand(program);
registerVerifyCommand(program);
registerConfigCommand(program);
registerEvalCommand(program);
registerMigrateCommand(program);
+22 -34
View File
@@ -3,7 +3,6 @@ import { mkdir, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { ShellProcessManager } from '@lobechat/local-file-shell';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { executeToolCall } from './index';
@@ -28,17 +27,15 @@ describe('executeToolCall', () => {
fs.rmSync(tmpDir, { force: true, recursive: true });
});
it('should dispatch readFile with formatted content and structured state', async () => {
it('should dispatch readFile', async () => {
const filePath = path.join(tmpDir, 'test.txt');
await writeFile(filePath, 'hello world');
const result = await executeToolCall('readFile', JSON.stringify({ path: filePath }));
expect(result.success).toBe(true);
// content is now the formatted prompt text, not raw JSON
expect(result.content).toContain('hello world');
// structured payload travels in `state` for client renders
expect((result.state as { content: string }).content).toContain('hello world');
const parsed = JSON.parse(result.content);
expect(parsed.content).toContain('hello world');
});
it('should dispatch writeFile', async () => {
@@ -50,7 +47,6 @@ describe('executeToolCall', () => {
);
expect(result.success).toBe(true);
expect((result.state as { path: string }).path).toBe(filePath);
expect(fs.readFileSync(filePath, 'utf8')).toBe('written');
});
@@ -61,7 +57,8 @@ describe('executeToolCall', () => {
const result = await executeToolCall('readLocalFile', JSON.stringify({ path: filePath }));
expect(result.success).toBe(true);
expect((result.state as { content: string }).content).toContain('legacy hello');
const parsed = JSON.parse(result.content);
expect(parsed.content).toContain('legacy hello');
});
it('should dispatch runCommand', async () => {
@@ -71,9 +68,8 @@ describe('executeToolCall', () => {
);
expect(result.success).toBe(true);
expect(result.content).toContain('dispatched');
const state = result.state as { output?: string; stdout?: string };
expect(state.stdout ?? state.output).toContain('dispatched');
const parsed = JSON.parse(result.content);
expect(parsed.stdout).toContain('dispatched');
});
it('should dispatch listFiles', async () => {
@@ -82,7 +78,8 @@ describe('executeToolCall', () => {
const result = await executeToolCall('listFiles', JSON.stringify({ path: tmpDir }));
expect(result.success).toBe(true);
expect((result.state as { totalCount: number }).totalCount).toBeGreaterThan(0);
const parsed = JSON.parse(result.content);
expect(parsed.totalCount).toBeGreaterThan(0);
});
it('should dispatch globFiles', async () => {
@@ -94,7 +91,8 @@ describe('executeToolCall', () => {
);
expect(result.success).toBe(true);
expect((result.state as { files: string[] }).files).toContain('test.ts');
const parsed = JSON.parse(result.content);
expect(parsed.files).toContain('test.ts');
});
it('should dispatch editFile', async () => {
@@ -111,7 +109,6 @@ describe('executeToolCall', () => {
);
expect(result.success).toBe(true);
expect((result.state as { replacements: number }).replacements).toBeGreaterThan(0);
expect(fs.readFileSync(filePath, 'utf8')).toBe('new content');
});
@@ -122,15 +119,19 @@ describe('executeToolCall', () => {
expect(result.error).toContain('Unknown tool API');
});
it('should carry structured state on file reads', async () => {
it('should handle tool that returns a string result', async () => {
// runCommand returns an object, but we test the string branch by mocking
// Actually, none of the tools return plain strings, so the JSON.stringify branch
// is always taken. The string check is for future-proofing.
// Let's verify the JSON output path
const filePath = path.join(tmpDir, 'str.txt');
await writeFile(filePath, 'content');
const result = await executeToolCall('readFile', JSON.stringify({ path: filePath }));
expect(result.success).toBe(true);
expect(result.state).toBeDefined();
expect(typeof result.content).toBe('string');
// Result should be valid JSON
expect(() => JSON.parse(result.content)).not.toThrow();
});
it('should return error for invalid JSON arguments', async () => {
@@ -149,7 +150,6 @@ describe('executeToolCall', () => {
);
expect(result.success).toBe(true);
expect(result.state).toBeDefined();
});
it('should dispatch searchFiles', async () => {
@@ -161,7 +161,6 @@ describe('executeToolCall', () => {
);
expect(result.success).toBe(true);
expect(result.state).toBeDefined();
});
it('should dispatch getCommandOutput', async () => {
@@ -170,21 +169,9 @@ describe('executeToolCall', () => {
JSON.stringify({ shell_id: 'nonexistent' }),
);
// The runtime envelopes a failed lookup as success:true with the failure in state
expect(result.success).toBe(true);
expect((result.state as { success: boolean }).success).toBe(false);
});
it('should forward the gateway timeout to getCommandOutput polling', async () => {
const spy = vi
.spyOn(ShellProcessManager.prototype, 'getOutput')
.mockResolvedValue({ exit_code: 0, output: '', stderr: '', stdout: '', success: true });
// 3rd arg is the gateway per-call timeout; executeToolCall injects it into args
await executeToolCall('getCommandOutput', JSON.stringify({ shell_id: 'sid' }), 5000);
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ shell_id: 'sid', timeout: 5000 }));
spy.mockRestore();
const parsed = JSON.parse(result.content);
expect(parsed.success).toBe(false);
});
it('should dispatch killCommand', async () => {
@@ -194,6 +181,7 @@ describe('executeToolCall', () => {
);
expect(result.success).toBe(true);
expect((result.state as { success: boolean }).success).toBe(false);
const parsed = JSON.parse(result.content);
expect(parsed.success).toBe(false);
});
});
+36 -39
View File
@@ -1,19 +1,41 @@
import { log } from '../utils/logger';
import { checkPlatformCapability } from './checkPlatformCapability';
import {
editLocalFile,
globLocalFiles,
grepContent,
listLocalFiles,
readLocalFile,
searchLocalFiles,
writeLocalFile,
} from './file';
import { getAgentProfile } from './getAgentProfile';
import { cancelHeteroTask, runHeteroTask } from './heteroTask';
import { runLocalSystemTool } from './localSystemRuntime';
import { getCommandOutput, killCommand, runCommand } from './shell';
/**
* CLI-only tools (platform agents). File/shell tools are handled separately by
* {@link runLocalSystemTool}, which routes them through
* `LocalSystemExecutionRuntime` so the result carries structured `state`.
*/
const methodMap: Record<string, (args: any) => Promise<unknown>> = {
cancelHeteroTask,
checkPlatformCapability,
getAgentProfile,
editFile: editLocalFile,
getCommandOutput,
globFiles: globLocalFiles,
grepContent,
killCommand,
listFiles: listLocalFiles,
readFile: readLocalFile,
runCommand,
runHeteroTask,
searchFiles: searchLocalFiles,
writeFile: writeLocalFile,
// Legacy aliases — older Gateway versions may still send the long form
editLocalFile,
globLocalFiles,
listLocalFiles,
readLocalFile,
searchLocalFiles,
writeLocalFile,
};
export async function executeToolCall(
@@ -23,44 +45,19 @@ export async function executeToolCall(
): Promise<{
content: string;
error?: string;
state?: unknown;
success: boolean;
}> {
let args: Record<string, any>;
try {
args = JSON.parse(argsStr);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
log.error(`Tool call failed: ${apiName} - ${errorMsg}`);
return { content: '', error: errorMsg, success: false };
const handler = methodMap[apiName];
if (!handler) {
return { content: '', error: `Unknown tool API: ${apiName}`, success: false };
}
const finalArgs =
typeof timeout === 'number' && Number.isFinite(timeout) && !('timeout' in args)
? { ...args, timeout }
: args;
try {
// File/shell tools route through LocalSystemExecutionRuntime so `content` is
// the formatted prompt text and `state` carries the structured payload for
// client renders — matching the desktop gateway path (PR #15114).
const localResult = await runLocalSystemTool(apiName, finalArgs);
if (localResult) {
const { error } = localResult;
return {
content: localResult.content,
error:
error instanceof Error ? error.message : typeof error === 'string' ? error : undefined,
state: localResult.state,
success: localResult.success,
};
}
// CLI-only tools return raw domain payloads, serialized into `content`.
const handler = methodMap[apiName];
if (!handler) {
return { content: '', error: `Unknown tool API: ${apiName}`, success: false };
}
const args = JSON.parse(argsStr);
const finalArgs =
typeof timeout === 'number' && Number.isFinite(timeout) && !('timeout' in args)
? { ...args, timeout }
: args;
const result = await handler(finalArgs);
const content = typeof result === 'string' ? result : JSON.stringify(result);
-197
View File
@@ -1,197 +0,0 @@
import path from 'node:path';
import type {
EditFileParams,
GetCommandOutputParams,
GlobFilesParams,
GrepContentParams,
KillCommandParams,
ListFilesParams,
ReadFileParams,
RunCommandParams,
SearchFilesParams,
WriteFileParams,
} from '@lobechat/local-file-shell';
import { type ILocalSystemService, LocalSystemExecutionRuntime } from '@lobechat/tool-runtime';
import {
editLocalFile,
globLocalFiles,
grepContent,
listLocalFiles,
readLocalFile,
searchLocalFiles,
writeLocalFile,
} from './file';
import { getCommandOutput, killCommand, runCommand } from './shell';
/**
* Output envelope produced by {@link runLocalSystemTool}. Mirrors
* `@lobechat/types`' `BuiltinServerRuntimeOutput`: `content` is the formatted
* prompt text fed to the LLM, while `state` carries the structured payload that
* client renders consume as `pluginState`.
*/
export interface LocalSystemToolOutput {
content: string;
error?: unknown;
state?: unknown;
success: boolean;
}
/**
* Stub for `ILocalSystemService` methods the CLI does not expose (batch read,
* move, rename). These are never routed by {@link runLocalSystemTool}; the
* interface just requires them, so we fail loudly if one is ever reached.
*/
const unsupported = (method: string) => (): Promise<never> =>
Promise.reject(new Error(`${method} is not supported by the LobeHub CLI`));
/**
* Adapter wiring the CLI's `@lobechat/local-file-shell` functions (file ops) and
* shell wrappers (with the shared `ShellProcessManager`) into the shape the
* runtime expects. The runtime denormalizes its camelCase params back to the
* snake_case IPC shapes these functions consume — see `LocalSystemExecutionRuntime`.
*/
const localSystemService: ILocalSystemService = {
editLocalFile,
getCommandOutput,
globFiles: globLocalFiles,
grepContent,
killCommand,
listLocalFiles,
moveLocalFiles: unsupported('moveLocalFiles'),
readLocalFile,
readLocalFiles: unsupported('readLocalFiles'),
renameLocalFile: unsupported('renameLocalFile'),
runCommand,
searchLocalFiles,
writeFile: writeLocalFile,
};
const runtime = new LocalSystemExecutionRuntime(localSystemService);
/**
* Legacy API name aliases used by older gateway versions. Normalized to the
* current tool names before dispatch.
*/
const LEGACY_API_ALIASES: Record<string, string> = {
editLocalFile: 'editFile',
globLocalFiles: 'globFiles',
listLocalFiles: 'listFiles',
readLocalFile: 'readFile',
searchLocalFiles: 'searchFiles',
writeLocalFile: 'writeFile',
};
/**
* Resolve a relative path against a scope (CWD). Mirrors the desktop gateway's
* inline copy of the renderer-side `resolveArgsWithScope` helper so the CLI and
* desktop produce identical scoping for search/grep tools.
*/
const resolveArgsWithScope = <T extends { scope?: string }>(args: T, pathField: string): T => {
const scope = args.scope;
const bag = args as Record<PropertyKey, unknown>;
const currentPath = typeof bag[pathField] === 'string' ? (bag[pathField] as string) : undefined;
if (!scope) return args;
if (!currentPath) return { ...args, [pathField]: scope };
if (path.isAbsolute(currentPath)) return args;
return { ...args, [pathField]: path.join(scope, currentPath) };
};
/**
* Route file/shell tool calls through `LocalSystemExecutionRuntime` so the
* result carries structured `state` (for client renders) and `content` is the
* formatted prompt text — matching the desktop gateway path (PR #15114).
*
* Returns `null` when `apiName` is not a local-system tool, so the caller can
* fall back to CLI-only tools (platform agents).
*/
export async function runLocalSystemTool(
apiName: string,
args: Record<string, any>,
): Promise<LocalSystemToolOutput | null> {
const normalized = LEGACY_API_ALIASES[apiName] ?? apiName;
switch (normalized) {
case 'listFiles': {
const p = args as ListFilesParams;
return runtime.listFiles({
directoryPath: p.path,
limit: p.limit,
sortBy: p.sortBy,
sortOrder: p.sortOrder,
} as never);
}
case 'readFile': {
const p = args as ReadFileParams;
return runtime.readFile({
endLine: p.loc?.[1],
path: p.path,
startLine: p.loc?.[0],
});
}
case 'writeFile': {
return runtime.writeFile(args as WriteFileParams);
}
case 'editFile': {
const p = args as EditFileParams;
return runtime.editFile({
all: p.replace_all,
path: p.file_path,
replace: p.new_string,
search: p.old_string,
});
}
case 'searchFiles': {
const resolved = resolveArgsWithScope(
args as SearchFilesParams & { scope?: string },
'directory',
);
return runtime.searchFiles({ ...resolved, directory: resolved.directory || '' } as never);
}
case 'grepContent': {
const resolved = resolveArgsWithScope(args as GrepContentParams, 'path');
return runtime.grepContent(resolved as never);
}
case 'globFiles': {
const p = args as GlobFilesParams;
// Honor both `scope` (current manifest) and the `cwd` legacy alias.
return runtime.globFiles({ directory: p.scope ?? p.cwd, pattern: p.pattern });
}
case 'runCommand': {
// ComputerRuntime's RunCommandState reads `args.background`; the manifest
// exposes `run_in_background`. Without this normalize the state would
// always show foreground even for background commands.
const p = args as RunCommandParams;
return runtime.runCommand({ ...p, background: p.run_in_background } as never);
}
case 'getCommandOutput': {
// Forward `timeout` (gateway per-call budget, injected into args by
// executeToolCall) so polling a running command honors it instead of the
// service's default wait. The runtime carries it through to getOutput.
const p = args as GetCommandOutputParams;
return runtime.getCommandOutput({
commandId: p.shell_id,
filter: p.filter,
timeout: p.timeout,
} as never);
}
case 'killCommand': {
const p = args as KillCommandParams;
return runtime.killCommand({ commandId: p.shell_id });
}
default: {
return null;
}
}
}
+1 -5
View File
@@ -296,11 +296,7 @@ export async function streamAgentEventsViaWebSocket(
console.log(JSON.stringify(jsonEvents, null, 2));
}
isSettled = true;
// Surface the close code + reason — `String(event)` is just "[object CloseEvent]".
const reason = event.reason ? `: ${event.reason}` : '';
reject(
new Error(`Agent gateway WebSocket closed before completion (code ${event.code}${reason})`),
);
reject(new Error(`Agent gateway WebSocket closed before completion: ${String(event)}`));
};
});
}
-1
View File
@@ -15,7 +15,6 @@
"paths": {
"@lobechat/device-gateway-client": ["../../packages/device-gateway-client/src"],
"@lobechat/local-file-shell": ["../../packages/local-file-shell/src"],
"@lobechat/tool-runtime": ["../../packages/tool-runtime/src"],
"@/*": ["../../src/*"]
}
},
-4
View File
@@ -17,10 +17,6 @@ export default defineConfig({
find: '@lobechat/file-loaders',
replacement: path.resolve(__dirname, '../../packages/file-loaders/src/index.ts'),
},
{
find: '@lobechat/tool-runtime',
replacement: path.resolve(__dirname, '../../packages/tool-runtime/src/index.ts'),
},
],
},
test: {
-17
View File
@@ -223,22 +223,5 @@ export default defineConfig({
dedupe: ['react', 'react-dom'],
tsconfigPaths: true,
},
// In dev the BrowserWindow loads `app://renderer/` and the Electron main process
// proxies non-backend requests to this Vite dev server via `net.fetch`. The HMR
// WebSocket still connects directly (browser → ws://localhost:<port>) — so the
// port MUST be deterministic. `strictPort` fails fast on conflict instead of
// silently sliding, and `clientPort` baked into the HMR injection has to match.
server: {
hmr: {
clientPort: 5173,
host: '127.0.0.1',
protocol: 'ws',
},
// Force IPv4 so main-process `fetch` skips happy-eyeballs dual-stack
// attempts that surface as ETIMEDOUT under cold-start request bursts.
host: '127.0.0.1',
port: 5173,
strictPort: true,
},
},
});
+2 -9
View File
@@ -68,16 +68,9 @@
if (resolvedTheme === 'dark' || resolvedTheme === 'light') {
document.documentElement.setAttribute('data-theme', resolvedTheme);
}
// Renderer-side reloads (Cmd+R / webContents.reload) don't go through
// the main process's `?lng=` injection, so prefer the i18next cache —
// the actual user setting persisted by the language switcher — before
// falling back to the URL param or navigator detection.
// Check URL query parameter for locale (set by Electron main process from stored settings)
var urlParams = new URLSearchParams(window.location.search);
var locale;
try {
locale = localStorage.getItem('i18nextLng');
} catch (_) {}
if (!locale) locale = urlParams.get('lng') || navigator.language || 'en-US';
var locale = urlParams.get('lng') || navigator.language || 'en-US';
document.documentElement.lang = locale;
var rtl = ['ar', 'arc', 'dv', 'fa', 'ha', 'he', 'khw', 'ks', 'ku', 'ps', 'ur', 'yi'];
document.documentElement.dir =
+3 -6
View File
@@ -63,7 +63,6 @@
"@lobechat/file-loaders": "workspace:*",
"@lobechat/heterogeneous-agents": "workspace:*",
"@lobechat/local-file-shell": "workspace:*",
"@lobechat/tool-runtime": "workspace:*",
"@lobehub/i18n-cli": "^1.25.1",
"@modelcontextprotocol/sdk": "^1.24.3",
"@t3-oss/env-core": "^0.13.8",
@@ -78,7 +77,7 @@
"cross-env": "^10.1.0",
"diff": "^8.0.4",
"electron": "41.3.0",
"electron-builder": "26.14.0",
"electron-builder": "^26.8.1",
"electron-devtools-installer": "4.0.0",
"electron-is": "^3.0.0",
"electron-store": "^8.2.0",
@@ -111,7 +110,7 @@
"undici": "^7.16.0",
"uuid": "^14.0.0",
"vite": "8.0.14",
"vitest": "3.2.4",
"vitest": "^3.2.4",
"zod": "^3.25.76"
},
"optionalDependencies": {
@@ -125,10 +124,8 @@
"node-mac-permissions"
],
"overrides": {
"node-gyp": "^12.4.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"vitest": "3.2.4"
"react-dom": "19.2.4"
}
}
}
-2
View File
@@ -11,8 +11,6 @@ packages:
- '../../packages/device-gateway-client'
- '../../packages/device-identity'
- '../../packages/local-file-shell'
- '../../packages/tool-runtime'
- '../../packages/prompts'
- './stubs/business-const'
- './stubs/types'
- '.'
-58
View File
@@ -1,58 +0,0 @@
import { describe, expect, it } from 'vitest';
import { fromIpcErrorEnvelope, isIpcErrorEnvelope, toIpcErrorEnvelope } from './ipcError';
describe('ipcError envelope', () => {
it('round-trips an Error and preserves its cause + code', () => {
const cause = Object.assign(new Error('getaddrinfo ENOTFOUND example.com'), {
code: 'ENOTFOUND',
});
const error = new TypeError('fetch failed', { cause });
const envelope = toIpcErrorEnvelope(error);
expect(isIpcErrorEnvelope(envelope)).toBe(true);
const revived = fromIpcErrorEnvelope(envelope);
expect(revived).toBeInstanceOf(Error);
expect(revived.name).toBe('TypeError');
expect(revived.message).toBe('fetch failed');
const revivedCause = revived.cause as Error & { code?: unknown };
expect(revivedCause).toBeInstanceOf(Error);
expect(revivedCause.message).toBe('getaddrinfo ENOTFOUND example.com');
expect(revivedCause.code).toBe('ENOTFOUND');
});
it('is clone-safe: the envelope survives structuredClone (the IPC boundary)', () => {
const error = new Error('boom', { cause: new Error('root') });
const envelope = toIpcErrorEnvelope(error);
const cloned = structuredClone(envelope);
const revived = fromIpcErrorEnvelope(cloned);
expect(revived.message).toBe('boom');
expect((revived.cause as Error).message).toBe('root');
});
it('handles non-Error thrown values', () => {
const revived = fromIpcErrorEnvelope(toIpcErrorEnvelope('plain string failure'));
expect(revived.message).toBe('plain string failure');
});
it('caps a deep / cyclic cause chain instead of recursing forever', () => {
const a = new Error('a');
const b = new Error('b', { cause: a });
(a as { cause?: unknown }).cause = b; // cycle
// Should not throw (stack overflow) — depth is bounded.
expect(() => toIpcErrorEnvelope(b)).not.toThrow();
});
it('isIpcErrorEnvelope rejects plain values and look-alikes', () => {
expect(isIpcErrorEnvelope(null)).toBe(false);
expect(isIpcErrorEnvelope(undefined)).toBe(false);
expect(isIpcErrorEnvelope('error')).toBe(false);
expect(isIpcErrorEnvelope({ data: 'ok' })).toBe(false);
expect(isIpcErrorEnvelope({ __lobeIpcError__: false })).toBe(false);
});
});
-86
View File
@@ -1,86 +0,0 @@
/**
* IPC error envelope.
*
* Electron's `ipcRenderer.invoke` rebuilds a thrown handler error from a
* *string* on the renderer side (roughly `new Error("Error invoking remote
* method '<channel>': " + String(mainError))`), so the original error object —
* including a non-enumerable `cause` never crosses the boundary. The real
* failure reason (e.g. undici's `ENOTFOUND` / `ECONNREFUSED` hidden under a
* generic `TypeError: fetch failed`) is therefore lost.
*
* To preserve it, the main process *returns* a clone-safe envelope (a plain
* object) instead of throwing, and the preload `invoke` wrapper rebuilds a real
* `Error` (with `cause`) from the envelope before re-throwing keeping the
* existing "promise rejects on failure" contract for every caller.
*/
const IPC_ERROR_MARKER = '__lobeIpcError__';
/** Bound recursion on a deliberately malicious / cyclic `cause` chain. */
const MAX_CAUSE_DEPTH = 5;
export interface SerializedIpcError {
cause?: SerializedIpcError | string;
/** Node/undici machine-readable reason (`ENOTFOUND`, `ECONNREFUSED`, …). */
code?: unknown;
message: string;
name: string;
stack?: string;
}
export interface IpcErrorEnvelope {
error: SerializedIpcError;
[IPC_ERROR_MARKER]: true;
}
const serializeError = (value: unknown, depth: number): SerializedIpcError => {
if (value instanceof Error) {
const serialized: SerializedIpcError = { message: value.message, name: value.name };
if (typeof value.stack === 'string') serialized.stack = value.stack;
const { code } = value as { code?: unknown };
if (code !== undefined) serialized.code = code;
if (value.cause !== undefined && value.cause !== null && depth < MAX_CAUSE_DEPTH) {
serialized.cause =
value.cause instanceof Error ? serializeError(value.cause, depth + 1) : String(value.cause);
}
return serialized;
}
return { message: typeof value === 'string' ? value : String(value), name: 'Error' };
};
/** Build a clone-safe envelope from a thrown value (main process). */
export const toIpcErrorEnvelope = (value: unknown): IpcErrorEnvelope => ({
[IPC_ERROR_MARKER]: true,
error: serializeError(value, 0),
});
/** Detect an envelope produced by {@link toIpcErrorEnvelope} (preload). */
export const isIpcErrorEnvelope = (value: unknown): value is IpcErrorEnvelope =>
typeof value === 'object' &&
value !== null &&
(value as Record<string, unknown>)[IPC_ERROR_MARKER] === true;
const reviveError = (serialized: SerializedIpcError): Error => {
const cause =
serialized.cause === undefined
? undefined
: typeof serialized.cause === 'string'
? serialized.cause
: reviveError(serialized.cause);
const error = new Error(serialized.message, cause === undefined ? undefined : { cause });
error.name = serialized.name;
if (serialized.stack !== undefined) error.stack = serialized.stack;
if (serialized.code !== undefined) (error as { code?: unknown }).code = serialized.code;
return error;
};
/** Rebuild a real `Error` (with `cause`) from an envelope (preload). */
export const fromIpcErrorEnvelope = (envelope: IpcErrorEnvelope): Error =>
reviveError(envelope.error);
+2 -14
View File
@@ -1,16 +1,4 @@
export const ELECTRON_BE_PROTOCOL_SCHEME = 'lobe-backend';
export const LOCAL_FILE_PROTOCOL_SCHEME = 'localfile';
export const LOCAL_FILE_PROTOCOL_HOST = 'file';
/**
* Renderer pathnames that must be proxied to the remote LobeHub backend
* instead of being served as static assets. Covers tRPC, webapi, NextAuth,
* and the marketplace REST + OIDC token/userinfo/handoff endpoints.
*
* `/lobehub-oidc/*` is intentionally NOT here those URLs are handed to
* `shell.openExternal` as fully-qualified web URLs and never reach renderer
* `fetch`.
*/
export const BACKEND_PATH_PREFIXES = ['/trpc', '/webapi', '/api/auth', '/market'];
export const isBackendPath = (pathname: string) =>
BACKEND_PATH_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
+8 -14
View File
@@ -321,9 +321,7 @@ export default class AuthCtr extends ControllerModule {
this.stopAutoRefresh();
await this.remoteServerConfigCtr.clearTokens();
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
this.broadcastAuthorizationRequired(
`auto-refresh:non_retryable ${result.error ?? ''}`.trim(),
);
this.broadcastAuthorizationRequired();
} else {
// For other errors (after retries exhausted), log but don't clear tokens immediately
// The next refresh cycle will retry
@@ -434,7 +432,7 @@ export default class AuthCtr extends ControllerModule {
this.stopAutoRefresh();
await this.remoteServerConfigCtr.clearTokens();
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
this.broadcastAuthorizationRequired(`refresh:non_retryable ${result.error ?? ''}`.trim());
this.broadcastAuthorizationRequired();
} else {
// For transient errors, don't clear tokens - allow manual retry
logger.warn('Refresh failed but error may be transient, tokens preserved for retry');
@@ -452,7 +450,7 @@ export default class AuthCtr extends ControllerModule {
this.stopAutoRefresh();
await this.remoteServerConfigCtr.clearTokens();
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
this.broadcastAuthorizationRequired(`refresh:exception ${errorMessage}`);
this.broadcastAuthorizationRequired();
}
return { error: errorMessage, success: false };
@@ -620,17 +618,15 @@ export default class AuthCtr extends ControllerModule {
}
/**
* Broadcast authorization required event.
* `reason` is a short tag (e.g. `refresh:invalid_grant`, `startup:non_retryable`)
* recorded so the renderer can log why the Session Expired modal appeared.
* Broadcast authorization required event
*/
private broadcastAuthorizationRequired(reason: string) {
logger.info(`Broadcasting authorizationRequired event (reason=${reason})`);
private broadcastAuthorizationRequired() {
logger.debug('Broadcasting authorizationRequired event to all windows');
const allWindows = BrowserWindow.getAllWindows();
for (const win of allWindows) {
if (!win.isDestroyed()) {
win.webContents.send('authorizationRequired', { reason });
win.webContents.send('authorizationRequired');
}
}
}
@@ -755,9 +751,7 @@ export default class AuthCtr extends ControllerModule {
logger.warn('Non-retryable error during proactive refresh, clearing tokens');
await this.remoteServerConfigCtr.clearTokens();
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
this.broadcastAuthorizationRequired(
`startup:non_retryable ${refreshResult.error ?? ''}`.trim(),
);
this.broadcastAuthorizationRequired();
} else {
// For transient errors, still start auto-refresh timer to retry later
logger.warn('Transient error during proactive refresh, will retry via auto-refresh');
@@ -3,41 +3,17 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type {
AgentRunRequestMessage,
GatewayMcpStdioParams,
} from '@lobechat/device-gateway-client';
import type {
EditLocalFileParams,
GatewayConnectionStatus,
GetCommandOutputParams,
GlobFilesParams,
GrepContentParams,
InitWorkspaceParams,
KillCommandParams,
ListLocalFileParams,
ListProjectSkillsParams,
LocalReadFileParams,
LocalReadFilesParams,
LocalSearchFilesParams,
MoveLocalFilesParams,
RenameLocalFileParams,
RunCommandParams,
WriteLocalFileParams,
} from '@lobechat/electron-client-ipc';
import { type ILocalSystemService, LocalSystemExecutionRuntime } from '@lobechat/tool-runtime';
import type { AgentRunRequestMessage } from '@lobechat/device-gateway-client';
import type { GatewayConnectionStatus } from '@lobechat/electron-client-ipc';
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
import ImessageBridgeService from '@/services/imessageBridgeSrv';
import GitCtr from './GitCtr';
import HeterogeneousAgentCtr from './HeterogeneousAgentCtr';
import { ControllerModule, IpcMethod } from './index';
import LocalFileCtr from './LocalFileCtr';
import McpCtr from './McpCtr';
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
import ShellCommandCtr from './ShellCommandCtr';
import WorkspaceCtr from './WorkspaceCtr';
/**
* Inject the lh-notify protocol into the first turn of a new hetero-agent session.
@@ -79,62 +55,8 @@ interface PlatformTaskEntry {
topicId: string;
}
/**
* Local mirror of `@lobechat/types`' `BuiltinServerRuntimeOutput`. Inlined
* because the desktop tsconfig doesn't expose `@lobechat/types`, and the shape
* is tiny + stable.
*/
interface BuiltinServerRuntimeOutput {
content: string;
error?: unknown;
state?: unknown;
success: boolean;
}
/**
* Legacy API name aliases used by older gateway versions. Normalized to the
* current `LocalSystemApiEnum` names before dispatch. `renameLocalFile` is
* intentionally absent it has no equivalent on the new surface and is
* handled by a dedicated branch below.
*/
const LEGACY_API_ALIASES: Record<string, string> = {
editLocalFile: 'editFile',
globLocalFiles: 'globFiles',
listLocalFiles: 'listFiles',
moveLocalFiles: 'moveFiles',
readLocalFile: 'readFile',
searchLocalFiles: 'searchFiles',
writeLocalFile: 'writeFile',
};
/**
* Parse a JSON string, returning `undefined` on failure. Used to surface the
* structured shape of platform-agent tool results (which return pre-stringified
* JSON) as `state` for the renderer, without crashing on malformed input.
*/
const safeJsonParse = (input: string): unknown => {
try {
return JSON.parse(input);
} catch {
return undefined;
}
};
/**
* Resolve a relative path against a scope (CWD). Mirrors the renderer-side
* `resolveArgsWithScope` helper in `@lobechat/builtin-tool-local-system` kept
* here as a small inline copy to avoid pulling the renderer-side `./client`
* subpath (which transitively requires React + antd) into the main process.
*/
const resolveArgsWithScope = <T extends { scope?: string }>(args: T, pathField: string): T => {
const scope = args.scope;
const bag = args as Record<PropertyKey, unknown>;
const currentPath = typeof bag[pathField] === 'string' ? (bag[pathField] as string) : undefined;
if (!scope) return args;
if (!currentPath) return { ...args, [pathField]: scope };
if (path.isAbsolute(currentPath)) return args;
return { ...args, [pathField]: path.join(scope, currentPath) };
};
type ToolCallHandler = () => Promise<unknown>;
type ToolCallHandlerMap = Record<string, ToolCallHandler>;
/**
* GatewayConnectionCtr
@@ -150,8 +72,6 @@ export default class GatewayConnectionCtr extends ControllerModule {
/** Maps topicId → hermes session_id for multi-turn conversation continuity. */
private readonly hermesSessionMap = new Map<string, string>();
private localSystemRuntime: LocalSystemExecutionRuntime | null = null;
// ─── Service Accessor ───
private get service() {
@@ -166,14 +86,6 @@ export default class GatewayConnectionCtr extends ControllerModule {
return this.app.getController(LocalFileCtr);
}
private get workspaceCtr() {
return this.app.getController(WorkspaceCtr);
}
private get gitCtr() {
return this.app.getController(GitCtr);
}
private get shellCommandCtr() {
return this.app.getController(ShellCommandCtr);
}
@@ -186,10 +98,6 @@ export default class GatewayConnectionCtr extends ControllerModule {
return this.app.getController(HeterogeneousAgentCtr);
}
private get mcpCtr() {
return this.app.getController(McpCtr);
}
// ─── Lifecycle ───
afterAppReady() {
@@ -204,9 +112,6 @@ export default class GatewayConnectionCtr extends ControllerModule {
// Wire up tool call handler
srv.setToolCallHandler((apiName, args) => this.executeToolCall(apiName, args));
// Wire up MCP call handler (tunneled stdio MCP calls from the cloud server)
srv.setMcpCallHandler((mcpCall) => this.executeMcpCall(mcpCall));
// Wire up message API handler
srv.setMessageApiHandler((platform, apiName, payload) =>
this.executeMessageApi(platform, apiName, payload),
@@ -215,10 +120,6 @@ export default class GatewayConnectionCtr extends ControllerModule {
// Wire up agent run handler
srv.setAgentRunHandler((request) => this.executeAgentRun(request));
// Wire up generic device RPC handler (server-internal method forwarding,
// e.g. workspace-init scans — never surfaced to the agent)
srv.setRpcHandler((method, params) => this.executeDeviceRpc(method, params));
// Wire up device registrar (persists this device to the server registry)
srv.setDeviceRegistrar((info) => this.registerDevice(info));
@@ -318,315 +219,21 @@ export default class GatewayConnectionCtr extends ControllerModule {
// ─── Tool Call Routing ───
/**
* Lazy-construct the LocalSystemExecutionRuntime backed by a thin service
* adapter over the existing controllers. The runtime is the same one the
* renderer uses, so remote tool calls produce identical
* `{ content, state, success }` envelopes `content` is the LLM-facing
* prompt text, `state` is the structured payload, both flow downstream
* intact (the gateway / DeviceGateway / RuntimeExecutors paths preserve them
* and write `state` to the tool message's `pluginState`).
*/
private getLocalSystemRuntime(): LocalSystemExecutionRuntime {
if (!this.localSystemRuntime) {
const local = this.localFileCtr;
const shell = this.shellCommandCtr;
const service: ILocalSystemService = {
editLocalFile: (p) => local.handleEditFile(p),
getCommandOutput: (p) => shell.handleGetCommandOutput(p),
globFiles: (p) => local.handleGlobFiles(p),
grepContent: (p) => local.handleGrepContent(p),
killCommand: (p) => shell.handleKillCommand(p),
listLocalFiles: (p) => local.listLocalFiles(p),
moveLocalFiles: (p) => local.handleMoveFiles(p),
readLocalFile: (p) => local.readFile(p),
readLocalFiles: (p) => local.readFiles(p),
renameLocalFile: (p) => local.handleRenameFile(p),
runCommand: (p) => shell.handleRunCommand(p),
searchLocalFiles: (p) => local.handleLocalFilesSearch(p),
writeFile: (p) => local.handleWriteFile(p),
};
this.localSystemRuntime = new LocalSystemExecutionRuntime(service);
private async executeToolCall(apiName: string, args: any): Promise<unknown> {
const methodMap = {
...this.getLocalFileToolHandlers(args),
...this.getShellCommandToolHandlers(args),
...this.getPlatformAgentToolHandlers(args),
} satisfies ToolCallHandlerMap;
const handler = methodMap[apiName];
if (!handler) {
throw new Error(
`Tool "${apiName}" is not available on this device. It may not be supported in the current desktop version. Please skip this tool and try alternative approaches.`,
);
}
return this.localSystemRuntime;
}
/**
* Dispatch a generic server-internal device RPC (not an agent tool call) by
* method name. Currently only `initWorkspace` (scan the bound project root for
* skills + AGENTS.md); add new server-only device methods here.
*/
private async executeDeviceRpc(method: string, params: unknown): Promise<unknown> {
switch (method) {
case 'initWorkspace': {
return this.workspaceCtr.initWorkspace(params as InitWorkspaceParams);
}
case 'getGitBranch': {
return this.gitCtr.getGitBranch((params as { path: string }).path);
}
case 'getLinkedPullRequest': {
return this.gitCtr.getLinkedPullRequest(params as { branch: string; path: string });
}
case 'getGitWorkingTreeStatus': {
return this.gitCtr.getGitWorkingTreeStatus((params as { path: string }).path);
}
case 'getGitAheadBehind': {
return this.gitCtr.getGitAheadBehind((params as { path: string }).path);
}
case 'listGitBranches': {
return this.gitCtr.listGitBranches((params as { path: string }).path);
}
case 'checkoutGitBranch': {
return this.gitCtr.checkoutGitBranch(
params as { branch: string; create?: boolean; path: string },
);
}
case 'pullGitBranch': {
return this.gitCtr.pullGitBranch(params as { path: string });
}
case 'pushGitBranch': {
return this.gitCtr.pushGitBranch(params as { path: string });
}
case 'getGitWorkingTreePatches': {
return this.gitCtr.getGitWorkingTreePatches((params as { path: string }).path);
}
case 'getGitWorkingTreeFiles': {
return this.gitCtr.getGitWorkingTreeFiles((params as { path: string }).path);
}
case 'getProjectFileIndex': {
return this.localFileCtr.getProjectFileIndex(params as { scope?: string });
}
case 'listProjectSkills': {
return this.workspaceCtr.listProjectSkills(params as ListProjectSkillsParams);
}
case 'getGitBranchDiff': {
return this.gitCtr.getGitBranchDiff(params as { baseRef?: string; path: string });
}
case 'listGitRemoteBranches': {
return this.gitCtr.listGitRemoteBranches((params as { path: string }).path);
}
case 'revertGitFile': {
return this.gitCtr.revertGitFile(params as { filePath: string; path: string });
}
case 'statPath': {
return this.workspaceCtr.statPath(params as { path: string });
}
default: {
throw new Error(`Unknown device RPC method: ${method}`);
}
}
}
private async executeToolCall(
apiName: string,
args: unknown,
): Promise<BuiltinServerRuntimeOutput> {
const runtime = this.getLocalSystemRuntime();
const normalized = LEGACY_API_ALIASES[apiName] ?? apiName;
// Each case narrows `args` to its IPC param type — the manifest guarantees
// the gateway sends params matching the apiName. The `as never` casts on
// runtime calls are legitimate widenings: the runtime's typed signatures
// (e.g. `ListFilesParams`) are narrower than what the IPC layer accepts
// (`limit`, `run_in_background`, etc.), and the same casts exist in the
// renderer-side `LocalSystemExecutor`.
switch (normalized) {
case 'listFiles': {
const p = args as ListLocalFileParams;
return runtime.listFiles({
directoryPath: p.path,
limit: p.limit,
sortBy: p.sortBy,
sortOrder: p.sortOrder,
} as never);
}
case 'readFile': {
const p = args as LocalReadFileParams;
return runtime.readFile({
endLine: p.loc?.[1],
path: p.path,
startLine: p.loc?.[0],
});
}
case 'readFiles': {
return runtime.readFiles(args as LocalReadFilesParams);
}
case 'searchFiles': {
const resolved = resolveArgsWithScope(args as LocalSearchFilesParams, 'directory');
return runtime.searchFiles({
...resolved,
directory: resolved.directory || '',
});
}
case 'moveFiles': {
const p = args as MoveLocalFilesParams;
return runtime.moveFiles({
operations: p.items?.map((item) => ({
destination: item.newPath,
source: item.oldPath,
})),
});
}
case 'writeFile': {
return runtime.writeFile(args as WriteLocalFileParams);
}
case 'editFile': {
const p = args as EditLocalFileParams;
return runtime.editFile({
all: p.replace_all,
path: p.file_path,
replace: p.new_string,
search: p.old_string,
});
}
case 'runCommand': {
// ComputerRuntime's RunCommandState reads `args.background`; the manifest
// exposes `run_in_background`. Without this normalize the state would
// always show foreground even for background commands.
const p = args as RunCommandParams;
return runtime.runCommand({
...p,
background: p.run_in_background,
} as never);
}
case 'getCommandOutput': {
const p = args as GetCommandOutputParams;
return runtime.getCommandOutput({
commandId: p.shell_id,
filter: p.filter,
} as never);
}
case 'killCommand': {
const p = args as KillCommandParams;
return runtime.killCommand({
commandId: p.shell_id,
});
}
case 'grepContent': {
const resolved = resolveArgsWithScope(args as GrepContentParams, 'path');
return runtime.grepContent(resolved as never);
}
case 'globFiles': {
const p = args as GlobFilesParams;
return runtime.globFiles({
directory: p.scope,
pattern: p.pattern,
});
}
case 'renameLocalFile': {
// ComputerRuntime has no public rename method — new surface uses
// `moveFiles`. Legacy gateway versions may still emit this name, so we
// call the IPC handler directly and wrap the raw result into the
// BuiltinServerRuntimeOutput shape so `state` still flows downstream.
const raw = await this.localFileCtr.handleRenameFile(args as RenameLocalFileParams);
return {
content: raw.success
? `Renamed to ${raw.newPath}`
: `Rename failed: ${raw.error ?? 'unknown error'}`,
state: raw,
success: raw.success,
};
}
// ─── Platform agent tools (openclaw / hermes) ───
// These don't go through LocalSystemExecutionRuntime — they return raw
// domain payloads that we envelope into BuiltinServerRuntimeOutput here.
// `content` is the JSON-serialized payload (what the LLM reads); `state`
// carries the parsed object so the renderer can render structured UI.
case 'checkPlatformCapability': {
const result = await this.checkPlatformCapability(args as { platform: string });
return { content: JSON.stringify(result), state: result, success: true };
}
case 'getAgentProfile': {
const result = await this.getAgentProfile(args as { agentId?: string; platform: string });
return { content: JSON.stringify(result), state: result, success: true };
}
case 'runHeteroTask': {
// runHeteroTask returns a pre-stringified JSON payload — pass it through
// as `content` and surface the parsed shape as `state`.
const json = await this.runHeteroTask(
args as {
agentId?: string;
agentType: string;
cwd?: string;
operationId: string;
prompt: string;
taskId: string;
topicId: string;
},
);
return { content: json, state: safeJsonParse(json), success: true };
}
case 'cancelHeteroTask': {
const json = await this.cancelHeteroTask(args as { signal?: string; taskId: string });
return { content: json, state: safeJsonParse(json), success: true };
}
default: {
throw new Error(
`Tool "${apiName}" is not available on this device. It may not be supported in the current desktop version. Please skip this tool and try alternative approaches.`,
);
}
}
}
/**
* Execute a stdio MCP tool call tunneled from the cloud server. The server
* can't spawn the user's local MCP binary, so it forwards the connection
* params (command/args/env); we run the call through the local MCP client,
* which spawns the stdio server on this machine.
*/
private async executeMcpCall(mcpCall: {
apiName: string;
arguments: string;
identifier: string;
params: GatewayMcpStdioParams;
}): Promise<BuiltinServerRuntimeOutput> {
const { apiName, arguments: args, params: stdioParams } = mcpCall;
return this.mcpCtr.runStdioMcpTool({
args,
env: stdioParams.env,
params: {
args: stdioParams.args,
command: stdioParams.command,
name: stdioParams.name,
},
toolName: apiName,
});
return handler();
}
private async executeMessageApi(
@@ -643,6 +250,59 @@ export default class GatewayConnectionCtr extends ControllerModule {
);
}
private getLocalFileToolHandlers(args: any): ToolCallHandlerMap {
const editFile = () => this.localFileCtr.handleEditFile(args);
const globFiles = () => this.localFileCtr.handleGlobFiles(args);
const listFiles = () => this.localFileCtr.listLocalFiles(args);
const moveFiles = () => this.localFileCtr.handleMoveFiles(args);
const readFile = () => this.localFileCtr.readFile(args);
const searchFiles = () => this.localFileCtr.handleLocalFilesSearch(args);
const writeFile = () => this.localFileCtr.handleWriteFile(args);
return {
editFile,
globFiles,
grepContent: () => this.localFileCtr.handleGrepContent(args),
listFiles,
moveFiles,
readFile,
searchFiles,
writeFile,
// Legacy aliases — keep these so older Gateway versions sending the long
// names continue to route correctly. `renameLocalFile` is also kept even
// though the new surface drops rename (it's now handled by `moveFiles`).
editLocalFile: editFile,
globLocalFiles: globFiles,
listLocalFiles: listFiles,
moveLocalFiles: moveFiles,
readLocalFile: readFile,
renameLocalFile: () => this.localFileCtr.handleRenameFile(args),
searchLocalFiles: searchFiles,
writeLocalFile: writeFile,
};
}
private getShellCommandToolHandlers(args: any): ToolCallHandlerMap {
return {
getCommandOutput: () => this.shellCommandCtr.handleGetCommandOutput(args),
killCommand: () => this.shellCommandCtr.handleKillCommand(args),
runCommand: () => this.shellCommandCtr.handleRunCommand(args),
};
}
private getPlatformAgentToolHandlers(args: any): ToolCallHandlerMap {
return {
// Platform agent capability probing
checkPlatformCapability: () => this.checkPlatformCapability(args),
getAgentProfile: () => this.getAgentProfile(args),
// Platform agent task execution (openclaw / hermes)
cancelHeteroTask: () => this.cancelHeteroTask(args),
runHeteroTask: () => this.runHeteroTask(args),
};
}
// ─── Platform Capability Probing ───
private async checkPlatformCapability(args: {
+167 -24
View File
@@ -22,16 +22,8 @@ import type {
GitWorkingTreeStatus,
SubmoduleWorkingTreePatches,
} from '@lobechat/electron-client-ipc';
import {
type DeviceGitInfo,
getGitAheadBehind as computeGitAheadBehind,
getGitBranch as computeGitBranch,
getGitWorkingTreeStatus as computeGitWorkingTreeStatus,
getLinkedPullRequest as computeLinkedPullRequest,
gitInfo as computeGitInfo,
} from '@lobechat/local-file-shell';
import { detectRepoType } from '@/utils/git';
import { detectRepoType, resolveGitDir } from '@/utils/git';
import { createLogger } from '@/utils/logger';
import { ControllerModule, IpcMethod } from './index';
@@ -458,17 +450,23 @@ export default class GitController extends ControllerModule {
*/
@IpcMethod()
async getGitBranch(dirPath: string): Promise<GitBranchInfo> {
return computeGitBranch(dirPath);
}
try {
const gitDir = await resolveGitDir(dirPath);
if (!gitDir) return {};
/**
* Aggregate git status (branch + linked PR + working tree + ahead/behind) for a
* directory. The single entry point shared by the local desktop display, the
* device `gitInfo` RPC, and the CLI implemented in `@lobechat/local-file-shell`.
*/
@IpcMethod()
async gitInfo(params: { isGithub?: boolean; scope: string }): Promise<DeviceGitInfo> {
return computeGitInfo(params);
const head = (await readFile(path.join(gitDir, 'HEAD'), 'utf8')).trim();
const refMatch = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
if (refMatch) {
return { branch: refMatch[1] };
}
// Detached HEAD — HEAD file contains the full sha
if (/^[\da-f]{40}$/i.test(head)) {
return { branch: head.slice(0, 7), detached: true };
}
return {};
} catch {
return {};
}
}
/**
@@ -481,7 +479,58 @@ export default class GitController extends ControllerModule {
branch: string;
path: string;
}): Promise<GitLinkedPullRequestResult> {
return computeLinkedPullRequest(payload);
const { path: dirPath, branch } = payload;
if (!branch) {
return { pullRequest: null, status: 'ok' };
}
const execFileAsync = promisify(execFile);
try {
const { stdout } = await execFileAsync(
'gh',
[
'pr',
'list',
'--head',
branch,
'--state',
'open',
'--limit',
'5',
'--json',
'number,url,title,state',
],
{ cwd: dirPath, timeout: 8000 },
);
const parsed = JSON.parse(stdout.trim() || '[]') as Array<{
number: number;
state: string;
title: string;
url: string;
}>;
if (parsed.length === 0) {
return { pullRequest: null, status: 'ok' };
}
const [primary, ...rest] = parsed;
return {
extraCount: rest.length,
pullRequest: primary,
status: 'ok',
};
} catch (error: any) {
const code = error?.code;
const stderr: string = error?.stderr ?? '';
// `gh` binary not on PATH
if (code === 'ENOENT') {
return { pullRequest: null, status: 'gh-missing' };
}
// gh reports auth issues via stderr; treat as a soft-fail
if (/auth\s+login|not\s+logged\s+in|authentication/i.test(stderr)) {
return { pullRequest: null, status: 'gh-missing' };
}
logger.debug('[getLinkedPullRequest] failed', { branch, code, stderr });
return { pullRequest: null, status: 'error' };
}
}
/**
@@ -586,7 +635,42 @@ export default class GitController extends ControllerModule {
*/
@IpcMethod()
async getGitWorkingTreeStatus(dirPath: string): Promise<GitWorkingTreeStatus> {
return computeGitWorkingTreeStatus(dirPath);
const execFileAsync = promisify(execFile);
try {
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-z'], {
cwd: dirPath,
timeout: 5000,
});
const tokens = stdout.split('\0');
let added = 0;
let modified = 0;
let deleted = 0;
let i = 0;
while (i < tokens.length) {
const entry = tokens[i];
i++;
if (entry.length < 2) continue;
const x = entry[0];
const y = entry[1];
// R/C entries carry an extra source-path token we must consume.
if (x === 'R' || x === 'C') i++;
if (x === '?' && y === '?') {
added++;
} else if (x === '!' && y === '!') {
// ignored — skip
} else if (x === 'D' || y === 'D') {
deleted++;
} else if (x === 'A' || y === 'A') {
added++;
} else {
modified++;
}
}
const total = added + modified + deleted;
return { added, clean: total === 0, deleted, modified, total };
} catch {
return { added: 0, clean: true, deleted: 0, modified: 0, total: 0 };
}
}
/**
@@ -605,7 +689,7 @@ export default class GitController extends ControllerModule {
const modified: string[] = [];
const deleted: string[] = [];
try {
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-u', '-z'], {
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-z'], {
cwd: dirPath,
timeout: 5000,
});
@@ -746,7 +830,7 @@ export default class GitController extends ControllerModule {
const entries: Entry[] = [];
const submoduleDirtyEntries: Entry[] = [];
try {
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-u', '-z'], {
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-z'], {
cwd: dirPath,
timeout: 5000,
});
@@ -1049,7 +1133,66 @@ export default class GitController extends ControllerModule {
*/
@IpcMethod()
async getGitAheadBehind(dirPath: string): Promise<GitAheadBehind> {
return computeGitAheadBehind(dirPath);
const execFileAsync = promisify(execFile);
try {
await execFileAsync('git', ['fetch', '--no-tags', '--quiet', 'origin'], {
cwd: dirPath,
timeout: 10_000,
});
} catch {
// swallow — fall through to compute against cached refs
}
try {
const { stdout: upstreamOut } = await execFileAsync(
'git',
['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'],
{ cwd: dirPath, timeout: 5000 },
);
const upstream = upstreamOut.trim();
if (!upstream) return { ahead: 0, behind: 0, hasUpstream: false };
const { stdout } = await execFileAsync(
'git',
['rev-list', '--left-right', '--count', `${upstream}...HEAD`],
{ cwd: dirPath, timeout: 5000 },
);
const [behindStr, aheadStr] = stdout.trim().split(/\s+/);
const behind = Number.parseInt(behindStr ?? '0', 10) || 0;
const ahead = Number.parseInt(aheadStr ?? '0', 10) || 0;
// `git push -u origin HEAD` always targets origin/<current-branch-name>,
// which may differ from upstream (the branched-off-canary case).
let pushTarget: string | undefined;
let pushTargetExists = false;
try {
const { stdout: branchOut } = await execFileAsync(
'git',
['symbolic-ref', '--short', 'HEAD'],
{ cwd: dirPath, timeout: 5000 },
);
const branch = branchOut.trim();
if (branch) {
pushTarget = `origin/${branch}`;
try {
await execFileAsync(
'git',
['rev-parse', '--verify', '--quiet', `refs/remotes/${pushTarget}`],
{ cwd: dirPath, timeout: 5000 },
);
pushTargetExists = true;
} catch {
pushTargetExists = false;
}
}
} catch {
// detached HEAD — leave pushTarget undefined
}
return { ahead, behind, hasUpstream: true, pushTarget, pushTargetExists, upstream };
} catch {
// No upstream configured, detached HEAD, or git error — all treated as "no upstream"
return { ahead: 0, behind: 0, hasUpstream: false };
}
}
/**
@@ -41,33 +41,6 @@ import { createLogger } from '@/utils/logger';
import { ControllerModule, IpcMethod } from './index';
const logger = createLogger('controllers:HeterogeneousAgentCtr');
// Anthropic auth env vars that must NOT be inherited from the desktop process
// when spawning a local CLI agent. A developer with `ANTHROPIC_API_KEY` (or an
// auth token / base url) exported in their shell would otherwise have it
// forwarded to `claude`, which then switches from its own subscription login to
// that key — an expired / wrong key surfaces as a baffling "Invalid API key"
// and the run exits non-zero. Agents that genuinely want an API key still set
// it through `session.env`, which is spread AFTER the inherited env below and
// therefore wins.
const STRIPPED_INHERITED_ENV_KEYS = [
'ANTHROPIC_API_KEY',
'ANTHROPIC_AUTH_TOKEN',
'ANTHROPIC_BASE_URL',
] as const;
/**
* Inherited `process.env` with the Anthropic auth vars removed. Keep this pure
* and exported so the "never leak host Anthropic creds into the CLI" invariant
* can be unit-tested directly.
*/
export const buildInheritedSpawnEnv = (
sourceEnv: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv => {
const env = { ...sourceEnv };
for (const key of STRIPPED_INHERITED_ENV_KEYS) delete env[key];
return env;
};
const CODEX_RESUME_THREAD_NOT_FOUND_PATTERNS = [
/no conversation found/i,
/thread .*not found/i,
@@ -947,10 +920,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
const spawnOptions = {
cwd,
detached: process.platform !== 'win32',
// Strip host Anthropic creds from the inherited env so a developer's
// shell `ANTHROPIC_API_KEY` can't hijack the CLI's own auth. `session.env`
// is spread last, so an agent that explicitly configures a key still wins.
env: { ...buildInheritedSpawnEnv(), ...proxyEnv, ...session.env },
env: { ...process.env, ...proxyEnv, ...session.env },
stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] as ['pipe' | 'ignore', 'pipe', 'pipe'],
};
@@ -1338,14 +1308,6 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
} = params;
const workDir = cwd ?? process.cwd();
// When CLI tracing is enabled (dev builds, or the Help-menu toggle in
// packaged builds), have `lh hetero exec` persist the agent process's RAW
// stream-json (pre-adapter) on this device. The remote-device path
// otherwise leaves no local record — the CLI consumes stdout internally and
// only POSTs adapted events to the server — so without this there's nothing
// to inspect when a remote run misbehaves.
const rawDumpDir = this.shouldTraceCliOutput ? this.resolveTraceRootDir(workDir) : undefined;
const args = [
'hetero',
'exec',
@@ -1362,7 +1324,6 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
'--cwd',
workDir,
...(resumeSessionId ? ['--resume', resumeSessionId] : []),
...(rawDumpDir ? ['--raw-dump', rawDumpDir] : []),
];
const env = {
@@ -1,5 +1,5 @@
import { constants } from 'node:fs';
import { access, mkdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
import { access, mkdir, readdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import {
@@ -12,6 +12,8 @@ import {
type GrepContentParams,
type GrepContentResult,
type ListLocalFileParams,
type ListProjectSkillsParams,
type ListProjectSkillsResult,
type LocalFilePreviewUrlParams,
type LocalFilePreviewUrlResult,
type LocalMoveFilesResultItem,
@@ -121,6 +123,62 @@ const collectProjectDirectories = (files: string[], root: string): ProjectFileIn
return [...directories].map((directory) => createProjectFileEntry(root, directory, true));
};
const SKILL_FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
// Cap recursion to guard against pathological directory trees.
const MAX_SKILL_FILE_COUNT = 1000;
const listSkillFilesRecursive = async (dir: string): Promise<string[]> => {
const results: string[] = [];
const stack: string[] = [dir];
while (stack.length > 0 && results.length < MAX_SKILL_FILE_COUNT) {
const current = stack.pop()!;
let entries;
try {
entries = await readdir(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(full);
} else if (entry.isFile()) {
results.push(toPosixRelativePath(path.relative(dir, full)));
if (results.length >= MAX_SKILL_FILE_COUNT) break;
}
}
}
return results.sort();
};
// Parse a minimal YAML frontmatter block for SKILL.md files.
// Only handles `key: value` lines; multi-line block scalars fall back to the first line.
const parseSkillFrontmatter = (raw: string): Record<string, string> => {
const match = raw.match(SKILL_FRONTMATTER_RE);
if (!match) return {};
const fields: Record<string, string> = {};
for (const line of match[1].split(/\r?\n/)) {
const colonIdx = line.indexOf(':');
if (colonIdx === -1) continue;
const key = line.slice(0, colonIdx).trim();
if (!key || key.startsWith('#')) continue;
let value = line.slice(colonIdx + 1).trim();
if (value.startsWith('|') || value.startsWith('>')) continue;
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
fields[key] = value;
}
return fields;
};
const createDetectedProjectFileEntry = async (
root: string,
absolutePath: string,
@@ -603,6 +661,61 @@ export default class LocalFileCtr extends ControllerModule {
};
}
/**
* Scan agent skill directories under the project root and return parsed
* frontmatter for each SKILL.md. Used by the hetero agent's working sidebar
* to surface skills available in the current project.
*/
@IpcMethod()
async listProjectSkills(params: ListProjectSkillsParams): Promise<ListProjectSkillsResult> {
const root = params.scope;
const sources = ['.agents/skills', '.claude/skills'] as const;
for (const source of sources) {
const dir = path.join(root, source);
try {
const entries = await readdir(dir, { withFileTypes: true });
const skills = (
await Promise.all(
entries
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
.map(async (entry) => {
const skillDir = path.join(dir, entry.name);
const skillFile = path.join(skillDir, 'SKILL.md');
try {
const raw = await readFile(skillFile, 'utf8');
const fields = parseSkillFrontmatter(raw);
const files = await listSkillFilesRecursive(skillDir);
return {
description: fields.description || undefined,
fileCount: files.length,
files,
name: fields.name || entry.name,
path: skillFile,
skillDir,
source,
};
} catch {
return null;
}
}),
)
)
.filter((skill): skill is NonNullable<typeof skill> => skill !== null)
.sort((a, b) => a.name.localeCompare(b.name));
if (skills.length > 0) {
await this.approveProjectRootForPreview(root);
return { root, skills, source };
}
} catch {
// Directory does not exist or is not readable; try the next candidate.
}
}
return { root, skills: [], source: null };
}
/**
* Handle IPC event for local file search
*/
+3 -16
View File
@@ -91,7 +91,7 @@ interface GetStreamableMcpServerManifestInput {
url: string;
}
export interface CallToolInput {
interface CallToolInput {
args: any;
env: any;
params: GetStdioMcpServerManifestInput;
@@ -324,19 +324,6 @@ export default class McpCtr extends ControllerModule {
@IpcMethod()
async callTool(payload: SuperJSONSerialized<CallToolInput>) {
const input = deserializePayload<CallToolInput>(payload);
return serializePayload(await this.runStdioMcpTool(input));
}
/**
* Core stdio MCP tool execution, shared by the renderer IPC path
* ({@link callTool}) and the device-gateway tunnel (GatewayConnectionCtr,
* which runs MCP calls forwarded from the cloud server). Returns the plain
* result envelope; callers serialize as needed. Throws on failure so each
* caller can shape its own error response.
*/
async runStdioMcpTool(
input: CallToolInput,
): Promise<{ content: string; state: unknown; success: boolean }> {
const params: MCPClientParams = {
args: input.params.args || [],
command: input.params.command,
@@ -355,11 +342,11 @@ export default class McpCtr extends ControllerModule {
const content = await toMarkdown(processed, (key) => this.fileService.getFileHTTPURL(key));
return {
return serializePayload({
content,
state: { ...raw, content: processed },
success: true,
};
});
} catch (error) {
// If it's an MCPConnectionError with stderr logs, enhance the error message
if (error instanceof MCPConnectionError && error.stderrLogs.length > 0) {
@@ -1,251 +0,0 @@
import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import {
type InitWorkspaceParams,
type InitWorkspaceResult,
type ListProjectSkillsParams,
type ListProjectSkillsResult,
type ProjectSkillItem,
} from '@lobechat/electron-client-ipc';
import { detectRepoType } from '@/utils/git';
import { createLogger } from '@/utils/logger';
import { ControllerModule, IpcMethod } from './index';
const logger = createLogger('controllers:WorkspaceCtr');
const SKILL_FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
// Cap recursion to guard against pathological directory trees.
const MAX_SKILL_FILE_COUNT = 1000;
const toPosixRelativePath = (filePath: string) => filePath.split(path.sep).join('/');
const listSkillFilesRecursive = async (dir: string): Promise<string[]> => {
const results: string[] = [];
const stack: string[] = [dir];
while (stack.length > 0 && results.length < MAX_SKILL_FILE_COUNT) {
const current = stack.pop()!;
let entries;
try {
entries = await readdir(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(full);
} else if (entry.isFile()) {
results.push(toPosixRelativePath(path.relative(dir, full)));
if (results.length >= MAX_SKILL_FILE_COUNT) break;
}
}
}
return results.sort();
};
// Parse a minimal YAML frontmatter block for SKILL.md files.
// Only handles `key: value` lines; multi-line block scalars fall back to the first line.
const parseSkillFrontmatter = (raw: string): Record<string, string> => {
const match = raw.match(SKILL_FRONTMATTER_RE);
if (!match) return {};
const fields: Record<string, string> = {};
for (const line of match[1].split(/\r?\n/)) {
const colonIdx = line.indexOf(':');
if (colonIdx === -1) continue;
const key = line.slice(0, colonIdx).trim();
if (!key || key.startsWith('#')) continue;
let value = line.slice(colonIdx + 1).trim();
if (value.startsWith('|') || value.startsWith('>')) continue;
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
fields[key] = value;
}
return fields;
};
/**
* WorkspaceCtr
*
* Owns "project workspace" scanning: discovering agent skills (`.agents/skills`
* / `.claude/skills`) and project-root instructions (`AGENTS.md` / `CLAUDE.md`)
* under a bound project directory. Split out of LocalFileCtr so the
* workspace/agent-config concern is distinct from generic local file ops.
*/
export default class WorkspaceCtr extends ControllerModule {
static override readonly groupName = 'workspace';
/**
* Scan one skill source directory (e.g. `.agents/skills`) under `root` and
* return parsed frontmatter for each `SKILL.md`. Returns `[]` when the source
* directory is absent or unreadable. Unsorted callers sort/merge.
*/
private async scanSkillsInSource(
root: string,
source: ProjectSkillItem['source'],
): Promise<ProjectSkillItem[]> {
const dir = path.join(root, source);
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
// Directory does not exist or is not readable.
return [];
}
const skills = await Promise.all(
entries
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
.map(async (entry) => {
const skillDir = path.join(dir, entry.name);
const skillFile = path.join(skillDir, 'SKILL.md');
try {
const raw = await readFile(skillFile, 'utf8');
const fields = parseSkillFrontmatter(raw);
const files = await listSkillFilesRecursive(skillDir);
return {
description: fields.description || undefined,
fileCount: files.length,
files,
name: fields.name || entry.name,
path: skillFile,
skillDir,
source,
};
} catch {
return null;
}
}),
);
return skills.filter((skill): skill is ProjectSkillItem => skill !== null);
}
/**
* Scan agent skill directories under the project root and return parsed
* frontmatter for each SKILL.md. Used by the hetero agent's working sidebar
* to surface skills available in the current project. Returns the first
* source directory that yields any skills (`.agents/skills` wins).
*/
@IpcMethod()
async listProjectSkills(params: ListProjectSkillsParams): Promise<ListProjectSkillsResult> {
const root = params.scope;
const sources = ['.agents/skills', '.claude/skills'] as const;
for (const source of sources) {
const skills = (await this.scanSkillsInSource(root, source)).sort((a, b) =>
a.name.localeCompare(b.name),
);
if (skills.length > 0) {
await this.approveProjectRootForPreview(root);
return { root, skills, source };
}
}
return { root, skills: [], source: null };
}
/**
* One-call "workspace init" scan of a bound project directory: merge the
* project skills from BOTH `.agents/skills` and `.claude/skills` (deduped by
* name, `.agents/skills` winning) and read the project-root agent
* instructions file (`AGENTS.md`, else `CLAUDE.md`). Driven server-side at run
* start via the generic device RPC (not an LLM-visible tool) and cached onto
* `devices.workingDirs[].workspace`.
*
* Approves the root for the `lobe-file://` preview protocol (same as
* `listProjectSkills`) so the user can later click through to the scanned
* skills / instructions in the UI.
*/
@IpcMethod()
async initWorkspace(params: InitWorkspaceParams): Promise<InitWorkspaceResult> {
const root = params.scope;
const sources = ['.agents/skills', '.claude/skills'] as const;
const seen = new Set<string>();
const skills: ProjectSkillItem[] = [];
for (const source of sources) {
for (const skill of await this.scanSkillsInSource(root, source)) {
if (seen.has(skill.name)) continue;
seen.add(skill.name);
skills.push(skill);
}
}
skills.sort((a, b) => a.name.localeCompare(b.name));
const instructions = await this.readWorkspaceInstructions(root);
// Approve regardless of what was found — the run is now bound to this root,
// so any later click-through to it should resolve through the preview
// protocol even if the project carries neither skills nor instructions.
await this.approveProjectRootForPreview(root);
return { instructions, root, skills };
}
/**
* Check whether a path exists on this device and is a directory, plus its git
* repo type (`git` / `github` / none). Used to validate a manually-entered
* working directory from a web / remote client (which can't browse this
* device's filesystem) before binding it, and to render the right dir icon.
*/
@IpcMethod()
async statPath(params: {
path: string;
}): Promise<{ exists: boolean; isDirectory: boolean; repoType?: 'git' | 'github' }> {
try {
const stats = await stat(params.path);
if (!stats.isDirectory()) return { exists: true, isDirectory: false };
const repoType = await detectRepoType(params.path);
return { exists: true, isDirectory: true, repoType };
} catch {
return { exists: false, isDirectory: false };
}
}
/**
* Read the project-root agent instructions files. Collects every present
* candidate (`AGENTS.md`, then `CLAUDE.md`) rather than first-match, since both
* can coexist. Each body is capped so a pathologically large file can't bloat
* the cached `workingDirs` payload or the injected system role.
*/
private async readWorkspaceInstructions(
root: string,
): Promise<InitWorkspaceResult['instructions']> {
const MAX_INSTRUCTIONS_BYTES = 64 * 1024;
const candidates = ['AGENTS.md', 'CLAUDE.md'] as const;
const instructions: InitWorkspaceResult['instructions'] = [];
for (const source of candidates) {
try {
const raw = await readFile(path.join(root, source), 'utf8');
const content =
raw.length > MAX_INSTRUCTIONS_BYTES ? raw.slice(0, MAX_INSTRUCTIONS_BYTES) : raw;
instructions.push({ content, source });
} catch {
// File absent or unreadable; skip it.
}
}
return instructions;
}
private async approveProjectRootForPreview(root: string) {
try {
await this.app.localFileProtocolManager.approveIndexedProjectRoot(root);
} catch (error) {
logger.error(`Failed to approve project preview root ${root}:`, error);
}
}
}
@@ -797,12 +797,7 @@ describe('AuthCtr', () => {
expect(mockRemoteServerConfigCtr.setRemoteServerConfig).toHaveBeenCalledWith({
active: false,
});
expect(mockWindow.webContents.send).toHaveBeenCalledWith(
'authorizationRequired',
expect.objectContaining({
reason: expect.stringContaining('startup:non_retryable'),
}),
);
expect(mockWindow.webContents.send).toHaveBeenCalledWith('authorizationRequired');
});
it('should preserve tokens on transient error', async () => {
@@ -9,7 +9,6 @@ import ImessageBridgeService from '@/services/imessageBridgeSrv';
import GatewayConnectionCtr from '../GatewayConnectionCtr';
import HeterogeneousAgentCtr from '../HeterogeneousAgentCtr';
import LocalFileCtr from '../LocalFileCtr';
import McpCtr from '../McpCtr';
import RemoteServerConfigCtr from '../RemoteServerConfigCtr';
import ShellCommandCtr from '../ShellCommandCtr';
@@ -70,26 +69,6 @@ const { ipcMainHandleMock, MockGatewayClient } = vi.hoisted(() => {
});
}
simulateMcpCallRequest(
apiName: string,
args: object,
params: object,
requestId = 'mcp-req-1',
identifier = 'kimi-datasource',
) {
this.emit('tool_call_request', {
requestId,
toolCall: {
apiName,
arguments: JSON.stringify(args),
identifier,
params,
type: 'mcp',
},
type: 'tool_call_request',
});
}
simulateMessageApiRequest(
platform: string,
apiName: string,
@@ -144,7 +123,6 @@ const { ipcMainHandleMock, MockGatewayClient } = vi.hoisted(() => {
vi.mock('electron', () => ({
app: {
getAppPath: vi.fn(() => '/mock/app'),
getPath: vi.fn((name: string) => `/mock/${name}`),
},
ipcMain: { handle: ipcMainHandleMock },
@@ -251,10 +229,6 @@ const mockImessageBridgeSrv = {
handleGatewayMessageApi: vi.fn().mockResolvedValue({ ok: true }),
} as unknown as ImessageBridgeService;
const mockMcpCtr = {
runStdioMcpTool: vi.fn().mockResolvedValue({ content: 'mcp result', state: {}, success: true }),
} as unknown as McpCtr;
const mockRemoteServerConfigCtr = {
getAccessToken: vi.fn().mockResolvedValue('mock-access-token'),
getRemoteServerUrl: vi.fn().mockResolvedValue('https://server.example.com'),
@@ -273,7 +247,6 @@ const mockApp = {
if (Cls === LocalFileCtr) return mockLocalFileCtr;
if (Cls === ShellCommandCtr) return mockShellCommandCtr;
if (Cls === HeterogeneousAgentCtr) return mockHeterogeneousAgentCtr;
if (Cls === McpCtr) return mockMcpCtr;
return null;
}),
getService: vi.fn((Cls) => {
@@ -553,18 +526,15 @@ describe('GatewayConnectionCtr', () => {
['renameLocalFile', 'handleRenameFile', mockLocalFileCtr],
] as const)('should route %s to %s', async (apiName, methodName, controller) => {
const client = await connectAndOpen();
const args = { test: 'arg' };
// Each tool's args are domain-shaped (path, file_path, items, etc.).
// The runtime denormalizes them before calling the controller, so this
// test only asserts that the *right* controller method runs — see the
// envelope-shape test below for end-to-end content/state coverage.
client.simulateToolCallRequest(apiName, { test: 'arg' });
client.simulateToolCallRequest(apiName, args);
await vi.advanceTimersByTimeAsync(0);
expect((controller as any)[methodName]).toHaveBeenCalled();
expect((controller as any)[methodName]).toHaveBeenCalledWith(args);
});
it('should send tool_call_response with content + state envelope on success', async () => {
it('should send tool_call_response with success result', async () => {
vi.mocked(mockLocalFileCtr.readFile).mockResolvedValueOnce({
charCount: 5,
content: 'hello',
@@ -582,20 +552,23 @@ describe('GatewayConnectionCtr', () => {
client.simulateToolCallRequest('readFile', { path: '/a.txt' }, 'req-42');
await vi.advanceTimersByTimeAsync(0);
// The runtime produces a formatted prompt string for `content` and a
// structured snapshot for `state`. We only assert envelope shape here
// — the exact prompt format is owned by the runtime/prompts packages.
expect(client.sendToolCallResponse).toHaveBeenCalledTimes(1);
const response = client.sendToolCallResponse.mock.calls[0][0];
expect(response.requestId).toBe('req-42');
expect(response.result.success).toBe(true);
expect(typeof response.result.content).toBe('string');
expect(response.result.content.length).toBeGreaterThan(0);
expect(response.result.content).toContain('hello');
expect(response.result.state).toMatchObject({
content: 'hello',
filename: 'a.txt',
path: '/a.txt',
expect(client.sendToolCallResponse).toHaveBeenCalledWith({
requestId: 'req-42',
result: {
content: JSON.stringify({
charCount: 5,
content: 'hello',
createdTime: new Date('2024-01-01'),
filename: 'a.txt',
fileType: '.txt',
lineCount: 1,
loc: [1, 1],
modifiedTime: new Date('2024-01-01'),
totalCharCount: 5,
totalLineCount: 1,
}),
success: true,
},
});
});
@@ -633,89 +606,6 @@ describe('GatewayConnectionCtr', () => {
},
});
});
it('should route tunneled stdio MCP calls to McpCtr.runStdioMcpTool', async () => {
const client = await connectAndOpen();
client.simulateMcpCallRequest(
'getStock',
{ symbol: 'AAPL' },
{ args: ['stock-mcp'], command: 'npx', env: { TOKEN: 'secret' }, name: 'kimi-datasource' },
);
await vi.advanceTimersByTimeAsync(0);
// The builtin local-system switch is keyed on apiName and would reject
// 'getStock'; the `type: 'mcp'` discriminator routes to the MCP client.
expect(mockMcpCtr.runStdioMcpTool).toHaveBeenCalledWith({
args: '{"symbol":"AAPL"}',
env: { TOKEN: 'secret' },
params: { args: ['stock-mcp'], command: 'npx', name: 'kimi-datasource' },
toolName: 'getStock',
});
});
it('should NOT route to MCP when params are present but type is not mcp', async () => {
// Regression: routing must follow the explicit `type` discriminator, not
// the mere presence of `params`. A builtin call that happens to carry a
// `params` field must still go to the builtin switch.
const client = await connectAndOpen();
client.emit('tool_call_request', {
requestId: 'tool-with-params',
toolCall: {
apiName: 'readFile',
arguments: JSON.stringify({ path: '/a.txt' }),
identifier: 'lobe-local-system',
params: { args: [], command: 'npx', name: 'x' },
type: 'tool',
},
type: 'tool_call_request',
});
await vi.advanceTimersByTimeAsync(0);
expect(mockMcpCtr.runStdioMcpTool).not.toHaveBeenCalled();
expect(mockLocalFileCtr.readFile).toHaveBeenCalled();
});
it('should send tool_call_response envelope for a successful MCP call', async () => {
vi.mocked(mockMcpCtr.runStdioMcpTool).mockResolvedValueOnce({
content: 'stock: 100',
state: { rows: 1 },
success: true,
});
const client = await connectAndOpen();
client.simulateMcpCallRequest(
'getStock',
{},
{ args: [], command: 'npx', name: 'kimi-datasource' },
'mcp-ok',
);
await vi.advanceTimersByTimeAsync(0);
expect(client.sendToolCallResponse).toHaveBeenCalledWith({
requestId: 'mcp-ok',
result: { content: 'stock: 100', state: { rows: 1 }, success: true },
});
});
it('should send error response when the MCP call throws', async () => {
vi.mocked(mockMcpCtr.runStdioMcpTool).mockRejectedValueOnce(new Error('spawn ENOENT'));
const client = await connectAndOpen();
client.simulateMcpCallRequest(
'getStock',
{},
{ args: [], command: 'missing-bin', name: 'kimi-datasource' },
'mcp-err',
);
await vi.advanceTimersByTimeAsync(0);
expect(client.sendToolCallResponse).toHaveBeenCalledWith({
requestId: 'mcp-err',
result: { content: 'spawn ENOENT', error: 'spawn ENOENT', success: false },
});
});
});
describe('message API routing', () => {
@@ -1086,7 +976,6 @@ describe('GatewayConnectionCtr', () => {
requestId: 'req-cap',
result: {
content: JSON.stringify({ available: true, version: 'openclaw 1.2.3' }),
state: { available: true, version: 'openclaw 1.2.3' },
success: true,
},
});
@@ -1111,7 +1000,6 @@ describe('GatewayConnectionCtr', () => {
requestId: 'req-cap-nover',
result: {
content: JSON.stringify({ available: true }),
state: { available: true },
success: true,
},
});
@@ -1137,10 +1025,6 @@ describe('GatewayConnectionCtr', () => {
available: false,
reason: 'openclaw is not installed on this device',
}),
state: {
available: false,
reason: 'openclaw is not installed on this device',
},
success: true,
},
});
@@ -1159,7 +1043,6 @@ describe('GatewayConnectionCtr', () => {
requestId: 'req-unknown-plat',
result: {
content: JSON.stringify({ available: false, reason: 'Unknown platform: unknownBot' }),
state: { available: false, reason: 'Unknown platform: unknownBot' },
success: true,
},
});
@@ -1174,7 +1057,6 @@ describe('GatewayConnectionCtr', () => {
requestId: 'req-profile',
result: {
content: JSON.stringify({}),
state: {},
success: true,
},
});
@@ -313,53 +313,6 @@ describe('HeterogeneousAgentCtr', () => {
]);
});
it('does not leak host Anthropic auth env into the spawned CLI', async () => {
// A developer with these exported in their shell would otherwise have them
// forwarded to `claude`, overriding its subscription login and surfacing
// as a baffling "Invalid API key" / non-zero exit. Regression guard for
// that env-leak.
const original = {
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN,
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
};
process.env.ANTHROPIC_API_KEY = 'sk-host-should-not-leak';
process.env.ANTHROPIC_AUTH_TOKEN = 'host-token-should-not-leak';
process.env.ANTHROPIC_BASE_URL = 'https://host.example/should-not-leak';
try {
const { options } = await runSendPrompt('hello');
expect(options.env).not.toHaveProperty('ANTHROPIC_API_KEY');
expect(options.env).not.toHaveProperty('ANTHROPIC_AUTH_TOKEN');
expect(options.env).not.toHaveProperty('ANTHROPIC_BASE_URL');
// Unrelated inherited vars must still pass through.
expect(options.env.PATH).toBe(process.env.PATH);
} finally {
for (const [key, value] of Object.entries(original)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
});
it('lets an agent-configured Anthropic key in session.env override the stripped host env', async () => {
const originalKey = process.env.ANTHROPIC_API_KEY;
process.env.ANTHROPIC_API_KEY = 'sk-host-should-not-leak';
try {
const { options } = await runSendPrompt('hello', {
env: { ANTHROPIC_API_KEY: 'sk-agent-explicit' },
});
// Explicit per-agent config wins; the host value is never seen.
expect(options.env.ANTHROPIC_API_KEY).toBe('sk-agent-explicit');
} finally {
if (originalKey === undefined) delete process.env.ANTHROPIC_API_KEY;
else process.env.ANTHROPIC_API_KEY = originalKey;
}
});
it('captures the Claude Code session id from stream-json init events', async () => {
const { ctr, sessionId } = await runSendPrompt('hello', {}, [
`${JSON.stringify({ session_id: 'sess_cc_123', subtype: 'init', type: 'system' })}\n`,
@@ -487,14 +440,8 @@ describe('HeterogeneousAgentCtr', () => {
expect(command).toBe('codex');
expect(cliArgs).not.toContain(prompt);
expect(cliArgs).toEqual(
expect.arrayContaining([
'exec',
'--json',
'--skip-git-repo-check',
'--dangerously-bypass-approvals-and-sandbox',
]),
expect.arrayContaining(['exec', '--json', '--skip-git-repo-check', '--full-auto']),
);
expect(cliArgs).not.toContain('--full-auto');
expect(cliArgs).not.toContain('-');
expect(writes).toEqual([prompt]);
});
@@ -26,7 +26,6 @@ vi.mock('@/utils/logger', () => ({
// Mock child_process for the shared package
vi.mock('node:child_process', () => ({
execFile: vi.fn(),
spawn: vi.fn(),
}));
@@ -1,161 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { type App } from '@/core/App';
import WorkspaceCtr from '../WorkspaceCtr';
const { ipcMainHandleMock } = vi.hoisted(() => ({
ipcMainHandleMock: vi.fn(),
}));
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}),
}));
vi.mock('electron', () => ({
ipcMain: {
handle: ipcMainHandleMock,
},
}));
vi.mock('node:fs/promises', () => ({
readFile: vi.fn(),
readdir: vi.fn(),
}));
const mockLocalFileProtocolManager = {
approveIndexedProjectRoot: vi.fn(),
};
const mockApp = {
localFileProtocolManager: mockLocalFileProtocolManager,
} as unknown as App;
describe('WorkspaceCtr', () => {
let workspaceCtr: WorkspaceCtr;
let mockFsPromises: any;
beforeEach(async () => {
vi.clearAllMocks();
mockFsPromises = await import('node:fs/promises');
workspaceCtr = new WorkspaceCtr(mockApp);
});
const dirent = (name: string, kind: 'dir' | 'file') => ({
isDirectory: () => kind === 'dir',
isFile: () => kind === 'file',
isSymbolicLink: () => false,
name,
});
const frontmatter = (name: string, description: string) =>
`---\nname: ${name}\ndescription: ${description}\n---\nbody`;
describe('initWorkspace', () => {
it('merges skills from both sources and reads instruction files', async () => {
vi.mocked(mockFsPromises.readdir).mockImplementation(async (dir: string) => {
if (dir === '/proj/.agents/skills') return [dirent('spa-routes', 'dir')];
if (dir === '/proj/.agents/skills/spa-routes') return [dirent('SKILL.md', 'file')];
if (dir === '/proj/.claude/skills') return [dirent('reviewer', 'dir')];
if (dir === '/proj/.claude/skills/reviewer') return [dirent('SKILL.md', 'file')];
throw new Error('ENOENT');
});
vi.mocked(mockFsPromises.readFile).mockImplementation(async (file: string) => {
if (file === '/proj/.agents/skills/spa-routes/SKILL.md')
return frontmatter('spa-routes', 'SPA routing');
if (file === '/proj/.claude/skills/reviewer/SKILL.md')
return frontmatter('reviewer', 'Code review');
if (file === '/proj/AGENTS.md') return '# Agents';
if (file === '/proj/CLAUDE.md') return '# Claude';
throw new Error('ENOENT');
});
const result = await workspaceCtr.initWorkspace({ scope: '/proj' });
expect(result.skills.map((s) => s.name)).toEqual(['reviewer', 'spa-routes']);
expect(result.instructions).toEqual([
{ content: '# Agents', source: 'AGENTS.md' },
{ content: '# Claude', source: 'CLAUDE.md' },
]);
// Approves the scanned root for the lobe-file:// preview protocol.
expect(mockLocalFileProtocolManager.approveIndexedProjectRoot).toHaveBeenCalledWith('/proj');
});
it('dedupes skills by name with .agents/skills winning', async () => {
vi.mocked(mockFsPromises.readdir).mockImplementation(async (dir: string) => {
if (dir === '/proj/.agents/skills') return [dirent('shared', 'dir')];
if (dir === '/proj/.claude/skills') return [dirent('shared', 'dir')];
if (dir.endsWith('/shared')) return [dirent('SKILL.md', 'file')];
throw new Error('ENOENT');
});
vi.mocked(mockFsPromises.readFile).mockImplementation(async (file: string) => {
if (file === '/proj/.agents/skills/shared/SKILL.md')
return frontmatter('shared', 'from agents');
if (file === '/proj/.claude/skills/shared/SKILL.md')
return frontmatter('shared', 'from claude');
throw new Error('ENOENT');
});
const result = await workspaceCtr.initWorkspace({ scope: '/proj' });
expect(result.skills).toHaveLength(1);
expect(result.skills[0]).toMatchObject({
description: 'from agents',
path: '/proj/.agents/skills/shared/SKILL.md',
});
});
it('caps instruction file content', async () => {
vi.mocked(mockFsPromises.readdir).mockRejectedValue(new Error('ENOENT'));
const huge = 'x'.repeat(100 * 1024);
vi.mocked(mockFsPromises.readFile).mockImplementation(async (file: string) => {
if (file === '/proj/AGENTS.md') return huge;
throw new Error('ENOENT');
});
const result = await workspaceCtr.initWorkspace({ scope: '/proj' });
expect(result.skills).toEqual([]);
expect(result.instructions).toHaveLength(1);
expect(result.instructions[0].content.length).toBe(64 * 1024);
});
it('returns empty skills and instructions when nothing is present', async () => {
vi.mocked(mockFsPromises.readdir).mockRejectedValue(new Error('ENOENT'));
vi.mocked(mockFsPromises.readFile).mockRejectedValue(new Error('ENOENT'));
const result = await workspaceCtr.initWorkspace({ scope: '/proj' });
expect(result).toEqual({ instructions: [], root: '/proj', skills: [] });
});
});
describe('listProjectSkills', () => {
it('returns the first source with skills (.agents/skills wins) and ignores .claude', async () => {
vi.mocked(mockFsPromises.readdir).mockImplementation(async (dir: string) => {
if (dir === '/proj/.agents/skills') return [dirent('alpha', 'dir')];
if (dir === '/proj/.agents/skills/alpha') return [dirent('SKILL.md', 'file')];
throw new Error('ENOENT');
});
vi.mocked(mockFsPromises.readFile).mockResolvedValue(frontmatter('alpha', 'A'));
const result = await workspaceCtr.listProjectSkills({ scope: '/proj' });
expect(result.source).toBe('.agents/skills');
expect(result.skills.map((s) => s.name)).toEqual(['alpha']);
});
it('returns empty + null source when no skills exist', async () => {
vi.mocked(mockFsPromises.readdir).mockRejectedValue(new Error('ENOENT'));
const result = await workspaceCtr.listProjectSkills({ scope: '/proj' });
expect(result).toEqual({ root: '/proj', skills: [], source: null });
});
});
});
@@ -24,7 +24,6 @@ import SystemController from './SystemCtr';
import ToolDetectorCtr from './ToolDetectorCtr';
import TrayMenuCtr from './TrayMenuCtr';
import UpdaterCtr from './UpdaterCtr';
import WorkspaceCtr from './WorkspaceCtr';
export const controllerIpcConstructors = [
HeterogeneousAgentCtr,
@@ -51,7 +50,6 @@ export const controllerIpcConstructors = [
ToolDetectorCtr,
TrayMenuCtr,
UpdaterCtr,
WorkspaceCtr,
] as const satisfies readonly IpcServiceConstructor[];
type DesktopControllerIpcConstructors = typeof controllerIpcConstructors;
+12 -7
View File
@@ -10,6 +10,7 @@ import * as electronIs from 'electron-is';
import { name } from '@/../../package.json';
import { binDir, buildDir } from '@/const/dir';
import { isDev } from '@/const/env';
import { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol';
import type { IControlModule } from '@/controllers';
import AuthCtr from '@/controllers/AuthCtr';
import { generateCliWrapper, getCliWrapperDir } from '@/modules/cliEmbedding';
@@ -28,7 +29,6 @@ import type { IServiceModule } from '@/services';
import { createLogger } from '@/utils/logger';
import { BrowserManager } from './browser/BrowserManager';
import { backendProxyProtocolManager } from './infrastructure/BackendProxyProtocolManager';
import { I18nManager } from './infrastructure/I18nManager';
import { IoCContainer } from './infrastructure/IoCContainer';
import { LocalFileProtocolManager } from './infrastructure/LocalFileProtocolManager';
@@ -104,17 +104,21 @@ export class App {
this.storeManager = new StoreManager(this);
this.rendererUrlManager = new RendererUrlManager();
// Wire the backend reverse-proxy as an `app://` interceptor: keeps
// RendererUrlManager ignorant of "what counts as a backend path" while
// letting BackendProxyProtocolManager own that knowledge.
this.rendererUrlManager.addRequestInterceptor(
backendProxyProtocolManager.createAppRequestInterceptor(),
);
this.localFileProtocolManager = new LocalFileProtocolManager();
void this.localFileProtocolManager.approveWorkspaceRoots(
this.storeManager.get('localFileWorkspaceRoots', []),
);
protocol.registerSchemesAsPrivileged([
{
privileges: {
allowServiceWorkers: true,
corsEnabled: true,
secure: true,
standard: true,
supportFetchAPI: true,
},
scheme: ELECTRON_BE_PROTOCOL_SCHEME,
},
this.rendererUrlManager.protocolScheme,
this.localFileProtocolManager.protocolScheme,
]);
@@ -427,6 +431,7 @@ export class App {
if (!isDev) return;
logger.debug('Setting up dev branding');
app.setName('lobehub-desktop-dev');
if (electronIs.macOS()) {
app.dock!.setIcon(path.join(buildDir, 'icon-dev.png'));
}
@@ -8,6 +8,7 @@ import { app, BrowserWindow, ipcMain, screen, session as electronSession, shell
import { preloadDir, resourcesDir } from '@/const/dir';
import { isMac } from '@/const/env';
import { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol';
import RemoteServerConfigCtr from '@/controllers/RemoteServerConfigCtr';
import { backendProxyProtocolManager } from '@/core/infrastructure/BackendProxyProtocolManager';
import { appendVercelCookie, setResponseHeader } from '@/utils/http-headers';
@@ -560,10 +561,7 @@ export default class Browser {
}
/**
* Bind this window's session to the backend proxy. The `app://` request
* interceptor (wired in `App.ts`) consumes this context to route
* `/trpc`, `/webapi`, `/api/auth`, and `/market` requests to the remote
* LobeHub server.
* Rewrite tRPC requests to remote server and inject OIDC token
*/
private setupRemoteServerRequestHook(browserWindow: BrowserWindow): void {
const session = browserWindow.webContents.session;
@@ -579,6 +577,7 @@ export default class Browser {
const remoteServerUrl = await remoteServerConfigCtr.getRemoteServerUrl(config);
return remoteServerUrl || null;
},
scheme: ELECTRON_BE_PROTOCOL_SCHEME,
source: this.identifier,
});
}
@@ -1,72 +1,70 @@
import { AUTH_REQUIRED_HEADER } from '@lobechat/desktop-bridge';
import { BrowserWindow, type Session, session as electronSession } from 'electron';
import { BrowserWindow, type Session } from 'electron';
import { isDev } from '@/const/env';
import { isBackendPath } from '@/const/protocol';
import { appendVercelCookie } from '@/utils/http-headers';
import { createLogger } from '@/utils/logger';
import { netFetch } from '@/utils/net-fetch';
import type { RendererRequestInterceptor } from './RendererProtocolManager';
interface BackendProxyContext {
interface BackendProxyProtocolManagerOptions {
getAccessToken: () => Promise<string | undefined | null>;
rewriteUrl: (rawUrl: string) => Promise<string | null>;
scheme: string;
/**
* Used for log prefixes. e.g. window identifier
*/
source?: string;
}
interface BackendProxyRemoteBaseOptions {
interface BackendProxyProtocolManagerRemoteBaseOptions {
getAccessToken: () => Promise<string | undefined | null>;
getRemoteBaseUrl: () => Promise<string | undefined | null>;
scheme: string;
/**
* Used for log prefixes. e.g. window identifier
*/
source?: string;
}
/**
* Holds per-session proxy context for routing renderer-originated backend
* requests (`/trpc`, `/webapi`, `/api/auth`, `/market`) to the remote LobeHub
* server. The context is consumed by `createAppRequestInterceptor`, which the
* `app://` protocol manager invokes before its static / Vite fallback.
* Manage `lobe-backend://` (or any custom scheme) transparent proxy handler registration.
* Keeps a WeakSet per session to avoid duplicate handler registration.
*/
export class BackendProxyProtocolManager {
private readonly contexts = new WeakMap<Session, BackendProxyContext>();
private readonly handledSessions = new WeakSet<Session>();
private readonly logger = createLogger('core:BackendProxyProtocolManager');
/**
* Debounce timer for authorization required notifications.
* Prevents multiple rapid 401 responses from triggering duplicate notifications.
*/
private authRequiredDebounceTimer: NodeJS.Timeout | null = null;
private pendingAuthRequiredReason: string | null = null;
private static readonly AUTH_REQUIRED_DEBOUNCE_MS = 1000;
private notifyAuthorizationRequired(reason: string) {
private notifyAuthorizationRequired() {
// Trailing-edge debounce: coalesce rapid 401 bursts and fire AFTER the burst settles.
// This ensures the IPC event is sent after the renderer has had time to mount listeners.
// The most recent reason wins — within a burst they almost always describe the same cause.
this.pendingAuthRequiredReason = reason;
if (this.authRequiredDebounceTimer) {
clearTimeout(this.authRequiredDebounceTimer);
}
this.authRequiredDebounceTimer = setTimeout(() => {
this.authRequiredDebounceTimer = null;
const finalReason = this.pendingAuthRequiredReason ?? reason;
this.pendingAuthRequiredReason = null;
this.logger.info(`Broadcasting authorizationRequired (reason=${finalReason})`);
const allWindows = BrowserWindow.getAllWindows();
for (const win of allWindows) {
if (!win.isDestroyed()) {
win.webContents.send('authorizationRequired', { reason: finalReason });
win.webContents.send('authorizationRequired');
}
}
}, BackendProxyProtocolManager.AUTH_REQUIRED_DEBOUNCE_MS);
}
/**
* Bind a session's proxy context using a remote-base-URL provider. Backend
* paths get rewritten onto the remote base; same-origin requests pass through
* (returns null so the `app://` handler falls back to its static / Vite path).
*/
registerWithRemoteBaseUrl(session: Session, options: BackendProxyRemoteBaseOptions) {
registerWithRemoteBaseUrl(
session: Session,
options: BackendProxyProtocolManagerRemoteBaseOptions,
) {
let lastRemoteBaseUrl: string | undefined;
const rewriteUrl = async (rawUrl: string) => {
@@ -101,142 +99,90 @@ export class BackendProxyProtocolManager {
this.register(session, {
getAccessToken: options.getAccessToken,
rewriteUrl,
scheme: options.scheme,
source: options.source,
});
}
/**
* Bind a session's proxy context. Subsequent backend-path requests on this
* session will be rewritten via `rewriteUrl` and have `Oidc-Auth` injected.
*/
register(session: Session, context: BackendProxyContext) {
if (!session) return;
this.contexts.set(session, context);
}
register(session: Session, options: BackendProxyProtocolManagerOptions) {
if (!session || this.handledSessions.has(session)) return;
/**
* Build an `app://` request interceptor that diverts backend-prefixed paths
* (trpc / webapi / api/auth / market) through `proxy()` against the default
* session. Plug into `RendererProtocolManager.addRequestInterceptor` so the
* protocol manager doesn't need to know what "backend" means.
*
* Returns `null` for non-backend paths (lets the fallback run). Returns a
* 502 if the backend context isn't wired up yet for backend prefixes we
* must never fall through to the SPA HTML / Vite path.
*/
createAppRequestInterceptor(): RendererRequestInterceptor {
return async (request) => {
const url = new URL(request.url);
if (!isBackendPath(url.pathname)) return null;
const logPrefix = options.source ? `[${options.source}] BackendProxy` : '[BackendProxy]';
const session = electronSession.defaultSession;
if (!session) return new Response('Backend Proxy Unavailable', { status: 502 });
const proxied = await this.proxy(request, session);
return proxied ?? new Response('Backend Proxy Unavailable', { status: 502 });
};
}
/**
* Proxy a renderer-originated request through the remote LobeHub backend.
* Returns `null` if the session has no proxy context registered yet (caller
* decides how to fall back). Throws on upstream fetch failure to mirror the
* original `protocol.handle` semantics.
*/
async proxy(request: Request, session: Session): Promise<Response | null> {
const context = this.contexts.get(session);
if (!context) return null;
const logPrefix = context.source ? `[${context.source}] BackendProxy` : '[BackendProxy]';
const rewrittenUrl = await context.rewriteUrl(request.url);
if (!rewrittenUrl) return null;
const headers = new Headers(request.headers);
const token = await context.getAccessToken();
if (token) {
headers.set('Oidc-Auth', token);
}
appendVercelCookie(headers);
const requestInit: RequestInit & { duplex?: 'half' } = {
headers,
method: request.method,
};
// Only forward body for non-GET/HEAD requests
if (request.method !== 'GET' && request.method !== 'HEAD') {
const body = request.body ?? undefined;
if (body) {
requestInit.body = body;
// Node.js (undici) requires `duplex` when sending a streaming body
requestInit.duplex = 'half';
}
}
let upstreamResponse: Response;
try {
upstreamResponse = await netFetch(rewrittenUrl, requestInit);
} catch (error) {
this.logger.error(`${logPrefix} upstream fetch failed: ${rewrittenUrl}`, error);
throw error;
}
const responseHeaders = new Headers(upstreamResponse.headers);
const allowOrigin = request.headers.get('Origin') || undefined;
if (allowOrigin) {
responseHeaders.set('Access-Control-Allow-Origin', allowOrigin);
responseHeaders.set('Access-Control-Allow-Credentials', 'true');
}
if (isDev) {
responseHeaders.set('x-dev-oidc-auth', token);
}
responseHeaders.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
responseHeaders.set('Access-Control-Allow-Headers', '*');
responseHeaders.set('X-Src-Url', rewrittenUrl);
// Re-auth prompt: rely on X-Auth-Required (set by tRPC responseMeta for UNAUTHORIZED).
// Batched tRPC responses can use HTTP 207 when calls mix success (200) and UNAUTHORIZED (401);
// checking only status === 401 misses that case and the login modal never opens.
// Other failures keep 401 without this header (e.g., invalid API keys) and must not notify here.
const authRequired = upstreamResponse.headers.get(AUTH_REQUIRED_HEADER) === 'true';
if (authRequired) {
const pathTag = (() => {
try {
return new URL(rewrittenUrl).pathname;
} catch {
return rewrittenUrl;
}
})();
const sourceTag = context.source ? `${context.source}:` : '';
const wwwAuth = upstreamResponse.headers.get('www-authenticate') ?? '';
// Clone before forwarding the body downstream — the original stream stays
// intact for the renderer. Body snippet is truncated to keep logs small
// and to avoid leaking large payloads if the server ever returns one.
let bodySnippet: string;
session.protocol.handle(options.scheme, async (request: Request): Promise<Response | null> => {
try {
bodySnippet = (await upstreamResponse.clone().text()).slice(0, 300).replaceAll(/\s+/g, ' ');
} catch (error) {
bodySnippet = `<body-read-failed:${error instanceof Error ? error.message : 'unknown'}>`;
}
const parts = [
`proxy:${sourceTag}status=${upstreamResponse.status}`,
`${request.method} ${pathTag}`,
`hadToken=${Boolean(token)}`,
];
if (wwwAuth) parts.push(`wwwAuth=${wwwAuth}`);
if (bodySnippet) parts.push(`body=${bodySnippet}`);
this.notifyAuthorizationRequired(parts.join(' '));
}
const rewrittenUrl = await options.rewriteUrl(request.url);
if (!rewrittenUrl) return null;
return new Response(upstreamResponse.body, {
headers: responseHeaders,
status: upstreamResponse.status,
statusText: upstreamResponse.statusText,
const headers = new Headers(request.headers);
const token = await options.getAccessToken();
if (token) {
headers.set('Oidc-Auth', token);
}
appendVercelCookie(headers);
const requestInit: RequestInit & { duplex?: 'half' } = {
headers,
method: request.method,
};
// Only forward body for non-GET/HEAD requests
if (request.method !== 'GET' && request.method !== 'HEAD') {
const body = request.body ?? undefined;
if (body) {
requestInit.body = body;
// Node.js (undici) requires `duplex` when sending a streaming body
requestInit.duplex = 'half';
}
}
let upstreamResponse: Response;
try {
upstreamResponse = await netFetch(rewrittenUrl, requestInit);
} catch (error) {
this.logger.error(`${logPrefix} upstream fetch failed: ${rewrittenUrl}`, error);
throw error;
}
const responseHeaders = new Headers(upstreamResponse.headers);
const allowOrigin = request.headers.get('Origin') || undefined;
if (allowOrigin) {
responseHeaders.set('Access-Control-Allow-Origin', allowOrigin);
responseHeaders.set('Access-Control-Allow-Credentials', 'true');
}
if (isDev) {
responseHeaders.set('x-dev-oidc-auth', token);
}
responseHeaders.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
responseHeaders.set('Access-Control-Allow-Headers', '*');
responseHeaders.set('X-Src-Url', rewrittenUrl);
// Re-auth prompt: rely on X-Auth-Required (set by tRPC responseMeta for UNAUTHORIZED).
// Batched tRPC responses can use HTTP 207 when calls mix success (200) and UNAUTHORIZED (401);
// checking only status === 401 misses that case and the login modal never opens.
// Other failures keep 401 without this header (e.g., invalid API keys) and must not notify here.
const authRequired = upstreamResponse.headers.get(AUTH_REQUIRED_HEADER) === 'true';
if (authRequired) {
this.notifyAuthorizationRequired();
}
return new Response(upstreamResponse.body, {
headers: responseHeaders,
status: upstreamResponse.status,
statusText: upstreamResponse.statusText,
});
} catch (error) {
this.logger.error(`${logPrefix} protocol.handle error:`, error);
throw error;
}
});
this.logger.debug(`${logPrefix} protocol handler registered for ${options.scheme}`);
this.handledSessions.add(session);
}
}
@@ -10,61 +10,41 @@ import { getExportMimeType } from '../../utils/mime';
type ResolveRendererFilePath = (url: URL) => Promise<string | null>;
/**
* Request interceptor: inspects an `app://` request and either produces a Response
* (short-circuits the pipeline) or returns `null` to let the next interceptor and
* ultimately the fallback strategy try.
*/
export type RendererRequestInterceptor = (request: Request) => Promise<Response | null>;
/**
* Fallback strategy invoked when no interceptor handled the request. Static
* (production) and Vite-proxy (development) implementations live below; the
* protocol manager is agnostic to which one is plugged in.
*/
export interface RendererFallbackStrategy {
handle: (request: Request, url: URL) => Promise<Response>;
}
const RENDERER_PROTOCOL_PRIVILEGES = {
allowServiceWorkers: true,
corsEnabled: true,
secure: true,
standard: true,
stream: true,
supportFetchAPI: true,
} as const;
interface RendererProtocolManagerOptions {
fallback: RendererFallbackStrategy;
host?: string;
rendererDir: string;
resolveRendererFilePath: ResolveRendererFilePath;
scheme?: string;
}
const RENDERER_DIR = 'renderer';
export class RendererProtocolManager {
private readonly scheme: string;
private readonly host: string;
private readonly fallback: RendererFallbackStrategy;
private readonly interceptors: RendererRequestInterceptor[] = [];
private readonly rendererDir: string;
private readonly resolveRendererFilePath: ResolveRendererFilePath;
private handlerRegistered = false;
constructor(options: RendererProtocolManagerOptions) {
this.scheme = options.scheme ?? 'app';
this.host = options.host ?? RENDERER_DIR;
this.fallback = options.fallback;
const { rendererDir, resolveRendererFilePath } = options;
this.scheme = 'app';
this.host = RENDERER_DIR;
this.rendererDir = rendererDir;
this.resolveRendererFilePath = resolveRendererFilePath;
}
/**
* Register a request interceptor that runs before the fallback strategy.
* Interceptors are invoked in registration order; the first one to return a
* non-null Response short-circuits the pipeline.
* Get the full renderer URL with scheme and host
*/
addRequestInterceptor(interceptor: RendererRequestInterceptor) {
this.interceptors.push(interceptor);
}
getRendererUrl(): string {
return `${this.scheme}://${this.host}`;
}
@@ -75,30 +55,169 @@ export class RendererProtocolManager {
scheme: this.scheme,
};
}
registerHandler() {
if (this.handlerRegistered) return;
if (!pathExistsSync(this.rendererDir)) {
createLogger('core:RendererProtocolManager').warn(
`Renderer directory not found, skip static handler: ${this.rendererDir}`,
);
return;
}
const logger = createLogger('core:RendererProtocolManager');
logger.debug(`Registering ${this.scheme}:// handler for host ${this.host}`);
logger.debug(
`Registering renderer ${this.scheme}:// handler for production export at host ${this.host}`,
);
const register = () => {
if (this.handlerRegistered) return;
protocol.handle(this.scheme, async (request) => {
const url = new URL(request.url);
const hostname = url.hostname;
const pathname = url.pathname;
const isAssetRequest = this.isAssetRequest(pathname);
const isExplicit404HtmlRequest = pathname.endsWith('/404.html');
if (url.hostname !== this.host) {
if (hostname !== this.host) {
return new Response('Not Found', { status: 404 });
}
// Pipeline: first interceptor to return a Response wins; null = pass through.
for (const interceptor of this.interceptors) {
const response = await interceptor(request);
if (response) return response;
const buildFileResponse = async (targetPath: string) => {
const fileStat = await stat(targetPath);
const totalSize = fileStat.size;
const buffer = await readFile(targetPath);
const headers = new Headers();
const mimeType = getExportMimeType(targetPath);
if (mimeType) headers.set('Content-Type', mimeType);
// Chromium media pipeline relies on byte ranges for video/audio.
headers.set('Accept-Ranges', 'bytes');
const method = request.method?.toUpperCase?.() || 'GET';
const rangeHeader = request.headers.get('range') || request.headers.get('Range');
// HEAD (no range): return only headers
if (method === 'HEAD' && !rangeHeader) {
headers.set('Content-Length', String(totalSize));
return new Response(null, { headers, status: 200 });
}
// No Range: return entire file
if (!rangeHeader) {
headers.set('Content-Length', String(buffer.byteLength));
return new Response(buffer, { headers, status: 200 });
}
// Range: bytes=start-end | bytes=-suffixLength
const match = /^bytes=(\d*)-(\d*)$/i.exec(rangeHeader.trim());
if (!match) {
headers.set('Content-Range', `bytes */${totalSize}`);
return new Response(null, {
headers,
status: 416,
statusText: 'Range Not Satisfiable',
});
}
const [, startRaw, endRaw] = match;
let start = startRaw ? Number(startRaw) : NaN;
let end = endRaw ? Number(endRaw) : NaN;
// Suffix range: bytes=-N (last N bytes)
if (!startRaw && endRaw) {
const suffixLength = Number(endRaw);
if (!Number.isFinite(suffixLength) || suffixLength <= 0) {
headers.set('Content-Range', `bytes */${totalSize}`);
return new Response(null, {
headers,
status: 416,
statusText: 'Range Not Satisfiable',
});
}
start = Math.max(totalSize - suffixLength, 0);
end = totalSize - 1;
} else {
if (!Number.isFinite(start)) start = 0;
if (!Number.isFinite(end)) end = totalSize - 1;
}
if (start < 0 || end < 0 || start > end || start >= totalSize) {
headers.set('Content-Range', `bytes */${totalSize}`);
return new Response(null, {
headers,
status: 416,
statusText: 'Range Not Satisfiable',
});
}
end = Math.min(end, totalSize - 1);
const sliced = buffer.subarray(start, end + 1);
headers.set('Content-Range', `bytes ${start}-${end}/${totalSize}`);
headers.set('Content-Length', String(sliced.byteLength));
if (method === 'HEAD') {
return new Response(null, { headers, status: 206, statusText: 'Partial Content' });
}
return new Response(sliced, { headers, status: 206, statusText: 'Partial Content' });
};
const resolveEntryFilePath = () =>
this.resolveRendererFilePath(new URL(`${this.scheme}://${this.host}/`));
let filePath = await this.resolveRendererFilePath(url);
// If the resolved file is the export 404 page, treat it as missing so we can
// fall back to the entry HTML for SPA routing (unless explicitly requested).
if (filePath && this.is404Html(filePath) && !isExplicit404HtmlRequest) {
filePath = null;
}
return this.fallback.handle(request, url);
if (!filePath) {
if (isAssetRequest) {
return new Response('File Not Found', { status: 404 });
}
// Fallback to entry HTML for unknown routes (SPA-like behavior)
filePath = await resolveEntryFilePath();
if (!filePath || this.is404Html(filePath)) {
return new Response('Render file Not Found', { status: 404 });
}
}
try {
return await buildFileResponse(filePath);
} catch (error) {
const code = (error as any).code;
if (code === 'ENOENT') {
logger.warn(`Export asset missing on disk ${filePath}, falling back`, error);
if (isAssetRequest) {
return new Response('File Not Found', { status: 404 });
}
const fallbackPath = await resolveEntryFilePath();
if (!fallbackPath || this.is404Html(fallbackPath)) {
return new Response('Render file Not Found', { status: 404 });
}
try {
return await buildFileResponse(fallbackPath);
} catch (fallbackError) {
logger.error(`Failed to serve fallback entry ${fallbackPath}:`, fallbackError);
return new Response('Internal Server Error', { status: 500 });
}
}
logger.error(`Failed to serve export asset ${filePath}:`, error);
return new Response('Internal Server Error', { status: 500 });
}
});
this.handlerRegistered = true;
@@ -108,165 +227,10 @@ export class RendererProtocolManager {
register();
} else {
// protocol.handle needs the default session, which is only available after ready
app.whenReady().then(register);
}
}
}
/**
* Production fallback: serve the renderer's static export from disk. Resolves
* the file via `resolveRendererFilePath`, falls back to the SPA entry HTML for
* unknown routes, and supports HTTP `Range` requests for media playback.
*/
export class StaticRendererFallback implements RendererFallbackStrategy {
private readonly rendererDir: string;
private readonly resolveRendererFilePath: ResolveRendererFilePath;
private readonly logger = createLogger('core:StaticRendererFallback');
constructor(rendererDir: string, resolveRendererFilePath: ResolveRendererFilePath) {
this.rendererDir = rendererDir;
this.resolveRendererFilePath = resolveRendererFilePath;
if (!pathExistsSync(this.rendererDir)) {
this.logger.warn(`Renderer directory not found: ${this.rendererDir}`);
}
}
async handle(request: Request, url: URL): Promise<Response> {
const pathname = url.pathname;
const isAssetRequest = this.isAssetRequest(pathname);
const isExplicit404HtmlRequest = pathname.endsWith('/404.html');
let filePath = await this.resolveRendererFilePath(url);
if (filePath && this.is404Html(filePath) && !isExplicit404HtmlRequest) {
filePath = null;
}
if (!filePath) {
if (isAssetRequest) {
return new Response('File Not Found', { status: 404 });
}
filePath = await this.resolveEntryFilePath(url);
if (!filePath || this.is404Html(filePath)) {
return new Response('Render file Not Found', { status: 404 });
}
}
try {
return await this.buildFileResponse(request, filePath);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
this.logger.warn(`Export asset missing on disk ${filePath}, falling back`, error);
if (isAssetRequest) {
return new Response('File Not Found', { status: 404 });
}
const fallbackPath = await this.resolveEntryFilePath(url);
if (!fallbackPath || this.is404Html(fallbackPath)) {
return new Response('Render file Not Found', { status: 404 });
}
try {
return await this.buildFileResponse(request, fallbackPath);
} catch (fallbackError) {
this.logger.error(`Failed to serve fallback entry ${fallbackPath}:`, fallbackError);
return new Response('Internal Server Error', { status: 500 });
}
}
this.logger.error(`Failed to serve export asset ${filePath}:`, error);
return new Response('Internal Server Error', { status: 500 });
}
}
private resolveEntryFilePath(url: URL) {
return this.resolveRendererFilePath(new URL(`${url.protocol}//${url.host}/`));
}
private async buildFileResponse(request: Request, targetPath: string): Promise<Response> {
const fileStat = await stat(targetPath);
const totalSize = fileStat.size;
const buffer = await readFile(targetPath);
const headers = new Headers();
const mimeType = getExportMimeType(targetPath);
if (mimeType) headers.set('Content-Type', mimeType);
// Chromium media pipeline relies on byte ranges for video/audio.
headers.set('Accept-Ranges', 'bytes');
const method = request.method?.toUpperCase?.() || 'GET';
const rangeHeader = request.headers.get('range') || request.headers.get('Range');
if (method === 'HEAD' && !rangeHeader) {
headers.set('Content-Length', String(totalSize));
return new Response(null, { headers, status: 200 });
}
if (!rangeHeader) {
headers.set('Content-Length', String(buffer.byteLength));
return new Response(buffer, { headers, status: 200 });
}
const match = /^bytes=(\d*)-(\d*)$/i.exec(rangeHeader.trim());
if (!match) {
headers.set('Content-Range', `bytes */${totalSize}`);
return new Response(null, {
headers,
status: 416,
statusText: 'Range Not Satisfiable',
});
}
const [, startRaw, endRaw] = match;
let start = startRaw ? Number(startRaw) : Number.NaN;
let end = endRaw ? Number(endRaw) : Number.NaN;
// Suffix range: bytes=-N (last N bytes)
if (!startRaw && endRaw) {
const suffixLength = Number(endRaw);
if (!Number.isFinite(suffixLength) || suffixLength <= 0) {
headers.set('Content-Range', `bytes */${totalSize}`);
return new Response(null, {
headers,
status: 416,
statusText: 'Range Not Satisfiable',
});
}
start = Math.max(totalSize - suffixLength, 0);
end = totalSize - 1;
} else {
if (!Number.isFinite(start)) start = 0;
if (!Number.isFinite(end)) end = totalSize - 1;
}
if (start < 0 || end < 0 || start > end || start >= totalSize) {
headers.set('Content-Range', `bytes */${totalSize}`);
return new Response(null, {
headers,
status: 416,
statusText: 'Range Not Satisfiable',
});
}
end = Math.min(end, totalSize - 1);
const sliced = buffer.subarray(start, end + 1);
headers.set('Content-Range', `bytes ${start}-${end}/${totalSize}`);
headers.set('Content-Length', String(sliced.byteLength));
if (method === 'HEAD') {
return new Response(null, { headers, status: 206, statusText: 'Partial Content' });
}
return new Response(sliced, { headers, status: 206, statusText: 'Partial Content' });
}
private isAssetRequest(pathname: string) {
const normalizedPathname = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
@@ -285,82 +249,3 @@ export class StaticRendererFallback implements RendererFallbackStrategy {
return path.basename(filePath) === '404.html';
}
}
class Semaphore {
private active = 0;
private readonly waiters: Array<() => void> = [];
constructor(private readonly max: number) {}
async acquire(): Promise<() => void> {
if (this.active >= this.max) {
await new Promise<void>((resolve) => this.waiters.push(resolve));
}
this.active += 1;
let released = false;
return () => {
if (released) return;
released = true;
this.active -= 1;
this.waiters.shift()?.();
};
}
}
const VITE_FETCH_CONCURRENCY = 64;
export class ViteRendererFallback implements RendererFallbackStrategy {
private readonly viteOrigin: string;
private readonly logger = createLogger('core:ViteRendererFallback');
private readonly gate = new Semaphore(VITE_FETCH_CONCURRENCY);
constructor(viteOrigin: string) {
this.viteOrigin = viteOrigin.replace(/\/+$/, '');
}
async handle(request: Request, url: URL): Promise<Response> {
const target = `${this.viteOrigin}${url.pathname}${url.search}`;
// Strip Host so fetch derives it from the target URL (otherwise Vite
// sees `Host: renderer` and middleware that keys off Host can misbehave).
const headers = new Headers(request.headers);
headers.delete('host');
const init: RequestInit & { duplex?: 'half' } = {
headers,
method: request.method,
};
if (request.method !== 'GET' && request.method !== 'HEAD' && request.body) {
init.body = request.body;
init.duplex = 'half';
}
const release = await this.gate.acquire();
try {
const response = await fetch(target, init);
return this.releaseOnBodyDone(response, release);
} catch (error) {
release();
this.logger.error(`Vite dev server fetch failed: ${target}`, error);
return new Response('Vite Dev Server Unavailable', { status: 502 });
}
}
private releaseOnBodyDone(response: Response, release: () => void): Response {
if (!response.body) {
release();
return response;
}
const passthrough = new TransformStream();
void response.body.pipeTo(passthrough.writable).then(release, release);
return new Response(passthrough.readable, {
headers: response.headers,
status: response.status,
statusText: response.statusText,
});
}
}
@@ -7,12 +7,7 @@ import { isDev } from '@/const/env';
import { getDesktopEnv } from '@/env';
import { createLogger } from '@/utils/logger';
import {
RendererProtocolManager,
type RendererRequestInterceptor,
StaticRendererFallback,
ViteRendererFallback,
} from './RendererProtocolManager';
import { RendererProtocolManager } from './RendererProtocolManager';
const logger = createLogger('core:RendererUrlManager');
@@ -25,11 +20,12 @@ const POPUP_ENTRY_HTML = path.join(rendererDir, 'apps', 'desktop', 'popup.html')
export class RendererUrlManager {
private readonly rendererProtocolManager: RendererProtocolManager;
private readonly rendererStaticOverride = getDesktopEnv().DESKTOP_RENDERER_STATIC;
private readonly rendererLoadedUrl: string;
private rendererLoadedUrl: string;
constructor() {
this.rendererProtocolManager = new RendererProtocolManager({
fallback: this.pickFallback(),
rendererDir,
resolveRendererFilePath: this.resolveRendererFilePath,
});
this.rendererLoadedUrl = this.rendererProtocolManager.getRendererUrl();
@@ -39,22 +35,31 @@ export class RendererUrlManager {
return this.rendererProtocolManager.protocolScheme;
}
addRequestInterceptor(interceptor: RendererRequestInterceptor) {
this.rendererProtocolManager.addRequestInterceptor(interceptor);
}
/**
* Register the `app://` protocol handler. Idempotent safe to call after
* interceptors are wired.
* Configure renderer loading strategy for dev/prod
*/
configureRendererLoader() {
this.rendererProtocolManager.registerHandler();
const electronRendererUrl = process.env['ELECTRON_RENDERER_URL'];
if (isDev && !this.rendererStaticOverride && electronRendererUrl) {
this.rendererLoadedUrl = electronRendererUrl;
this.setupDevRenderer();
return;
}
if (isDev && !this.rendererStaticOverride && !electronRendererUrl) {
logger.warn('Dev mode: ELECTRON_RENDERER_URL not set, falling back to protocol handler');
}
if (isDev && this.rendererStaticOverride) {
logger.warn('Dev mode: DESKTOP_RENDERER_STATIC enabled, using static renderer handler');
}
this.setupProdRenderer();
}
/**
* Build a renderer URL. Always uses `app://renderer` so dev and prod share
* the same origin (cookies, storage, service-workers). Dev requests are
* proxied to the Vite dev server inside the `app://` handler.
* Build renderer URL for dev/prod.
*/
buildRendererUrl(path: string): string {
const cleanPath = path.startsWith('/') ? path : `/${path}`;
@@ -64,10 +69,7 @@ export class RendererUrlManager {
}
/**
* Resolve a renderer file path against the static export. Used by the
* production fallback; left on the manager so the desktop-specific entry
* HTML mappings stay in one place.
*
* Resolve renderer file path in production.
* Static assets map directly; /overlay routes fall back to overlay.html;
* popup routes go to popup.html; all other routes fall back to index.html (SPA).
*/
@@ -94,26 +96,20 @@ export class RendererUrlManager {
return SPA_ENTRY_HTML;
};
private pickFallback() {
const electronRendererUrl = process.env['ELECTRON_RENDERER_URL'];
/**
* Development: use electron-vite renderer dev server
*/
private setupDevRenderer() {
logger.info(
`Development mode: renderer served from electron-vite dev server at ${this.rendererLoadedUrl}`,
);
}
if (isDev && !this.rendererStaticOverride && electronRendererUrl) {
logger.info(
`Development mode: app:// requests proxied to Vite dev server at ${electronRendererUrl}`,
);
return new ViteRendererFallback(electronRendererUrl);
}
if (isDev && !this.rendererStaticOverride && !electronRendererUrl) {
logger.warn(
'Dev mode: ELECTRON_RENDERER_URL not set, falling back to static renderer handler',
);
}
if (isDev && this.rendererStaticOverride) {
logger.warn('Dev mode: DESKTOP_RENDERER_STATIC enabled, using static renderer handler');
}
return new StaticRendererFallback(rendererDir, this.resolveRendererFilePath);
/**
* Production: serve static renderer assets via protocol handler
*/
private setupProdRenderer() {
this.rendererLoadedUrl = this.rendererProtocolManager.getRendererUrl();
this.rendererProtocolManager.registerHandler();
}
}
@@ -1,5 +1,5 @@
import { AUTH_REQUIRED_HEADER } from '@lobechat/desktop-bridge';
import { BrowserWindow, session as electronSession } from 'electron';
import { BrowserWindow } from 'electron';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { BackendProxyProtocolManager } from '../BackendProxyProtocolManager';
@@ -10,6 +10,19 @@ interface RequestInitWithDuplex extends RequestInit {
type FetchMock = (input: RequestInfo | URL, init?: RequestInitWithDuplex) => Promise<Response>;
const { mockProtocol, protocolHandlerRef } = vi.hoisted(() => {
const protocolHandlerRef = { current: null as any };
return {
mockProtocol: {
handle: vi.fn((_scheme: string, handler: any) => {
protocolHandlerRef.current = handler;
}),
},
protocolHandlerRef,
};
});
vi.mock('electron-is', () => ({
dev: vi.fn(() => false),
macOS: vi.fn(() => false),
@@ -35,23 +48,21 @@ vi.mock('electron', () => ({
global.fetch(input as any, init as any),
),
},
session: {
defaultSession: {},
},
}));
describe('BackendProxyProtocolManager', () => {
beforeEach(() => {
vi.clearAllMocks();
protocolHandlerRef.current = null;
});
afterEach(() => {
vi.useRealTimers();
});
it('rewrites url to remote base and injects Oidc-Auth via proxy()', async () => {
it('should rewrite url to remote base and inject Oidc-Auth token', async () => {
const manager = new BackendProxyProtocolManager();
const session = {} as any;
const session = { protocol: mockProtocol } as any;
const fetchMock = vi.fn<FetchMock>(async () => {
return new Response('ok', {
@@ -65,19 +76,19 @@ describe('BackendProxyProtocolManager', () => {
manager.registerWithRemoteBaseUrl(session, {
getAccessToken: async () => 'token-123',
getRemoteBaseUrl: async () => 'https://remote.example.com',
scheme: 'lobe-backend',
source: 'main',
});
const response = await manager.proxy(
{
headers: new Headers({ 'Origin': 'app://renderer', 'X-Test': '1' }),
method: 'GET',
url: 'app://renderer/trpc/hello?batch=1',
} as any,
session,
);
const handler = protocolHandlerRef.current;
expect(mockProtocol.handle).toHaveBeenCalledWith('lobe-backend', expect.any(Function));
const response = await handler({
headers: new Headers({ 'Origin': 'app://desktop', 'X-Test': '1' }),
method: 'GET',
url: 'lobe-backend://app/trpc/hello?batch=1',
} as any);
expect(response).not.toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(1);
const [calledUrl, init] = fetchMock.mock.calls[0]!;
expect(calledUrl).toBe('https://remote.example.com/trpc/hello?batch=1');
@@ -89,18 +100,16 @@ describe('BackendProxyProtocolManager', () => {
expect(headers.get('Oidc-Auth')).toBe('token-123');
expect(headers.get('X-Test')).toBe('1');
expect(response!.status).toBe(200);
expect(response!.headers.get('X-Src-Url')).toBe(
'https://remote.example.com/trpc/hello?batch=1',
);
expect(response!.headers.get('Access-Control-Allow-Origin')).toBe('app://renderer');
expect(response!.headers.get('Access-Control-Allow-Credentials')).toBe('true');
expect(await response!.text()).toBe('ok');
expect(response.status).toBe(200);
expect(response.headers.get('X-Src-Url')).toBe('https://remote.example.com/trpc/hello?batch=1');
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('app://desktop');
expect(response.headers.get('Access-Control-Allow-Credentials')).toBe('true');
expect(await response.text()).toBe('ok');
});
it('forwards body and sets duplex for non-GET requests', async () => {
it('should forward body and set duplex for non-GET requests', async () => {
const manager = new BackendProxyProtocolManager();
const session = {} as any;
const session = { protocol: mockProtocol } as any;
const fetchMock = vi.fn<FetchMock>(async () => new Response('ok', { status: 200 }));
vi.stubGlobal('fetch', fetchMock as any);
@@ -108,18 +117,18 @@ describe('BackendProxyProtocolManager', () => {
manager.registerWithRemoteBaseUrl(session, {
getAccessToken: async () => null,
getRemoteBaseUrl: async () => 'https://remote.example.com',
scheme: 'lobe-backend',
});
await manager.proxy(
{
headers: new Headers(),
method: 'POST',
// body doesn't have to be a real stream for this unit test; manager only checks truthiness
body: 'payload' as any,
url: 'app://renderer/api/upload',
} as any,
session,
);
const handler = protocolHandlerRef.current;
await handler({
headers: new Headers(),
method: 'POST',
// body doesn't have to be a real stream for this unit test; manager only checks truthiness
body: 'payload' as any,
url: 'lobe-backend://app/api/upload',
} as any);
const [, init] = fetchMock.mock.calls[0]!;
expect(init).toBeDefined();
@@ -130,9 +139,9 @@ describe('BackendProxyProtocolManager', () => {
expect(init.duplex).toBe('half');
});
it('returns null when remote base url is missing', async () => {
it('should return null when remote base url is missing', async () => {
const manager = new BackendProxyProtocolManager();
const session = {} as any;
const session = { protocol: mockProtocol } as any;
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock as any);
@@ -140,20 +149,19 @@ describe('BackendProxyProtocolManager', () => {
manager.registerWithRemoteBaseUrl(session, {
getAccessToken: async () => 'token',
getRemoteBaseUrl: async () => null,
scheme: 'lobe-backend',
});
const res = await manager.proxy(
{ method: 'GET', headers: new Headers(), url: 'app://renderer/trpc' } as any,
session,
);
const handler = protocolHandlerRef.current;
const res = await handler({ method: 'GET', url: 'lobe-backend://app/trpc' } as any);
expect(res).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it('returns null when request url is already the remote origin', async () => {
it('should return null when request url is already the remote origin', async () => {
const manager = new BackendProxyProtocolManager();
const session = {} as any;
const session = { protocol: mockProtocol } as any;
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock as any);
@@ -161,24 +169,22 @@ describe('BackendProxyProtocolManager', () => {
manager.registerWithRemoteBaseUrl(session, {
getAccessToken: async () => null,
getRemoteBaseUrl: async () => 'https://remote.example.com',
scheme: 'lobe-backend',
});
const res = await manager.proxy(
{
method: 'GET',
headers: new Headers(),
url: 'https://remote.example.com/trpc/hello?x=1',
} as any,
session,
);
const handler = protocolHandlerRef.current;
const res = await handler({
method: 'GET',
url: 'https://remote.example.com/trpc/hello?x=1',
} as any);
expect(res).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it('returns null when rewrite fails (invalid remote base url)', async () => {
it('should return null when rewrite fails (invalid remote base url)', async () => {
const manager = new BackendProxyProtocolManager();
const session = {} as any;
const session = { protocol: mockProtocol } as any;
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock as any);
@@ -186,20 +192,19 @@ describe('BackendProxyProtocolManager', () => {
manager.registerWithRemoteBaseUrl(session, {
getAccessToken: async () => null,
getRemoteBaseUrl: async () => 'not-a-url',
scheme: 'lobe-backend',
});
const res = await manager.proxy(
{ method: 'GET', headers: new Headers(), url: 'app://renderer/trpc' } as any,
session,
);
const handler = protocolHandlerRef.current;
const res = await handler({ method: 'GET', url: 'lobe-backend://app/trpc' } as any);
expect(res).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it('throws when upstream fetch throws', async () => {
it('should throw when upstream fetch throws', async () => {
const manager = new BackendProxyProtocolManager();
const session = {} as any;
const session = { protocol: mockProtocol } as any;
const fetchMock = vi.fn(async () => {
throw new Error('network down');
@@ -209,21 +214,20 @@ describe('BackendProxyProtocolManager', () => {
manager.registerWithRemoteBaseUrl(session, {
getAccessToken: async () => null,
getRemoteBaseUrl: async () => 'https://remote.example.com',
scheme: 'lobe-backend',
});
const handler = protocolHandlerRef.current;
await expect(
manager.proxy(
{
headers: new Headers(),
method: 'GET',
url: 'app://renderer/trpc/hello',
} as any,
session,
),
handler({
headers: new Headers(),
method: 'GET',
url: 'lobe-backend://app/trpc/hello',
} as any),
).rejects.toThrow('network down');
});
it('broadcasts authorizationRequired when X-Auth-Required is set on HTTP 207 (batched tRPC)', async () => {
it('should broadcast authorizationRequired when X-Auth-Required is set on HTTP 207 (batched tRPC)', async () => {
vi.useFakeTimers();
const send = vi.fn();
vi.mocked(BrowserWindow.getAllWindows).mockReturnValue([
@@ -231,7 +235,7 @@ describe('BackendProxyProtocolManager', () => {
] as any);
const manager = new BackendProxyProtocolManager();
const session = {} as any;
const session = { protocol: mockProtocol } as any;
const headers = new Headers({
[AUTH_REQUIRED_HEADER]: 'true',
@@ -245,133 +249,18 @@ describe('BackendProxyProtocolManager', () => {
manager.registerWithRemoteBaseUrl(session, {
getAccessToken: async () => null,
getRemoteBaseUrl: async () => 'https://remote.example.com',
scheme: 'lobe-backend',
});
await manager.proxy(
{
headers: new Headers(),
method: 'GET',
url: 'app://renderer/trpc/lambda/batch?batch=1',
} as any,
session,
);
const handler = protocolHandlerRef.current;
await handler({
headers: new Headers(),
method: 'GET',
url: 'lobe-backend://app/trpc/lambda/batch?batch=1',
} as any);
expect(send).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1000);
expect(send).toHaveBeenCalledWith(
'authorizationRequired',
expect.objectContaining({
reason: expect.stringContaining('status=207'),
}),
);
});
it('captures www-authenticate, body snippet and hadToken in reason on 401', async () => {
vi.useFakeTimers();
const send = vi.fn();
vi.mocked(BrowserWindow.getAllWindows).mockReturnValue([
{ isDestroyed: () => false, webContents: { send } },
] as any);
const manager = new BackendProxyProtocolManager();
const session = {} as any;
const upstreamBody = JSON.stringify({
error: { json: { data: { code: 'UNAUTHORIZED' }, message: 'token expired at 2026-06-09' } },
});
const headers = new Headers({
[AUTH_REQUIRED_HEADER]: 'true',
'Content-Type': 'application/json',
'www-authenticate': 'Bearer error="invalid_token", error_description="expired"',
});
const fetchMock = vi.fn<FetchMock>(
async () => new Response(upstreamBody, { headers, status: 401, statusText: 'Unauthorized' }),
);
vi.stubGlobal('fetch', fetchMock as any);
manager.registerWithRemoteBaseUrl(session, {
getAccessToken: async () => 'fake-token',
getRemoteBaseUrl: async () => 'https://remote.example.com',
});
const response = await manager.proxy(
{
headers: new Headers(),
method: 'POST',
url: 'app://renderer/trpc/lambda/me',
} as any,
session,
);
// Original body is still readable by the downstream caller — clone() must not consume it.
expect(await response!.text()).toBe(upstreamBody);
await vi.advanceTimersByTimeAsync(1000);
expect(send).toHaveBeenCalledTimes(1);
const [, payload] = send.mock.calls[0];
expect(payload.reason).toContain('status=401');
expect(payload.reason).toContain('POST /trpc/lambda/me');
expect(payload.reason).toContain('hadToken=true');
expect(payload.reason).toContain('wwwAuth=Bearer error="invalid_token"');
expect(payload.reason).toContain('UNAUTHORIZED');
expect(payload.reason).toContain('token expired');
});
describe('createAppRequestInterceptor', () => {
it('returns null for non-backend paths', async () => {
const manager = new BackendProxyProtocolManager();
const interceptor = manager.createAppRequestInterceptor();
const res = await interceptor({
headers: new Headers(),
method: 'GET',
url: 'app://renderer/settings',
} as any);
expect(res).toBeNull();
});
it('returns 502 for backend paths when default session has no context', async () => {
// electronSession.defaultSession is the empty {} mock; no register() was called.
void electronSession.defaultSession;
const manager = new BackendProxyProtocolManager();
const interceptor = manager.createAppRequestInterceptor();
const res = await interceptor({
headers: new Headers(),
method: 'GET',
url: 'app://renderer/trpc/hello',
} as any);
expect(res).not.toBeNull();
expect(res!.status).toBe(502);
});
it('proxies backend paths through the registered default-session context', async () => {
const fetchMock = vi.fn<FetchMock>(async () => new Response('proxied', { status: 200 }));
vi.stubGlobal('fetch', fetchMock as any);
const manager = new BackendProxyProtocolManager();
manager.registerWithRemoteBaseUrl(electronSession.defaultSession as any, {
getAccessToken: async () => null,
getRemoteBaseUrl: async () => 'https://remote.example.com',
});
const interceptor = manager.createAppRequestInterceptor();
const res = await interceptor({
headers: new Headers(),
method: 'GET',
url: 'app://renderer/trpc/hello?batch=1',
} as any);
expect(res).not.toBeNull();
expect(res!.status).toBe(200);
expect(await res!.text()).toBe('proxied');
expect(fetchMock).toHaveBeenCalledWith(
'https://remote.example.com/trpc/hello?batch=1',
expect.objectContaining({ method: 'GET' }),
);
});
expect(send).toHaveBeenCalledWith('authorizationRequired');
});
});
@@ -1,41 +1,27 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
RendererProtocolManager,
StaticRendererFallback,
ViteRendererFallback,
} from '../RendererProtocolManager';
import { RendererProtocolManager } from '../RendererProtocolManager';
const {
mockApp,
mockFetch,
mockPathExistsSync,
mockProtocol,
mockReadFile,
mockStat,
protocolHandlerRef,
} = vi.hoisted(() => {
const protocolHandlerRef = { current: null as any };
const { mockApp, mockPathExistsSync, mockProtocol, mockReadFile, mockStat, protocolHandlerRef } =
vi.hoisted(() => {
const protocolHandlerRef = { current: null as any };
return {
mockApp: {
isReady: vi.fn().mockReturnValue(true),
whenReady: vi.fn().mockResolvedValue(undefined),
},
mockFetch: vi.fn(),
mockPathExistsSync: vi.fn().mockReturnValue(true),
mockProtocol: {
handle: vi.fn((_scheme: string, handler: any) => {
protocolHandlerRef.current = handler;
}),
},
mockReadFile: vi.fn(),
mockStat: vi.fn(),
protocolHandlerRef,
};
});
vi.stubGlobal('fetch', mockFetch);
return {
mockApp: {
isReady: vi.fn().mockReturnValue(true),
whenReady: vi.fn().mockResolvedValue(undefined),
},
mockPathExistsSync: vi.fn().mockReturnValue(true),
mockProtocol: {
handle: vi.fn((_scheme: string, handler: any) => {
protocolHandlerRef.current = handler;
}),
},
mockReadFile: vi.fn(),
mockStat: vi.fn(),
protocolHandlerRef,
};
});
vi.mock('electron', () => ({
app: mockApp,
@@ -60,7 +46,7 @@ vi.mock('@/utils/logger', () => ({
}),
}));
describe('RendererProtocolManager + StaticRendererFallback', () => {
describe('RendererProtocolManager', () => {
beforeEach(() => {
vi.clearAllMocks();
protocolHandlerRef.current = null;
@@ -73,14 +59,7 @@ describe('RendererProtocolManager + StaticRendererFallback', () => {
protocolHandlerRef.current = null;
});
const buildStaticManager = (resolve: (url: URL) => Promise<string | null>) => {
const fallback = new StaticRendererFallback('/export', resolve);
const manager = new RendererProtocolManager({ fallback });
manager.registerHandler();
return manager;
};
it('falls back to entry HTML when resolve returns 404.html for non-asset routes', async () => {
it('should fall back to entry HTML when resolve returns 404.html for non-asset routes', async () => {
const resolveRendererFilePath = vi.fn(async (url: URL) => {
if (url.pathname === '/missing') return '/export/404.html';
if (url.pathname === '/') return '/export/index.html';
@@ -88,7 +67,12 @@ describe('RendererProtocolManager + StaticRendererFallback', () => {
});
mockReadFile.mockImplementation(async (path: string) => Buffer.from(`content:${path}`));
buildStaticManager(resolveRendererFilePath);
const manager = new RendererProtocolManager({
rendererDir: '/export',
resolveRendererFilePath,
});
manager.registerHandler();
expect(mockProtocol.handle).toHaveBeenCalled();
const handler = protocolHandlerRef.current;
@@ -108,7 +92,7 @@ describe('RendererProtocolManager + StaticRendererFallback', () => {
expect(response.status).toBe(200);
});
it('serves 404.html when explicitly requested', async () => {
it('should serve 404.html when explicitly requested', async () => {
const resolveRendererFilePath = vi.fn(async (url: URL) => {
if (url.pathname === '/404.html') return '/export/404.html';
if (url.pathname === '/') return '/export/index.html';
@@ -116,7 +100,12 @@ describe('RendererProtocolManager + StaticRendererFallback', () => {
});
mockReadFile.mockImplementation(async (path: string) => Buffer.from(`content:${path}`));
buildStaticManager(resolveRendererFilePath);
const manager = new RendererProtocolManager({
rendererDir: '/export',
resolveRendererFilePath,
});
manager.registerHandler();
const handler = protocolHandlerRef.current;
const response = await handler({
@@ -130,30 +119,36 @@ describe('RendererProtocolManager + StaticRendererFallback', () => {
expect(response.status).toBe(200);
});
it('returns 404 for missing asset requests without fallback', async () => {
it('should return 404 for missing asset requests without fallback', async () => {
const resolveRendererFilePath = vi.fn(async (_url: URL) => null);
buildStaticManager(resolveRendererFilePath);
const manager = new RendererProtocolManager({
rendererDir: '/export',
resolveRendererFilePath,
});
manager.registerHandler();
const handler = protocolHandlerRef.current;
const response = await handler({
headers: new Headers(),
method: 'GET',
url: 'app://renderer/logo.png',
} as any);
const response = await handler({ url: 'app://renderer/logo.png' } as any);
expect(resolveRendererFilePath).toHaveBeenCalledTimes(1);
expect(response.status).toBe(404);
});
it('supports Range requests for media assets', async () => {
it('should support Range requests for media assets', async () => {
const resolveRendererFilePath = vi.fn(async (_url: URL) => '/export/intro-video.mp4');
const payload = Buffer.from('0123456789');
mockStat.mockImplementation(async () => ({ size: payload.length }));
mockReadFile.mockImplementation(async () => payload);
buildStaticManager(resolveRendererFilePath);
const manager = new RendererProtocolManager({
rendererDir: '/export',
resolveRendererFilePath,
});
manager.registerHandler();
const handler = protocolHandlerRef.current;
const response = await handler({
@@ -171,126 +166,4 @@ describe('RendererProtocolManager + StaticRendererFallback', () => {
const buf = Buffer.from(await response.arrayBuffer());
expect(buf.toString()).toBe('01');
});
it('runs interceptors before the fallback and short-circuits on first non-null Response', async () => {
const resolveRendererFilePath = vi.fn(async () => '/export/index.html');
mockReadFile.mockImplementation(async () => Buffer.from('static'));
const fallback = new StaticRendererFallback('/export', resolveRendererFilePath);
const manager = new RendererProtocolManager({ fallback });
manager.addRequestInterceptor(async () => null);
manager.addRequestInterceptor(async (request) =>
new URL(request.url).pathname === '/trpc/hello'
? new Response('intercepted', { status: 200 })
: null,
);
manager.registerHandler();
const handler = protocolHandlerRef.current;
const intercepted = await handler({
headers: new Headers(),
method: 'GET',
url: 'app://renderer/trpc/hello',
} as any);
expect(intercepted.status).toBe(200);
expect(await intercepted.text()).toBe('intercepted');
expect(resolveRendererFilePath).not.toHaveBeenCalled();
const fallthrough = await handler({
headers: new Headers(),
method: 'GET',
url: 'app://renderer/anything',
} as any);
expect(fallthrough.status).toBe(200);
expect(await fallthrough.text()).toBe('static');
});
it('returns 404 for cross-host requests', async () => {
const resolveRendererFilePath = vi.fn(async () => '/export/index.html');
buildStaticManager(resolveRendererFilePath);
const handler = protocolHandlerRef.current;
const response = await handler({
headers: new Headers(),
method: 'GET',
url: 'app://elsewhere/index.html',
} as any);
expect(response.status).toBe(404);
expect(resolveRendererFilePath).not.toHaveBeenCalled();
});
});
describe('ViteRendererFallback', () => {
beforeEach(() => {
vi.clearAllMocks();
protocolHandlerRef.current = null;
mockApp.isReady.mockReturnValue(true);
});
it('forwards GET requests to the Vite origin preserving pathname + search', async () => {
mockFetch.mockResolvedValue(new Response('vite-served', { status: 200 }));
const fallback = new ViteRendererFallback('http://localhost:5173');
const manager = new RendererProtocolManager({ fallback });
manager.registerHandler();
const handler = protocolHandlerRef.current;
const response = await handler({
headers: new Headers({ Accept: 'text/html' }),
method: 'GET',
url: 'app://renderer/src/main.tsx?t=12345',
} as any);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [target, init] = mockFetch.mock.calls[0]!;
expect(target).toBe('http://localhost:5173/src/main.tsx?t=12345');
expect((init as RequestInit).method).toBe('GET');
const headers = (init as RequestInit).headers as Headers;
expect(headers.get('Accept')).toBe('text/html');
expect(headers.get('Host')).toBeNull();
expect(response.status).toBe(200);
expect(await response.text()).toBe('vite-served');
});
it('forwards body and sets duplex for non-GET requests', async () => {
mockFetch.mockResolvedValue(new Response('ok', { status: 200 }));
const fallback = new ViteRendererFallback('http://localhost:5173/');
const manager = new RendererProtocolManager({ fallback });
manager.registerHandler();
const handler = protocolHandlerRef.current;
await handler({
headers: new Headers(),
method: 'POST',
body: 'payload' as any,
url: 'app://renderer/__hmr',
} as any);
const [target, init] = mockFetch.mock.calls[0]!;
expect(target).toBe('http://localhost:5173/__hmr');
expect((init as RequestInit & { duplex?: string }).duplex).toBe('half');
expect((init as any).body).toBe('payload');
});
it('returns 502 when fetch throws', async () => {
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
const fallback = new ViteRendererFallback('http://localhost:5173');
const manager = new RendererProtocolManager({ fallback });
manager.registerHandler();
const handler = protocolHandlerRef.current;
const response = await handler({
headers: new Headers(),
method: 'GET',
url: 'app://renderer/@vite/client',
} as any);
expect(response.status).toBe(502);
});
});
@@ -1,18 +1,14 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mockPathExistsSync = vi.fn();
const mockProtocolHandle = vi.fn();
vi.mock('electron', () => ({
app: {
isReady: vi.fn(() => true),
whenReady: vi.fn(() => Promise.resolve()),
},
net: {
fetch: vi.fn(),
},
protocol: {
handle: mockProtocolHandle,
handle: vi.fn(),
},
}));
@@ -49,7 +45,6 @@ describe('RendererUrlManager', () => {
beforeEach(() => {
vi.clearAllMocks();
mockPathExistsSync.mockReset();
mockProtocolHandle.mockReset();
mockIsDev = false;
delete process.env['ELECTRON_RENDERER_URL'];
});
@@ -84,39 +79,8 @@ describe('RendererUrlManager', () => {
});
});
describe('buildRendererUrl', () => {
it('always returns app://renderer regardless of dev/prod', async () => {
const { RendererUrlManager } = await import('../RendererUrlManager');
const prodManager = new RendererUrlManager();
expect(prodManager.buildRendererUrl('/')).toBe('app://renderer/');
expect(prodManager.buildRendererUrl('/settings')).toBe('app://renderer/settings');
mockIsDev = true;
process.env['ELECTRON_RENDERER_URL'] = 'http://localhost:5173';
const devManager = new RendererUrlManager();
expect(devManager.buildRendererUrl('/')).toBe('app://renderer/');
expect(devManager.buildRendererUrl('/settings')).toBe('app://renderer/settings');
});
it('prefixes a slash when the input lacks one', async () => {
const { RendererUrlManager } = await import('../RendererUrlManager');
const manager = new RendererUrlManager();
expect(manager.buildRendererUrl('settings')).toBe('app://renderer/settings');
});
});
describe('configureRendererLoader', () => {
it('registers the app:// protocol handler in prod', async () => {
mockIsDev = false;
const { RendererUrlManager } = await import('../RendererUrlManager');
const manager = new RendererUrlManager();
manager.configureRendererLoader();
expect(mockProtocolHandle).toHaveBeenCalledTimes(1);
expect(mockProtocolHandle.mock.calls[0][0]).toBe('app');
});
it('registers the app:// protocol handler in dev (Vite fallback)', async () => {
describe('configureRendererLoader (dev mode)', () => {
it('should use ELECTRON_RENDERER_URL when available in dev mode', async () => {
mockIsDev = true;
process.env['ELECTRON_RENDERER_URL'] = 'http://localhost:5173';
@@ -124,20 +88,34 @@ describe('RendererUrlManager', () => {
const manager = new RendererUrlManager();
manager.configureRendererLoader();
expect(mockProtocolHandle).toHaveBeenCalledTimes(1);
expect(mockProtocolHandle.mock.calls[0][0]).toBe('app');
expect(manager.buildRendererUrl('/')).toBe('http://localhost:5173/');
expect(manager.buildRendererUrl('/settings')).toBe('http://localhost:5173/settings');
});
it('still registers in dev when ELECTRON_RENDERER_URL is missing (static fallback)', async () => {
it('should normalize trailing slashes from ELECTRON_RENDERER_URL', async () => {
mockIsDev = true;
process.env['ELECTRON_RENDERER_URL'] = 'http://localhost:5173/';
const { RendererUrlManager } = await import('../RendererUrlManager');
const manager = new RendererUrlManager();
manager.configureRendererLoader();
expect(mockProtocolHandle).toHaveBeenCalledTimes(1);
expect(manager.buildRendererUrl('/')).toBe('http://localhost:5173/');
expect(manager.buildRendererUrl('/overlay')).toBe('http://localhost:5173/overlay');
});
it('uses static fallback when DESKTOP_RENDERER_STATIC overrides ELECTRON_RENDERER_URL', async () => {
it('should fall back to protocol handler when ELECTRON_RENDERER_URL is not set', async () => {
mockIsDev = true;
const { RendererUrlManager } = await import('../RendererUrlManager');
const manager = new RendererUrlManager();
mockPathExistsSync.mockReturnValue(true);
manager.configureRendererLoader();
expect(manager.buildRendererUrl('/')).toBe('app://renderer/');
});
it('should use protocol handler when DESKTOP_RENDERER_STATIC is enabled regardless of ELECTRON_RENDERER_URL', async () => {
mockIsDev = true;
process.env['ELECTRON_RENDERER_URL'] = 'http://localhost:5173';
@@ -146,10 +124,10 @@ describe('RendererUrlManager', () => {
const { RendererUrlManager } = await import('../RendererUrlManager');
const manager = new RendererUrlManager();
mockPathExistsSync.mockReturnValue(true);
manager.configureRendererLoader();
expect(manager.buildRendererUrl('/')).toBe('app://renderer/');
expect(mockProtocolHandle).toHaveBeenCalledTimes(1);
});
});
});
-5
View File
@@ -70,11 +70,6 @@ export const getDesktopEnv = memoize(() =>
// escape hatch: allow testing static renderer in dev via env
DESKTOP_RENDERER_STATIC: envBoolean(false),
// device gateway url override (dev: point at a local `wrangler dev` instance,
// e.g. http://localhost:8787). Falls back to the stored value, then the
// production gateway.
DEVICE_GATEWAY_URL: z.string().url().optional(),
// Force use dev-app-update.yml even in packaged app (for testing updates)
FORCE_DEV_UPDATE_CONFIG: envBoolean(false),
-2
View File
@@ -1,5 +1,3 @@
import './pre-app-init';
import fixPath from 'fix-path';
import { App } from './core/App';
@@ -1,11 +1,13 @@
import {
CODEX_DEFAULT_EXECUTION_ARGS,
CODEX_EXECUTION_MODE_FLAGS,
CODEX_REQUIRED_ARGS,
} from '@lobechat/heterogeneous-agents/spawn';
import type { HeterogeneousAgentBuildPlanParams, HeterogeneousAgentDriver } from '../types';
const CODEX_REQUIRED_ARGS = ['--json', '--skip-git-repo-check'] as const;
const CODEX_AUTO_EXECUTION_FLAGS = [
'--full-auto',
'--dangerously-bypass-approvals-and-sandbox',
'--sandbox',
'-s',
] as const;
const hasAnyFlag = (args: string[], flags: readonly string[]) =>
args.some((arg) => flags.includes(arg as (typeof flags)[number]));
@@ -16,11 +18,9 @@ const buildCodexOptionArgs = async ({
}: Pick<HeterogeneousAgentBuildPlanParams, 'args' | 'helpers' | 'imageList'>) => {
const imagePaths = await helpers.resolveCliImagePaths(imageList);
const imageArgs = imagePaths.flatMap((filePath) => ['--image', filePath]);
const executionModeArgs = hasAnyFlag(args, CODEX_EXECUTION_MODE_FLAGS)
? []
: [...CODEX_DEFAULT_EXECUTION_ARGS];
const autoExecutionArgs = hasAnyFlag(args, CODEX_AUTO_EXECUTION_FLAGS) ? [] : ['--full-auto'];
return [...CODEX_REQUIRED_ARGS, ...executionModeArgs, ...args, ...imageArgs];
return [...CODEX_REQUIRED_ARGS, ...autoExecutionArgs, ...args, ...imageArgs];
};
export const codexDriver: HeterogeneousAgentDriver = {
@@ -181,46 +181,5 @@ describe('cliAgentDetectors', () => {
expect(execMock).not.toHaveBeenCalled();
expect(execFileMock).toHaveBeenCalledTimes(2);
});
it('falls back to the login shell PATH for tools installed by shell setup', async () => {
const originalPath = process.env.PATH;
const originalShell = process.env.SHELL;
process.env.PATH = '/usr/bin:/bin';
process.env.SHELL = '/bin/zsh';
try {
callExecFileError(new Error('not found'));
callExecFile('/opt/homebrew/bin:/Users/Hanam/.local/share/mise/shims:/usr/bin:/bin');
callExecFile('/Users/Hanam/.local/share/mise/shims/gemini\n');
callExecFile('gemini 0.2.0');
const { geminiCliDetector } = await import('../cliAgentDetectors');
const status = await geminiCliDetector.detect();
expect(status.available).toBe(true);
expect(status.path).toBe('/Users/Hanam/.local/share/mise/shims/gemini');
expect(status.version).toBe('gemini 0.2.0');
expect(execFileMock).toHaveBeenCalledTimes(4);
expect(execFileMock.mock.calls[0]![0]).toBe('which');
expect(execFileMock.mock.calls[1]![0]).toBe('/bin/zsh');
expect(execFileMock.mock.calls[1]![1]).toEqual(['-ilc', 'printf "%s" "$PATH"']);
expect(execFileMock.mock.calls[2]![0]).toBe('which');
expect(execFileMock.mock.calls[2]![2]).toMatchObject({
env: {
PATH: '/opt/homebrew/bin:/Users/Hanam/.local/share/mise/shims:/usr/bin:/bin',
},
});
expect(execFileMock.mock.calls[3]![0]).toBe('/Users/Hanam/.local/share/mise/shims/gemini');
expect(execFileMock.mock.calls[3]![2]).toMatchObject({
env: {
PATH: '/opt/homebrew/bin:/Users/Hanam/.local/share/mise/shims:/usr/bin:/bin',
},
});
} finally {
process.env.PATH = originalPath;
process.env.SHELL = originalShell;
}
});
});
});

Some files were not shown because too many files have changed in this diff Show More