From 6558db0ba78dd2b6c2a332bdcc9f206668dfae11 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 10:19:28 +0800 Subject: [PATCH 01/51] docs(cli): define local control plane --- docs/architecture/local-control-plane/plan.md | 115 ++++ docs/architecture/local-control-plane/spec.md | 604 ++++++++++++++++++ .../architecture/local-control-plane/tasks.md | 98 +++ 3 files changed, 817 insertions(+) create mode 100644 docs/architecture/local-control-plane/plan.md create mode 100644 docs/architecture/local-control-plane/spec.md create mode 100644 docs/architecture/local-control-plane/tasks.md diff --git a/docs/architecture/local-control-plane/plan.md b/docs/architecture/local-control-plane/plan.md new file mode 100644 index 000000000..583395261 --- /dev/null +++ b/docs/architecture/local-control-plane/plan.md @@ -0,0 +1,115 @@ +# Local Control Plane and Bundled CLI V1 Plan + +## Delivery Rules + +- Implement the complete V1 on `feat/local-control-plane-cli-v1` as dependency-ordered commits. +- Before every commit, inspect the complete diff and affected call paths. Rank findings by severity + across hidden side effects, compatibility, boundaries, performance, security, naming, test + sufficiency, and maintenance cost; fix in-scope findings before committing. +- Commit messages describe delivered behavior and never describe the review process. +- Do not push from this workstream. +- Keep the surface deny-by-default and reuse canonical route contracts. + +## Stage A: Typed Foundation and Local Transport + +1. Introduce the discriminated `RouteCaller` wrapper and migrate the ten renderer-context integration + files without changing renderer behavior. +2. Add canonical contracts for CLI diagnostics, public DTOs, new compute routes, approval resolution, + artifacts, events, and detached runs. +3. Define `CLI_SURFACE_V1` with compile-time route references and runtime uniqueness/classification + assertions. +4. Implement descriptor creation, atomic permission-safe replacement, token rotation, stale cleanup, + UDS/named-pipe listening, authentication, JSON envelopes, body bounds, and shutdown fencing. +5. Implement the bundled Node thin client, two-token command grammar, descriptor discovery, + fail-closed Agent-token selection, version negotiation, machine output, cancellation, and stable + exit codes. +6. Add focused caller, surface, transport, descriptor, parser, and lifecycle tests. + +## Stage B: Raw Model, Media, Speech, OCR, and Artifacts + +1. Add `models.invoke` on the existing `coreStream` foundation with tools/session/memory disabled and + one canonical stream for all CLI output modes. +2. Expose standalone image and video generation through typed contracts and output artifacts. +3. Add a provider-runtime `generateSpeechStandalone` capability and typed audio artifact; keep the + current VoiceAI event quirk behind its adapter. +4. Move transcription CLI input to bounded upload/owned-artifact adapters. +5. Add strict request-body accumulation limits, private threshold spill, and exhaustive cleanup. +6. Implement output-only `ArtifactSpool` ownership, quotas, streaming download, expiry, startup + cleanup, and shutdown cleanup. +7. Add OCR status, human upload extraction, owned-artifact extraction, and human-only cache clearing + at background priority with bounded text output. +8. Add machine metrics for raw/media/OCR benchmarks, including the four explicit OCR states. +9. Add focused provider, artifact, upload, OCR, quota, and CLI integration tests. + +## Stage C: Effects, Approval, and Administration + +1. Extract canonicalization and the generic pending/timeout/consume mechanics into `ApprovalBroker` + while preserving all existing tool permission behavior through `ToolPermissionBroker`. +2. Add `CliMutationGuard`, unique per-request CLI approvals, redacted display data, targeted approval + events, and renderer-only `approvals.resolve` typed IPC. +3. Implement the effect matrix, operation classifier, scope checks, audit records, rate limits, and + renderer-unavailable failure behavior. +4. Expose redacted settings and allowlisted updates with per-key effect classification. +5. Expose redacted provider/model reads and separated configuration/credential mutations. +6. Expose reviewed Skill list/enable/install/uninstall adapters without arbitrary Agent paths. +7. Expose reviewed MCP list/add/update/remove/enable/start/stop adapters without raw tool calls or + secret-bearing output. +8. Add policy-matrix, approval-state, redaction, compatibility, and administration tests. + +## Stage D: Typed Events and Detached Agent Runs + +1. Replace relevant all-window publication with a Typed Event Hub that supports explicit renderer, + connection, request, and run targets. +2. Add bounded subscriber queues, per-request ordering, overflow termination, disconnect handling, + and cursor/recovery semantics. +3. Add `sessions.runDetached` by composing existing detached session creation with an initial turn. +4. Add owned run status, message/result recovery, idempotent cancellation, and CLI JSONL streaming. +5. Prove that CLI prompt/delta/approval events cannot leak to unrelated windows or connections. +6. Add detached-run lifecycle, restart/recovery, cancellation, event isolation, and backpressure tests. + +## Stage E: Packaged Product and Agent Integration + +1. Build the CLI as a packaged application resource that runs on the bundled Node runtime. +2. Add opt-in platform launchers and PATH installation/removal with explicit, reversible ownership. +3. Add the internal scoped-token issuer, conversation binding, expiry/revocation, call/byte quotas, + and main-enforced Agent restrictions. +4. Harden `CommandPermissionService` so redirection and compound shell syntax cannot inherit a safe + base-command decision. +5. Integrate `deepchat ` without adding `deepchat` to `SAFE_COMMANDS`; reject + prefix-global-flag grammar and deny Agent artifact-byte/output-path access. +6. Add the bundled DeepChat CLI Skill and ensure instructions never expose the human descriptor. +7. Add packaged smoke coverage for diagnostics, raw text, artifact download, OCR, and scoped Agent + denial paths without requiring external credentials where fixtures can substitute providers. +8. Complete cross-platform packaging validation and user-facing documentation. + +## Validation Order + +Run the smallest relevant tests after each change, then expand by risk: + +```text +pnpm run format +pnpm run i18n +pnpm run lint +pnpm run typecheck +pnpm test +pnpm run build +``` + +Use current-platform unsigned packaging and packaged smoke where local prerequisites allow. Claims +for other targets require their normal platform workflows. Record actual commands and outcomes in +`tasks.md`; do not report an unrun check as passed. + +## Review Gates + +Before each commit, review staged and unstaged changes in this order: + +1. Critical: authority escalation, credential/output leakage, arbitrary file access/write, endpoint + exposure, approval replay, or data loss. +2. High: renderer compatibility, caller confusion, lifecycle races, unbounded memory/disk/event use, + cancellation gaps, or cross-request data leakage. +3. Medium: error/exit instability, incomplete cleanup, misleading naming, retry/idempotency ambiguity, + performance regression, or insufficient negative tests. +4. Low: maintainability, local duplication, documentation drift, and non-functional clarity. + +Resolve all in-scope findings, rerun affected checks, then commit with a behavior-specific +Conventional Commit subject of at most 50 characters. diff --git a/docs/architecture/local-control-plane/spec.md b/docs/architecture/local-control-plane/spec.md new file mode 100644 index 000000000..fcfd5bfee --- /dev/null +++ b/docs/architecture/local-control-plane/spec.md @@ -0,0 +1,604 @@ +# Local Control Plane and Bundled CLI V1 + +Status: accepted; implementation in progress + +## Decision + +DeepChat main is the sole owner of the local control plane. A bundled Node CLI connects to the +running desktop application over HTTP semantics carried by a Unix domain socket on POSIX and a +named pipe on Windows. The CLI is a thin transport, formatting, and local file-I/O client. It does +not load providers, credentials, Skills, MCP servers, OCR runtimes, Agent runtimes, or application +databases. + +The public API is a versioned `CLI_SURFACE` allowlist that references DeepChat's canonical typed +route contracts. It is not a generic tunnel to the internal route registry. Raw model invocation, +media generation, offline OCR, and full detached Agent execution are separate capabilities with +separate policy and lifecycle semantics. + +Sensitive mutations use one main-owned approval state machine. The reusable state-machine core is +extracted from `ToolPermissionBroker`; the existing tool flow remains behind a tool-specific +adapter, while CLI mutations use `CliMutationGuard`. A CLI request may wait for a renderer decision, +but it can never approve itself or receive a replayable approval credential. + +The entire V1 is delivered on one feature branch through reviewable implementation commits. This +document intentionally describes dependency-ordered stages rather than repository integration +units. + +## Evidence Baseline + +This design is grounded in the current DeepChat tree and the separately inspected Alma 0.0.930 +application. Statements about Alma apply only to that inspected version. + +| Existing DeepChat component | Reused fact | +| --- | --- | +| `src/main/routes/routeRegistry.ts` | Main already owns a typed route registry and dispatcher. | +| `src/shared/contracts/routes.ts` | Route contracts are the canonical schema source. | +| `src/main/provider/index.ts` | `coreStream` plus standalone transcription, image, and video paths already exist. | +| `src/main/provider/providers/voiceAIProvider.ts` | Speech exists only as a stream-side implementation detail and needs a standalone contract. | +| `src/main/session/lifecycle.ts` | `createDetachedSession` creates a durable session without binding a renderer or starting a turn. | +| `src/main/app/composition.ts` | General events currently broadcast to every window and require targeted delivery for CLI requests. | +| `src/main/tool/permission/toolPermissionBroker.ts` | Canonical hashing, timeout, pending state, approval, and one-shot consumption already exist. | +| `src/main/tool/index.ts` | MCP and Agent pre-checks, including live delegation, already use the tool broker. | +| `src/main/tool/permission/commandPermissionService.ts` | Command approval is signature-based; `deepchat` must not become globally safe, and output redirection is currently missing from critical shell syntax. | +| `src/main/ocr/ocrRuntimeService.ts` | Image, batch, and document extraction are implemented; only an explicit public extraction contract is missing. | +| `src/main/ocr/ocrArtifactStore.ts` | Cache clearing removes derived cache rows only and rejects clearing while work is active. | + +Alma contributes useful product shape: a desktop-owned local service, a bundled command, and Agent +instructions that teach the command surface. Its inspected implementation is not adopted as a +protocol, schema, permission, or lifecycle foundation. + +## Goals + +- Ship `deepchat` with the desktop application and make it usable by humans, scripts, benchmark + harnesses, and explicitly scoped DeepChat Agents. +- Expose text, image, speech, transcription, video, OCR, and full Agent execution without moving + credentials or runtime ownership out of main. +- Expose a curated settings, provider/model, Skill, and MCP management plane with effects, + caller-specific policy, audit, and renderer-only approval. +- Provide deterministic JSON and JSONL output, stable exit codes, cancellation, timing, usage, and + artifact metadata suitable for external benchmarks. +- Reuse current route contracts, provider/session/OCR services, and permission machinery without a + second schema catalog or a second approval state machine. +- Preserve renderer behavior and compatibility while introducing an explicit caller model. + +## Non-Goals + +- TCP, HTTP loopback, remote access, CORS, or a network-listening daemon. +- A generic route-registry proxy or access to every internal route. +- CLI-side approval resolution or a `confirmed` request field. +- Raw MCP tool invocation, Browser/Computer Use, a TUI, or an interactive chat shell. +- Arbitrary settings, database, credential, filesystem, or application-secret reads. +- A persistent input-artifact lifecycle or arbitrary main-process input paths. +- Server-side OCR batch/layout/boxes/model management. +- A general cost-budget engine or an in-app benchmark framework. +- An ACP server. ACP remains a possible V2 transport over the same domain services. + +## Architectural Invariants + +1. Main is the only authority that resolves provider configuration, reads credentials, performs + upstream calls, mutates settings, installs Skills, manages MCP, runs OCR, and runs Agents. +2. Caller identity is constructed from the trusted transport. No request parameter can select or + upgrade its caller, scopes, connection, conversation, or approval state. +3. `CLI_SURFACE` is deny-by-default and independent from the internal route catalog. +4. Every exposed operation has an effect, caller set, scope predicate, rate/quota rule, audit rule, + transport shape, and output bound. +5. Approval resolution is renderer-only. The local socket exposes pending status, never resolve. +6. An approval is bound to the normalized method, arguments hash, main-generated execution identity, + scope, expiry, and original live request. It is consumed once and is never serializable as a + capability token. +7. Binary output is represented by an owned, expiring artifact. Main never writes a caller-supplied + output path. +8. Uploads are bounded independently of `Content-Length`; chunked bodies cannot bypass the limit. +9. Raw model invocation has no tools, memory, Skills, or session side effects. Full Agent execution + always uses the session runtime. +10. Agent callers cannot recursively start an Agent run. + +## Runtime Topology + +```mermaid +flowchart LR + Human["Human or benchmark"] --> CLI["Bundled deepchat CLI"] + Agent["DeepChat Agent shell"] --> Gate["CommandPermissionService"] + Gate --> CLI + CLI -->|"HTTP over UDS or named pipe"| Server["LocalControlServer in main"] + Server --> Auth["Connection authentication and caller scopes"] + Auth --> Surface["Versioned CLI_SURFACE"] + Surface --> Policy["Effect policy and quotas"] + Policy --> Routes["Canonical typed routes and domain adapters"] + Policy --> Guard["CliMutationGuard"] + Guard --> Broker["ApprovalBroker core"] + Broker -->|"targeted event"| Renderer["Trusted renderer approval UI"] + Renderer -->|"typed IPC only"| Broker + Routes --> Providers["Provider runtime"] + Routes --> OCR["OCR runtime"] + Routes --> Sessions["Session and Agent runtime"] + Routes --> Admin["Settings, Skills, and MCP services"] + Providers --> Spool["ArtifactSpool"] + Sessions --> Events["Typed Event Hub"] + Spool --> CLI + Events --> CLI +``` + +`LocalControlServer`, `ArtifactSpool`, and the CLI process are lifecycle clients of the existing main +composition. The server starts only after its route dependencies are ready. Shutdown first stops +accepting connections, aborts in-flight non-detached requests, closes event subscribers, cancels CLI +approval scopes, and removes the descriptor/socket; mutable services and databases close afterward. + +## Transport and Discovery + +### Endpoint + +- POSIX: an application-owned Unix domain socket below the DeepChat user-data directory. After + bind, its mode is verified as `0600`. +- Windows: a per-start random named-pipe name. The descriptor is protected with an owner-only ACL; + the random endpoint and bearer token provide defense in depth where Node does not expose a + portable pipe-DACL API. +- TCP fallback is forbidden. Failure to create the local endpoint disables CLI availability and is + reported by the desktop application; it never falls back to a port. + +The endpoint path/name is generated by main. Cleanup only touches the exact computed path after an +`lstat`/type check. It never deletes an environment-expanded, caller-provided, or broad path. + +### Descriptor + +Main atomically replaces a descriptor with this public shape: + +```ts +type LocalControlDescriptorV1 = { + protocolVersion: 1 + surfaceVersion: 1 + appVersion: string + endpoint: { kind: 'unix'; path: string } | { kind: 'pipe'; name: string } + pid: number + token: string + startedAt: number +} +``` + +The descriptor is `0600` on POSIX and owner-only on Windows. The token has at least 256 bits of +entropy, rotates on every main start, is compared in constant time, is never logged, and is removed +with the descriptor on clean shutdown. The CLI rejects malformed descriptors, unsupported versions, +impossible PIDs, non-local endpoint kinds, and overlong paths before connecting. A stale descriptor +is diagnostic information, not authority to launch or kill a process. + +The human descriptor token authenticates same-user local automation; it is not a defense against +arbitrary malware running as the same OS user. Agent invocation receives a short-lived scoped token +through the Agent runtime and must not rely on the descriptor token. The CLI must never fall back to +the human descriptor when an Agent-token environment is present but invalid or expired. + +The bearer token proves possession, not whether a same-UID process is semantically a human or an +Agent. A process that can read and deliberately replay the human descriptor can present as a human +caller. V1 therefore treats the Agent token as least-privilege capability routing, not process +attestation: sensitive operations still require renderer approval for a human token, `deepchat` +remains behind the shell gate, and the descriptor limitation is explicit. Stronger same-UID +separation would require an Agent sandbox or platform peer/process attestation and is not claimed by +this protocol. + +### HTTP Shapes + +- `POST /v1/rpc`: bounded JSON request and JSON response for unary methods. +- `POST /v1/stream`: bounded JSON request and `application/x-ndjson` response for streamed methods. +- `POST /v1/upload`: strict bounded multipart request for methods with byte input. +- `GET /v1/artifacts/:id`: ownership-checked binary output download. +- `GET /v1/events`: ownership-checked NDJSON event subscription with request/run filters. + +All endpoints require `Authorization: Bearer`. RPC envelopes carry a caller-generated request ID, +method, and params. Responses carry the same ID and either typed result metadata or a stable error +object. HTTP status communicates transport/authentication failure; CLI exit codes communicate the +domain outcome. Proxy environment variables are ignored for local transport. + +`Content-Length` is rejected when missing for fixed JSON bodies, invalid, conflicting, or above the +route limit. Uploads and chunked streams enforce a cumulative byte limit while reading. Bodies spill +to a private `0700` directory above a route-specific memory threshold. Abort, parse error, timeout, +limit failure, and shutdown all remove partial files. The public protocol does not expose those +temporary paths. + +## Contract Ownership and Surface + +`CLI_SURFACE_V1` is a readonly registry whose entries reference the same `RouteContract` objects used +by renderer IPC. A surface entry adds only transport and policy metadata: + +```ts +type CliSurfaceEntry = { + contract: RouteContract + effect: CliEffect | ((input: unknown) => CliEffect) + callers: readonly CliPrincipal[] + requiredScopes: readonly CliScope[] + transport: 'rpc' | 'stream' | 'upload' + approval: 'never' | 'policy' + limits: CliRouteLimits +} +``` + +Existing canonical contracts are reused when their input/output is safe. A genuinely new behavior or +redacted public view gets one new canonical shared contract and main handler; it is not described a +second time in a CLI-only schema. In particular, provider public DTOs exclude secrets, opaque auth +state, raw environment variables, and credential material. + +Public method names describe their domain (`models.invoke`, `providers.listPublic`, +`sessions.runDetached`). The `cli.*` namespace is reserved for behavior that exists only to operate +or diagnose the bundled CLI. + +### V1 Capability Matrix + +`H` means an authenticated human CLI connection. `A` means a short-lived Agent connection with the +listed scope. “Policy” means the renderer-only effect policy may be required; it never means a CLI +confirmation flag. + +| Capability | Public methods / command family | Effect | Callers | Approval | Output | +| --- | --- | --- | --- | --- | --- | +| 1. Raw text model | `models.listPublic`, `models.getCapabilities`, `models.invoke`; `deepchat model …` | read / compute | H, scoped A | never | JSON or token/usage JSONL | +| 2. Image generation | `images.generate`; `deepchat image generate` | compute | H, scoped A | never | progress JSONL + image artifacts | +| 3. Audio | `speech.generate`, `audio.transcribeUpload`, `audio.transcribeArtifact`; `deepchat audio speak\|transcribe` | compute | H; scoped A uses artifacts only | never | audio artifact or bounded text | +| 4. Video generation | `videos.generate`; `deepchat video generate` | compute | H, scoped A | never | progress JSONL + video artifact | +| 5. Offline OCR | `ocr.getRuntimeStatus`, `ocr.extractUpload`, `ocr.extractArtifact`, `ocr.clearCache`; `deepchat ocr …` | read / compute / local-maintenance | H; scoped A uses owned inputs and cannot clear | never | bounded text/metrics JSON | +| 6. Full Agent run | `sessions.runDetached`; `deepchat agent run` | compute | H only | never | durable run ID + targeted JSONL | +| 7. Settings | `settings.getPublic`, `settings.updatePublic`; `deepchat settings …` | read or key-derived mutation | H; scoped A for allowlisted keys | policy by effect | redacted JSON | +| 8. Provider/model administration | `providers.listPublic`, `providers.testConnection`, `providers.addPublic`, `providers.updatePublic`, `providers.remove`, `providers.setCredential`, `models.listRuntime`, `models.setStatus`, `models.getConfig`, `models.setConfig`, `models.resetConfig`; `deepchat provider …`, `deepchat model config …` | read / execution-config / credential / destructive | H; A is read-only | policy for mutations | redacted JSON | +| 9. Skills | `skills.listPublic`, `skills.setDisabled`, `skills.installFromUrl`, `skills.installUpload`, `skills.uninstall`; `deepchat skill …` | read / supply-chain / destructive | H; scoped A may request allowlisted mutations | policy for mutations | JSON | +| 10. MCP | `mcp.listPublic`, `mcp.addPublic`, `mcp.updatePublic`, `mcp.remove`, `mcp.setServerEnabled`, `mcp.startServer`, `mcp.stopServer`; `deepchat mcp …` | read / security-config / supply-chain / destructive | H; scoped A may request allowlisted non-credential mutations | policy for mutations | redacted JSON/events | +| 11. Runs, events, artifacts | `runs.get`, `runs.cancel`, `events.subscribe`, `artifacts.describe`, `artifacts.read`, `artifacts.delete`; `deepchat run …` | read / local-maintenance | H owns all; A may inspect/pass owned IDs but cannot read bytes, delete, or cancel unrelated work | never | JSONL or binary artifact for H; metadata for A | +| 12. CLI diagnostics | `cli.status`, `cli.version`, `cli.capabilities`, `cli.doctor`; top-level commands | read | H, A | never | stable JSON/text | +| 13. Benchmark automation | client-side stable modes over compute methods; `--json`, `--jsonl`, stdin, timeout, cancel | inherited | H, scoped A | inherited | reproducible result envelope | +| 14. Agent-scoped CLI use | internal `agentCli.issueScopedToken` plus bundled Skill instructions | security-config (internal) | trusted main runtime issues; A consumes | not exposed on socket | short-lived in-memory authority | + +Surface names and contracts are frozen by `surfaceVersion`. Additive entries require an advertised +capability and surface-version change policy; removal or semantic incompatibility requires a new +surface major. App and protocol versions are reported independently. + +## Caller Model and Route Migration + +The current optional renderer fields become a discriminated caller: + +```ts +type RendererRouteCaller = { + kind: 'renderer' + webContentsId: number + windowId: number | null +} + +type CliRouteCaller = { + kind: 'cli' + connectionId: string + principal: 'human' | 'agent' + scopes: readonly CliScope[] + conversationId?: string + expiresAt?: number +} + +type InternalRouteCaller = { + kind: 'internal' + component: 'scheduler' | 'migration' | 'agent-cli' +} + +type RouteContext = { caller: RouteCaller } +``` + +Renderer-only handlers assert `caller.kind === 'renderer'` before accessing window identity. Public +headless handlers either accept CLI/internal callers or delegate to a domain service that has no +desktop dependency. There are no sentinel window IDs. + +The migration touches these ten integration files: + +1. `src/main/routes/routeRegistry.ts`: define `RouteCaller` and wrapped `RouteContext`. +2. `src/main/routes/index.ts`: build renderer callers and make startup tracking renderer-aware. +3. `src/main/app/composition.ts`: construct internal/CLI callers and keep main-window checks explicit. +4. `src/main/app/routes.ts`: require renderer identity for window/session ownership behavior. +5. `src/main/desktop/routes.ts`: reject non-renderer callers at the desktop boundary. +6. `src/main/mcp/routes.ts`: adapt renderer callers to the existing MCP App ownership context while + allowing only separately selected headless MCP administration methods. +7. `src/main/session/sessionService.ts`: separate renderer-bound create/activate operations from + detached headless creation. +8. `src/main/session/routes.ts`: use caller narrowing for submission cancellation and UI-bound work. +9. `src/main/provider/routes.ts`: target renderer-specific OAuth/debug events only to renderer + callers. +10. `src/main/notifications/routes.ts`: keep readiness and notification ownership renderer-only. + +`McpAppRouteContext` in `appHost.ts` and `sandboxRegistry.ts` intentionally remains a domain-owned +renderer context. `mcp/routes.ts` is its adapter, avoiding CLI concepts inside the MCP App sandbox. + +## Effect Policy + +Authorization is `approvalPolicy(effect, caller, operation)`, not `isWrite`. + +| Effect | Human CLI | Agent CLI | Examples | +| --- | --- | --- | --- | +| `read` | allow | allow with scope | status, redacted lists | +| `compute` | allow with rate limits | allow with scope and quota | model/media/OCR | +| `local-maintenance` | allow and audit | deny | OCR cache clear, owned artifact delete | +| `preference-write` | allow and audit | renderer approval when allowlisted | language or UI-safe defaults | +| `security-config` | renderer approval | renderer approval only when explicitly allowlisted | MCP enablement, proxy/security policy | +| `execution-config` | renderer approval | deny | default provider/model, executable configuration | +| `supply-chain` | renderer approval | renderer approval only when explicitly allowlisted | Skill/MCP installation | +| `credential` | renderer approval | deny | provider or MCP secret update | +| `destructive` | renderer approval | deny | provider/Skill/MCP removal | + +Per-invocation provider/model selection is compute input, not an execution-config mutation. Benchmark +harnesses must use those per-call fields rather than changing global defaults. + +Every policy decision is audited with timestamp, caller kind, connection/conversation scope, +operation, effect, outcome, request ID, and redacted argument hash. Tokens, secrets, raw prompts, +uploaded bytes, and full generated output are not audit fields. + +## ApprovalBroker + +The generic core owns only state-machine mechanics: + +```ts +interface ApprovalBroker { + create(input: ApprovalBinding, options: ApprovalOptions): ApprovalSnapshot + wait(requestId: string, signal?: AbortSignal): Promise + resolve(input: ApprovalResolution): boolean + consumeApproved(match: ApprovalMatch): boolean + cancelScope(scopeKey: string): void + clear(): void + subscribe(listener: (event: ApprovalEvent) => void): () => void +} +``` + +`ApprovalBinding` contains a main-generated request/execution identity, scope key, operation, effect, +canonical argument hash, redacted display data, and expiry. Canonicalization preserves the current +depth, key-count, byte, finite-number, JSON-only, and cycle limits. Display data is supplied +separately so credential values cannot leak through an argument preview. + +Two adapters preserve distinct domain semantics: + +- `ToolPermissionBroker` retains model pre-check followed by approved one-shot execution, current + MCP App waiting behavior, tool naming, permission modes, and conversation cancellation. +- `CliMutationGuard` creates a unique non-deduplicated approval for the current authenticated request, + publishes it to a trusted renderer target, and awaits the decision while the HTTP request stays + open. Approval resumes that exact server-side continuation. Socket abort, timeout, shutdown, or + scope cancellation denies it and makes later resolution fail. + +CLI approvals do not deduplicate identical concurrent calls: otherwise one click could resume +multiple mutations. The core may retain tool-domain deduplication through an explicit adapter key. + +Renderer resolves through a new canonical `approvals.resolve` typed IPC route. The handler rejects +all non-renderer callers and validates the request's renderer target/scope. `/v1/approvals/*/resolve` +does not exist. Existing `chat.respondToolInteraction` behavior remains a tool adapter and is not +forged for CLI requests. + +## Model and Media Execution + +### Raw model invocation + +`models.invoke` resolves a provider/model through main and calls the existing provider `coreStream` +foundation with: + +- a real system-role message when supplied; +- user/assistant messages from a bounded typed request; +- `tools: []` and no tool loop; +- no session, memory retrieval, Skills, attachments, or Agent orchestration; +- per-invocation generation settings without mutating defaults. + +The server always produces the canonical stream. Human-readable and non-stream JSON CLI modes buffer +that stream in the CLI; main does not maintain a second non-stream execution path. Events include +text/reasoning deltas as permitted, usage, finish reason, resolved provider/model identity, TTFT, +latency, and redacted resolved settings. + +### Media and speech + +Image and video generation reuse existing standalone provider capabilities and write binary results +to `ArtifactSpool`. Speech gets a formal `generateSpeechStandalone` provider-runtime capability with +a typed audio result. Its implementation may collect the current VoiceAI stream internally, but the +public contract must not depend on audio currently appearing in an image-named stream event. + +Transcription accepts a human upload or an owned Agent artifact. Base64 remains an internal renderer +compatibility shape, not the preferred CLI transport. + +### Cost and concurrency + +V1 uses bounded request sizes, per-connection concurrency, per-method rate limits, Agent call counts, +and media/OCR byte quotas. It does not introduce a currency budget engine. Interactive renderer work +has priority; CLI/OCR/benchmark work enters background capacity and cannot starve chat. + +## Offline OCR + +OCR is an independent V1 domain, not a model alias. `ocr.extractUpload` is human-only and consumes a +bounded uploaded image/PDF. `ocr.extractArtifact` accepts only a DeepChat-owned attachment/artifact or +a main-issued file grant. If file grants are not delivered with Agent integration, standalone Agent +OCR remains unavailable rather than accepting a path. + +Explicit OCR is independent of the chat setting that automatically routes non-vision attachments. +Text is normalized and token/character bounded, returned directly, and never enters `ArtifactSpool`. +Detection boxes, confidence/layout output, server-side batch, and runtime model management are out of +scope. + +`ocr.clearCache` is `local-maintenance`: human CLI is allowed without main-owned approval, Agent CLI +is denied, extraction-in-progress rejection remains intact, and the operation is audited. The cache +contains derived data only; clearing does not touch original files, attachment snapshots, runtime +assets, settings, or credentials. + +### OCR benchmark semantics + +Results distinguish: + +- `cache-hit`; +- `cache-miss-warm-runtime`; +- `cold-runtime`; +- `offline-availability`. + +They report at least `runtimeWasReady`, cache state, input bytes/type, pages where applicable, +duration, output characters/tokens, engine identity, app/protocol/surface version, and availability. +`clearCache()` first calls `getResources()` and therefore warms an unstarted runtime. A clear followed +by extraction is necessarily a warm-runtime cache miss, never a cold-runtime measurement. Cold +runtime requires restarting the desktop application or an external harness. V1 does not expose +`restart-runtime` merely to improve a benchmark. + +## File I/O Boundary + +Human and Agent file flows are deliberately different: + +- Human input: the CLI opens a path and uploads bounded bytes. Main receives bytes plus safe metadata, + never an arbitrary source path. +- Agent input: only DeepChat-owned attachment/artifact IDs or a main-resolved file-grant ID are + accepted. The main process canonicalizes and validates a grant; the CLI cannot mint one. +- Human output: the CLI downloads an owned artifact and writes `--out` with no-overwrite semantics by + default. Replacement requires explicit `--overwrite`. +- Agent output: main returns artifact IDs and metadata only. Artifact byte download, stdout byte + export, deletion, and `--out` are rejected for Agent callers; IDs may be passed to another scoped + operation. + +Bounded upload bodies control main-process resources and keep transport authority narrow; they do not +prove where the CLI process obtained bytes. Today `cat` is already a safe shell command, so this is +not presented as closing the first possible Agent read path. + +The current shell risk parser also omits `>` and `>>`: a command beginning with safe `cat` can redirect +output without leaving the whitelist. Therefore the existing system already has a silent write path, +and the earlier claim that only `cp`/`mv` could write was incorrect. Before any Agent CLI surface is +enabled, redirection (including descriptor duplication and here-document/here-string variants) must +be tokenized conservatively and must force command approval. The CLI split still prevents main from +becoming an additional arbitrary-path writer, provides auditable provenance, and remains compatible +with a future tighter shell sandbox. + +## ArtifactSpool + +The spool is output-only and intentionally smaller than a general asset store: + +- random unguessable IDs and exclusive file creation in an application-private directory; +- in-memory ownership metadata bound to request, connection/principal, media type, size, hash, + creation, expiry, and suggested filename; +- per-artifact, per-request, per-connection, and aggregate byte/count limits; +- streaming writes with hash/size accounting and atomic publication; +- ownership checks on describe/read/delete and no path exposure; +- TTL cleanup, disconnect cleanup for non-detached output, startup cleanup after crashes, and shutdown + cleanup; +- bounded streaming download with backpressure. + +Input uploads use a separate private temporary-body utility and never become spool artifacts unless a +domain operation deliberately produces a new output artifact. + +## Typed Event Hub and Detached Runs + +The Event Hub envelope contains event name, schema version, sequence, timestamp, target, request/run +identity, and typed payload. Targets are explicit: renderer, CLI connection, request, run/session, or +trusted internal subscriber. CLI-originated prompt content, generation deltas, approvals, and run +events are never broadcast to all windows. + +Each subscriber has a bounded queue. Slow clients receive a terminal overflow error and disconnect; +main does not accumulate unbounded events. Request streams preserve per-request order. Cross-request +global ordering is not promised. + +Raw and media requests are cancelled when their connection/request aborts unless an operation +explicitly supports detachment. `sessions.runDetached` first creates a detached session through the +existing lifecycle, then starts the initial turn. It returns a durable run/session identity before +streaming. Disconnect does not destroy a detached run; status/messages can be recovered from session +state and event cursors. `runs.cancel` is idempotent and ownership checked. + +## CLI Product Contract + +The command grammar starts with exactly two capability tokens: + +```text +deepchat [options] +``` + +Global output/timeout flags follow the domain and verb, or use environment variables. Forms such as +`deepchat --json image generate` are rejected. This is a security contract: the existing shell +permission signature takes the base command and next token, but takes a third token when the second +starts with `-`; prefix flags would fragment or mis-scope session approvals. + +`deepchat` is never added to `SAFE_COMMANDS`. When an Agent invokes it through a shell, the controls +are: + +1. `CommandPermissionService` shell gate; +2. authenticated token and scopes; +3. `CLI_SURFACE` caller policy; +4. effect policy and renderer approval; +5. rate, quota, ownership, and audit enforcement. + +The first control is a hard dependency, not a decorative outer layer. Its parser must recognize +output/input redirection, file-descriptor redirection, command substitution, process substitution, +pipelines, separators, and newlines before Agent CLI is enabled. A safe base command must not override +critical compound-shell syntax. + +Human-friendly output goes to stdout, diagnostics to stderr, and machine modes are stable: + +- `--json` emits exactly one result envelope; +- `--jsonl` emits versioned events and one terminal result/error record; +- prompts and payloads may come from stdin without shell quoting; +- timeout sends cancellation before exiting; +- SIGINT cancels once, waits a bounded grace period, then exits; +- no ANSI/progress UI appears in machine modes. + +Exit codes are stable: success, usage, unavailable/version mismatch, authentication/authorization, +approval denied/timeout, domain failure, timeout/cancel, and internal/protocol failure are distinct. + +The packaged CLI uses the bundled Node runtime and ships as an application resource. Installation is +opt-in and places a small launcher in the platform's user command location. It does not install an npm +package or copy credentials. Upgrades replace app-owned resources while keeping the launcher stable. + +## Agent Token and Bundled Skill + +An internal Agent meta-tool asks main to mint an in-memory token containing principal `agent`, a +conversation binding, allowed surface scopes, expiry, call/byte quotas, and a random identifier. +The token is passed to the CLI invocation environment and is never written to the descriptor or +transcript. Main revokes it when the session ends, permission caches clear, or the app stops. + +Agent defaults allow bounded raw compute/media and owned-artifact operations. They deny +`sessions.runDetached`, credentials, destructive operations, arbitrary input paths, and output paths. +Management mutations are either denied or wait for renderer approval according to the matrix. + +The bundled Skill documents command discovery, machine output, artifact handling, stdin, timeouts, +and the rule that the CLI cannot approve itself. It must not instruct an Agent to read the human +descriptor. + +## Benchmark Contract + +Benchmarks are external harnesses over stable CLI output. Terminal records include: + +- requested and resolved provider/model; +- redacted generation settings and capability identity; +- input/output token or byte counts where available; +- usage, TTFT, end-to-end latency, finish reason, retries, and cancellation outcome; +- artifact MIME, size, hash, and ID without local paths; +- OCR cache/runtime classification; +- app, protocol, surface, CLI, and provider adapter versions. + +Raw text, media, speech, and OCR are benchmarkable as soon as their surfaces land. Full Agent +benchmarks use detached runs and the Event Hub. The harness controls repetitions, datasets, scoring, +and cold application restarts. + +## Compatibility and Failure Semantics + +- Renderer IPC retains current route names and outputs unless a canonical new route is added. +- Existing tool approvals retain their request/consume behavior through the adapter. +- Existing `chat.respondToolInteraction` remains supported; CLI approval uses a new renderer route. +- Provider secrets never appear in new public provider DTOs, logs, errors, events, or audit records. +- Unsupported protocol/surface versions fail before method dispatch and print actionable version + information. +- App shutdown, token expiry, descriptor rotation, database maintenance, queue saturation, body-limit + failure, and renderer absence have typed terminal errors. +- A pending sensitive CLI mutation fails closed if no trusted renderer can present it. +- Retrying a mutation requires a new authenticated request and a new approval; request IDs are not + idempotency keys unless a method explicitly declares an idempotency contract. + +## Acceptance Criteria + +- Packaged macOS, Windows, and Linux applications include a working `deepchat` launcher that connects + only to the local endpoint and reports compatible version/capability data. +- Surface tests prove every exposed method is declared, registered, classified, bounded, and allowed + only for its caller/scopes; internal routes are unreachable. +- Transport tests cover descriptor permissions/rotation, token comparison, stale endpoints, malformed + HTTP, fixed and chunked body limits, spill cleanup, aborts, backpressure, and shutdown ordering. +- Caller migration tests prove renderer-only routes reject CLI/internal callers without sentinel IDs. +- Approval tests cover binding, redaction, timeout, abort, scope cancellation, single consumption, + concurrent identical CLI mutations, renderer-only resolution, and preserved tool behavior. +- Model/media/speech tests prove credentials remain in main, raw invoke has no tools/session/memory, + stream order is stable, and binary results use owned artifacts. +- OCR tests cover upload/artifact caller split, output bounds, queue priority, explicit-setting + independence, clear-cache policy, and all four benchmark classifications. +- Agent tests cover detached recovery/cancellation, targeted events, scoped-token expiry/revocation, + recursion denial, arbitrary-path and artifact-byte denial, descriptor-token fallback denial, and + quota enforcement. +- CLI tests cover two-token grammar, post-command flags, stdin, JSON/JSONL, exit codes, signal/timeout, + no-overwrite/overwrite, and Agent rejection of `--out`. +- Command-permission tests prove redirection, process substitution, separators/newlines, and other + compound syntax cannot inherit a safe base-command decision. +- Format, i18n validation, lint, typecheck, focused tests, full tests, production build, and + current-platform packaged smoke pass where local prerequisites allow. + +## Open Questions + +None. Policy values above are the V1 baseline; future changes require an explicit surface/security +review rather than implicit widening. diff --git a/docs/architecture/local-control-plane/tasks.md b/docs/architecture/local-control-plane/tasks.md new file mode 100644 index 000000000..f857d86a3 --- /dev/null +++ b/docs/architecture/local-control-plane/tasks.md @@ -0,0 +1,98 @@ +# Local Control Plane and Bundled CLI V1 Tasks + +## Architecture and Scope + +- [x] Verify the current route registry, provider runtime, detached session, event publication, + permission broker, command signature, OCR runtime, and OCR cache behavior. +- [x] Freeze the V1 goals, exclusions, 14 capability groups, effect taxonomy, caller model, file-I/O + split, benchmark semantics, and implementation order. +- [x] Specify `CLI_SURFACE_V1`, the ten-file `RouteCaller` migration, and `ApprovalBroker` adapters. +- [x] Specify transport, discovery, artifact, event, CLI grammar, packaging, and Agent-token contracts. +- [x] Complete the severity-ranked architecture-document review and resolve its findings. +- [ ] Commit the accepted SDD locally. + +## Typed Foundation + +- [ ] Add `RouteCaller` and migrate renderer-dependent integrations without behavior change. +- [ ] Add canonical local-control contracts and redacted public DTOs. +- [ ] Define and test the deny-by-default versioned surface registry. +- [ ] Add local-control error codes, request/result envelopes, and route limits. + +## Local Transport and CLI + +- [ ] Implement atomic private descriptor creation, token rotation, and stale cleanup. +- [ ] Implement UDS/named-pipe HTTP server lifecycle and authentication. +- [ ] Implement fixed/chunked body bounds, spill-to-disk, abort handling, and cleanup. +- [ ] Implement the bundled thin CLI, two-token grammar, version negotiation, output modes, signals, + fail-closed Agent-token selection, timeouts, and exit codes. +- [ ] Add descriptor, transport, auth, body-boundary, parser, and shutdown tests. + +## Compute and Artifacts + +- [ ] Add raw `models.invoke` over `coreStream` with no Agent/session/tool side effects. +- [ ] Add image and video standalone generation surfaces. +- [ ] Add formal standalone speech generation and typed audio output. +- [ ] Add upload and owned-artifact transcription inputs. +- [ ] Implement output-only `ArtifactSpool` ownership, quotas, expiry, and cleanup. +- [ ] Add stream, media, speech, transcription, artifact, and quota tests. + +## OCR + +- [ ] Add explicit upload and owned-artifact extraction contracts and handlers. +- [ ] Preserve automatic-attachment-setting independence and background priority. +- [ ] Enforce bounded text output and exclude layout/batch/model administration. +- [ ] Classify cache clear as audited human-only `local-maintenance` without approval. +- [ ] Report cache hit, warm-runtime miss, cold-runtime, and offline metrics accurately. +- [ ] Add OCR caller, input, cache, runtime-state, output-bound, and benchmark tests. + +## Effects and Approval + +- [ ] Extract generic canonicalization/pending/timeout/consume mechanics into `ApprovalBroker`. +- [ ] Preserve MCP, Agent pre-check, and live-delegation behavior through `ToolPermissionBroker`. +- [ ] Add `CliMutationGuard` with unique live-request-bound approvals and no replay token. +- [ ] Add targeted approval events and renderer-only `approvals.resolve` IPC. +- [ ] Implement effect/caller/operation policy, scopes, quotas, rate limits, and redacted audit. +- [ ] Add concurrent-identical-call, timeout, abort, cancellation, redaction, and compatibility tests. + +## Administration Surface + +- [ ] Add public/redacted settings reads and allowlisted per-effect updates. +- [ ] Add public/redacted provider/model reads and separated credential mutations. +- [ ] Add reviewed Skill list/enable/install/uninstall operations. +- [ ] Add reviewed MCP list/add/update/remove/enable/start/stop operations. +- [ ] Prove raw MCP calls, arbitrary internal routes, secret reads, and Agent destructive operations are + unreachable. + +## Events and Agent Runs + +- [ ] Add explicit renderer/connection/request/run Event Hub targets. +- [ ] Add bounded queues, ordering, overflow, disconnect, and recovery semantics. +- [ ] Compose detached session creation with initial-turn execution. +- [ ] Add owned status, event streaming, result recovery, and idempotent cancellation. +- [ ] Add event-isolation, backpressure, detached-recovery, recursion-denial, and cancellation tests. + +## Packaging and Agent Use + +- [ ] Package the CLI with the bundled Node runtime on all supported targets. +- [ ] Add opt-in, reversible platform launcher/PATH integration. +- [ ] Add in-memory scoped Agent token issuance, expiry, revocation, and quotas. +- [ ] Harden shell permission checks for redirection and compound syntax before Agent enablement. +- [ ] Keep `deepchat` out of `SAFE_COMMANDS`, enforce domain/verb-first grammar, and deny Agent + artifact-byte/output-path access. +- [ ] Add the bundled CLI Skill without exposing the human descriptor. +- [ ] Add packaged diagnostics/compute/artifact/OCR/Agent-policy smoke coverage. + +## Validation and Delivery + +- [ ] Run focused tests after each implementation slice. +- [ ] Run format and i18n validation. +- [ ] Run lint and typecheck. +- [ ] Run the full test suite and production build. +- [ ] Run current-platform unsigned packaging and packaged smoke where prerequisites allow. +- [ ] Complete a severity-ranked review before every commit and resolve findings. +- [ ] Commit all V1 work locally with behavior-specific messages. +- [ ] Do not push. + +## Local Validation Evidence + +Not yet recorded. From d2957fa142cf93fbcd7cd940ceca604548b25a45 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 10:27:49 +0800 Subject: [PATCH 02/51] feat(cli): add typed caller foundation --- .../architecture/local-control-plane/tasks.md | 4 +- src/main/app/composition.ts | 4 +- src/main/app/routes.ts | 15 +- src/main/desktop/routes.ts | 65 +- src/main/mcp/routes.ts | 17 +- src/main/notifications/routes.ts | 8 +- src/main/provider/routes.ts | 9 +- src/main/routes/index.ts | 24 +- src/main/routes/routeRegistry.ts | 60 +- src/main/session/routes.ts | 28 +- src/main/session/sessionService.ts | 6 +- src/shared/contracts/localControl.ts | 221 +++++++ test/main/app/routes.test.ts | 17 +- test/main/contracts/localControl.test.ts | 90 +++ test/main/notifications/routes.test.ts | 30 +- test/main/ocr/routes.test.ts | 7 +- .../orchestration/orchestrationRoutes.test.ts | 3 +- test/main/provider/routes.test.ts | 3 +- test/main/routes/dispatcher.test.ts | 615 +++++------------- test/main/routes/routeRegistry.test.ts | 55 ++ test/main/session/sessionService.test.ts | 3 +- 21 files changed, 745 insertions(+), 539 deletions(-) create mode 100644 src/shared/contracts/localControl.ts create mode 100644 test/main/contracts/localControl.test.ts create mode 100644 test/main/routes/routeRegistry.test.ts diff --git a/docs/architecture/local-control-plane/tasks.md b/docs/architecture/local-control-plane/tasks.md index f857d86a3..85d0926df 100644 --- a/docs/architecture/local-control-plane/tasks.md +++ b/docs/architecture/local-control-plane/tasks.md @@ -9,11 +9,11 @@ - [x] Specify `CLI_SURFACE_V1`, the ten-file `RouteCaller` migration, and `ApprovalBroker` adapters. - [x] Specify transport, discovery, artifact, event, CLI grammar, packaging, and Agent-token contracts. - [x] Complete the severity-ranked architecture-document review and resolve its findings. -- [ ] Commit the accepted SDD locally. +- [x] Commit the accepted SDD locally. ## Typed Foundation -- [ ] Add `RouteCaller` and migrate renderer-dependent integrations without behavior change. +- [x] Add `RouteCaller` and migrate renderer-dependent integrations without behavior change. - [ ] Add canonical local-control contracts and redacted public DTOs. - [ ] Define and test the deny-by-default versioned surface registry. - [ ] Add local-control error codes, request/result envelopes, and route limits. diff --git a/src/main/app/composition.ts b/src/main/app/composition.ts index c3d7ad8f9..adf614da3 100644 --- a/src/main/app/composition.ts +++ b/src/main/app/composition.ts @@ -2169,8 +2169,8 @@ export async function createMainProcessControl(dependencies: { const appRoutes = createAppRoutes({ logging: loggingService, rendererPerformance: rendererPerformanceLogService, - isMainWindowContext: (context) => - windowPresenter.mainWindow?.webContents.id === context.webContentsId, + isMainWindowContext: (caller) => + windowPresenter.mainWindow?.webContents.id === caller.webContentsId, agentSettings, projects: projectService, databaseSecurity: databaseSecurityService, diff --git a/src/main/app/routes.ts b/src/main/app/routes.ts index 2af85ef54..348368e37 100644 --- a/src/main/app/routes.ts +++ b/src/main/app/routes.ts @@ -19,7 +19,12 @@ import { import type { DatabaseSecurityService } from './databaseSecurity' import type { StartupWorkloadCoordinator } from '@/app/startupWorkloadCoordinator' import type { SessionQuery } from '@/session/query' -import { createRouteMap, type DeepchatRouteMap, type RouteContext } from '@/routes/routeRegistry' +import { + createRouteMap, + requireRendererCaller, + type DeepchatRouteMap, + type RendererRouteCaller +} from '@/routes/routeRegistry' import { createDebugMockChatSession, type DebugMockChatDatabase @@ -32,7 +37,7 @@ import type { SplashWindow } from './splashWindow' export function createAppRoutes(deps: { logging: Pick rendererPerformance: Pick - isMainWindowContext(context: RouteContext): boolean + isMainWindowContext(caller: RendererRouteCaller): boolean agentSettings: Pick projects: Pick databaseSecurity: Pick @@ -136,7 +141,8 @@ export function createAppRoutes(deps: { performanceRecordRendererRoute.name, async (rawInput, context) => { const record = performanceRecordRendererRoute.input.parse(rawInput) - if (!deps.isMainWindowContext(context)) { + const caller = requireRendererCaller(context) + if (!deps.isMainWindowContext(caller)) { return performanceRecordRendererRoute.output.parse({ accepted: false }) } const accepted = await deps.rendererPerformance.record(record) @@ -147,6 +153,7 @@ export function createAppRoutes(deps: { startupGetBootstrapRoute.name, async (rawInput, context) => { startupGetBootstrapRoute.input.parse(rawInput) + const caller = requireRendererCaller(context) return await deps.startup.scheduleTask({ id: 'main.bootstrap:route', target: 'main', @@ -157,7 +164,7 @@ export function createAppRoutes(deps: { dedupeKey: 'main.bootstrap:route', runId: deps.startup.getRunId('main'), run: async () => { - const activeSessionId = deps.desktopSession.getActiveId(context.webContentsId) + const activeSessionId = deps.desktopSession.getActiveId(caller.webContentsId) const activeSession = activeSessionId ? ((await deps.startupSession.getLightweightByIds([activeSessionId]))[0] ?? null) : null diff --git a/src/main/desktop/routes.ts b/src/main/desktop/routes.ts index 46a9c188c..2a9e9dcb6 100644 --- a/src/main/desktop/routes.ts +++ b/src/main/desktop/routes.ts @@ -60,7 +60,12 @@ import { type SettingsActivityInput } from '@shared/contracts/routes' import { DEV_EVENTS } from '../events' -import { createRouteMap, type DeepchatRouteMap, type RouteContext } from '@/routes/routeRegistry' +import { + createRouteMap, + requireRendererCaller, + type DeepchatRouteMap, + type RouteContext +} from '@/routes/routeRegistry' import type { DesktopSessionBinding } from '@/desktop/sessionBinding' export function createDesktopRoutes(deps: { @@ -84,14 +89,15 @@ export function createDesktopRoutes(deps: { dialogService } = deps const readWindowState = (context: RouteContext) => { - const window = context.windowId == null ? null : BrowserWindow.fromId(context.windowId) + const caller = requireRendererCaller(context) + const window = caller.windowId == null ? null : BrowserWindow.fromId(caller.windowId) const exists = Boolean(window && !window.isDestroyed()) return { - windowId: context.windowId, + windowId: caller.windowId, exists, isMaximized: exists ? window!.isMaximized() : false, isFullScreen: exists ? window!.isFullScreen() : false, - isFocused: exists ? windowPresenter.isMainWindowFocused(context.windowId!) : false + isFocused: exists ? windowPresenter.isMainWindowFocused(caller.windowId!) : false } } const readBrowserStatus = async (sessionId: string) => @@ -233,9 +239,10 @@ export function createDesktopRoutes(deps: { windowGetRuntimeIdentityRoute.name, async (rawInput, context) => { windowGetRuntimeIdentityRoute.input.parse(rawInput) + const caller = requireRendererCaller(context) return windowGetRuntimeIdentityRoute.output.parse({ - windowId: context.windowId, - webContentsId: context.webContentsId + windowId: caller.windowId, + webContentsId: caller.webContentsId }) } ], @@ -243,7 +250,8 @@ export function createDesktopRoutes(deps: { windowMinimizeCurrentRoute.name, async (rawInput, context) => { windowMinimizeCurrentRoute.input.parse(rawInput) - if (context.windowId != null) windowPresenter.minimize(context.windowId) + const caller = requireRendererCaller(context) + if (caller.windowId != null) windowPresenter.minimize(caller.windowId) return windowMinimizeCurrentRoute.output.parse({ state: readWindowState(context) }) } ], @@ -251,7 +259,8 @@ export function createDesktopRoutes(deps: { windowToggleMaximizeCurrentRoute.name, async (rawInput, context) => { windowToggleMaximizeCurrentRoute.input.parse(rawInput) - if (context.windowId != null) windowPresenter.maximize(context.windowId) + const caller = requireRendererCaller(context) + if (caller.windowId != null) windowPresenter.maximize(caller.windowId) return windowToggleMaximizeCurrentRoute.output.parse({ state: readWindowState(context) }) } ], @@ -259,8 +268,9 @@ export function createDesktopRoutes(deps: { windowCloseCurrentRoute.name, async (rawInput, context) => { windowCloseCurrentRoute.input.parse(rawInput) - if (context.windowId == null) return windowCloseCurrentRoute.output.parse({ closed: false }) - windowPresenter.close(context.windowId) + const caller = requireRendererCaller(context) + if (caller.windowId == null) return windowCloseCurrentRoute.output.parse({ closed: false }) + windowPresenter.close(caller.windowId) return windowCloseCurrentRoute.output.parse({ closed: true }) } ], @@ -268,8 +278,9 @@ export function createDesktopRoutes(deps: { windowCloseFloatingCurrentRoute.name, async (rawInput, context) => { windowCloseFloatingCurrentRoute.input.parse(rawInput) + const caller = requireRendererCaller(context) const window = windowPresenter.getFloatingChatWindow()?.getWindow() ?? null - if (!window || window.isDestroyed() || window.webContents.id !== context.webContentsId) { + if (!window || window.isDestroyed() || window.webContents.id !== caller.webContentsId) { return windowCloseFloatingCurrentRoute.output.parse({ closed: false }) } windowPresenter.hide(window.id) @@ -304,7 +315,8 @@ export function createDesktopRoutes(deps: { windowNotifySettingsReadyRoute.name, async (rawInput, context) => { windowNotifySettingsReadyRoute.input.parse(rawInput) - windowPresenter.notifySettingsReady(context.webContentsId) + const caller = requireRendererCaller(context) + windowPresenter.notifySettingsReady(caller.webContentsId) return windowNotifySettingsReadyRoute.output.parse({ notified: true }) } ], @@ -352,6 +364,7 @@ export function createDesktopRoutes(deps: { browserLoadUrlRoute.name, async (rawInput, context) => { const input = browserLoadUrlRoute.input.parse(rawInput) + const caller = requireRendererCaller(context) const browser = browserPresenter as IYoBrowserPresenter & { loadUrl( sessionId: string, @@ -365,7 +378,7 @@ export function createDesktopRoutes(deps: { input.sessionId, input.url, input.timeoutMs, - context.windowId ?? undefined + caller.windowId ?? undefined ) }) } @@ -374,11 +387,12 @@ export function createDesktopRoutes(deps: { browserAttachCurrentWindowRoute.name, async (rawInput, context) => { const input = browserAttachCurrentWindowRoute.input.parse(rawInput) - if (context.windowId == null) { + const caller = requireRendererCaller(context) + if (caller.windowId == null) { return browserAttachCurrentWindowRoute.output.parse({ attached: false }) } return browserAttachCurrentWindowRoute.output.parse({ - attached: await browserPresenter.attachSessionBrowser(input.sessionId, context.windowId) + attached: await browserPresenter.attachSessionBrowser(input.sessionId, caller.windowId) }) } ], @@ -386,12 +400,13 @@ export function createDesktopRoutes(deps: { browserUpdateCurrentWindowBoundsRoute.name, async (rawInput, context) => { const input = browserUpdateCurrentWindowBoundsRoute.input.parse(rawInput) - if (context.windowId == null) { + const caller = requireRendererCaller(context) + if (caller.windowId == null) { return browserUpdateCurrentWindowBoundsRoute.output.parse({ updated: false }) } await browserPresenter.updateSessionBrowserBounds( input.sessionId, - context.windowId, + caller.windowId, input.bounds, input.visible ) @@ -410,11 +425,12 @@ export function createDesktopRoutes(deps: { browserSetPreviewModeRoute.name, async (rawInput, context) => { const input = browserSetPreviewModeRoute.input.parse(rawInput) + const caller = requireRendererCaller(context) return browserSetPreviewModeRoute.output.parse( await browserPresenter.setPreviewMode( input.sessionId, input.mode, - context.windowId ?? undefined, + caller.windowId ?? undefined, input.runId ) ) @@ -433,10 +449,11 @@ export function createDesktopRoutes(deps: { computerUseSetPreviewModeRoute.name, async (rawInput, context) => { const input = computerUseSetPreviewModeRoute.input.parse(rawInput) + const caller = requireRendererCaller(context) if ( - context.windowId == null || + caller.windowId == null || (input.mode !== 'stopped' && - deps.desktopSessionBinding.getActiveId(context.webContentsId) !== input.sessionId) + deps.desktopSessionBinding.getActiveId(caller.webContentsId) !== input.sessionId) ) { return computerUseSetPreviewModeRoute.output.parse({ updated: false, @@ -447,7 +464,7 @@ export function createDesktopRoutes(deps: { await computerUsePreviewPresenter.setPreviewMode( input.sessionId, input.mode, - context.windowId + caller.windowId ) ) } @@ -456,8 +473,9 @@ export function createDesktopRoutes(deps: { computerUseDismissPreviewRoute.name, async (rawInput, context) => { const input = computerUseDismissPreviewRoute.input.parse(rawInput) + const caller = requireRendererCaller(context) const active = - deps.desktopSessionBinding.getActiveId(context.webContentsId) === input.sessionId + deps.desktopSessionBinding.getActiveId(caller.webContentsId) === input.sessionId return computerUseDismissPreviewRoute.output.parse({ dismissed: active && computerUsePreviewPresenter.dismissPreview(input.sessionId, input.runId) @@ -535,8 +553,9 @@ export function createDesktopRoutes(deps: { tabCaptureCurrentAreaRoute.name, async (rawInput, context) => { const input = tabCaptureCurrentAreaRoute.input.parse(rawInput) + const caller = requireRendererCaller(context) return tabCaptureCurrentAreaRoute.output.parse({ - imageData: await tabPresenter.captureTabArea(context.webContentsId, input.rect) + imageData: await tabPresenter.captureTabArea(caller.webContentsId, input.rect) }) } ], diff --git a/src/main/mcp/routes.ts b/src/main/mcp/routes.ts index f914b439e..2b0d9ad0a 100644 --- a/src/main/mcp/routes.ts +++ b/src/main/mcp/routes.ts @@ -64,7 +64,12 @@ import { mcpUpdateServerRoute, type SettingsActivityInput } from '@shared/contracts/routes' -import { createRouteMap, type DeepchatRouteMap, type RouteContext } from '@/routes/routeRegistry' +import { + createRouteMap, + requireRendererCaller, + type DeepchatRouteMap, + type RouteContext +} from '@/routes/routeRegistry' import { assertBoundedMcpJson } from './schemaValidation' const MCP_APP_ROUTE_INPUT_MAX_BYTES = 3 * 1024 * 1024 @@ -81,16 +86,18 @@ export function createMcpRoutes(deps: { }): DeepchatRouteMap { const { mcpService } = deps const appContext = (context: RouteContext) => { - if (context.windowId === null || deps.isSettingsWindow(context.windowId)) { + const caller = requireRendererCaller(context) + if (caller.windowId === null || deps.isSettingsWindow(caller.windowId)) { throw new Error('MCP Apps are restricted to conversation windows') } return { - webContentsId: context.webContentsId, - windowId: context.windowId + webContentsId: caller.webContentsId, + windowId: caller.windowId } } const assertSettingsWindow = (context: RouteContext): void => { - if (!deps.isSettingsWindow(context.windowId)) { + const caller = requireRendererCaller(context) + if (!deps.isSettingsWindow(caller.windowId)) { throw new Error('MCP credential changes are restricted to the settings window') } } diff --git a/src/main/notifications/routes.ts b/src/main/notifications/routes.ts index 8e4e78897..edd931906 100644 --- a/src/main/notifications/routes.ts +++ b/src/main/notifications/routes.ts @@ -2,7 +2,7 @@ import { notificationAcknowledgePresentationRoute, notificationRendererReadyRoute } from '@shared/contracts/routes' -import { createRouteMap } from '@/routes/routeRegistry' +import { createRouteMap, requireRendererCaller } from '@/routes/routeRegistry' export type NotificationRoutesDependencies = Readonly<{ rendererReady: (webContentsId: number) => Promise @@ -15,8 +15,9 @@ export const createNotificationRoutes = (dependencies: NotificationRoutesDepende notificationRendererReadyRoute.name, async (rawInput, context) => { notificationRendererReadyRoute.input.parse(rawInput) + const caller = requireRendererCaller(context) return notificationRendererReadyRoute.output.parse({ - ready: await dependencies.rendererReady(context.webContentsId) + ready: await dependencies.rendererReady(caller.webContentsId) }) } ], @@ -24,10 +25,11 @@ export const createNotificationRoutes = (dependencies: NotificationRoutesDepende notificationAcknowledgePresentationRoute.name, async (rawInput, context) => { const input = notificationAcknowledgePresentationRoute.input.parse(rawInput) + const caller = requireRendererCaller(context) return notificationAcknowledgePresentationRoute.output.parse({ accepted: await dependencies.acknowledgePresentation( input.episodeId, - context.webContentsId + caller.webContentsId ) }) } diff --git a/src/main/provider/routes.ts b/src/main/provider/routes.ts index d6f550d06..cafdf675a 100644 --- a/src/main/provider/routes.ts +++ b/src/main/provider/routes.ts @@ -64,7 +64,11 @@ import { providersWarmupAcpProcessRoute, type SettingsActivityInput } from '@shared/contracts/routes' -import { createRouteMap, type DeepchatRouteMap } from '@/routes/routeRegistry' +import { + createRouteMap, + requireRendererCaller, + type DeepchatRouteMap +} from '@/routes/routeRegistry' import type { ProviderImportService } from './providerImportService' import { ProviderService, type ProviderQueryScheduler } from './providerService' import type { ProviderRuntime } from '.' @@ -369,10 +373,11 @@ export function createProviderRoutes(deps: { providersRunAcpDebugActionRoute.name, async (rawInput, context) => { const input = providersRunAcpDebugActionRoute.input.parse(rawInput) + const caller = requireRendererCaller(context) return providersRunAcpDebugActionRoute.output.parse({ result: await acpProviderAdminPort.runAcpDebugAction({ ...input, - webContentsId: context.webContentsId + webContentsId: caller.webContentsId }) }) } diff --git a/src/main/routes/index.ts b/src/main/routes/index.ts index 0f4bade2e..d701f5266 100644 --- a/src/main/routes/index.ts +++ b/src/main/routes/index.ts @@ -14,7 +14,12 @@ import { sessionsListLightweightRoute, skillsListMetadataRoute } from '@shared/contracts/routes' -import { createRouteRegistry, type DeepchatRouteMap, type RouteContext } from './routeRegistry' +import { + createRendererRouteContext, + createRouteRegistry, + type DeepchatRouteMap, + type RouteContext +} from './routeRegistry' import type { StartupWorkloadCoordinator } from '@/app/startupWorkloadCoordinator' export type RouteDispatcher = { @@ -61,10 +66,10 @@ type StartupTrackedRouteTask = { } function isSettingsWindowContext(dispatcher: RouteDispatcher, context: RouteContext): boolean { - if (context.windowId == null) { + if (context.caller.kind !== 'renderer' || context.caller.windowId == null) { return false } - return dispatcher.settingsWindow.getSettingsWindowId() === context.windowId + return dispatcher.settingsWindow.getSettingsWindowId() === context.caller.windowId } function resolveTrackedRouteTask( @@ -219,10 +224,15 @@ export function registerDeepchatRoutes(ipcMain: IpcMain, dispatcher: RouteDispat ipcMain.handle( DEEPCHAT_ROUTE_INVOKE_CHANNEL, async (event: IpcMainInvokeEvent, routeName: string, rawInput: unknown) => { - return await dispatchDeepchatRoute(dispatcher, routeName, rawInput, { - webContentsId: event.sender.id, - windowId: BrowserWindow.fromWebContents(event.sender)?.id ?? null - }) + return await dispatchDeepchatRoute( + dispatcher, + routeName, + rawInput, + createRendererRouteContext( + event.sender.id, + BrowserWindow.fromWebContents(event.sender)?.id ?? null + ) + ) } ) } diff --git a/src/main/routes/routeRegistry.ts b/src/main/routes/routeRegistry.ts index e995ead45..84343d737 100644 --- a/src/main/routes/routeRegistry.ts +++ b/src/main/routes/routeRegistry.ts @@ -1,8 +1,66 @@ import type { DeepchatRouteName } from '@shared/contracts/routes' +import type { LocalControlScope } from '@shared/contracts/localControl' -export type RouteContext = { +export type RendererRouteCaller = Readonly<{ + kind: 'renderer' webContentsId: number windowId: number | null +}> + +export type HumanCliRouteCaller = Readonly<{ + kind: 'cli' + principal: 'human' + connectionId: string + scopes: readonly LocalControlScope[] +}> + +export type AgentCliRouteCaller = Readonly<{ + kind: 'cli' + principal: 'agent' + connectionId: string + scopes: readonly LocalControlScope[] + conversationId: string + expiresAt: number +}> + +export type CliRouteCaller = HumanCliRouteCaller | AgentCliRouteCaller + +export type InternalRouteCaller = Readonly<{ + kind: 'internal' + component: 'scheduler' | 'migration' | 'agent-cli' +}> + +export type RouteCaller = RendererRouteCaller | CliRouteCaller | InternalRouteCaller + +export type RouteContext = Readonly<{ + caller: RouteCaller +}> + +export function createRendererRouteCaller( + webContentsId: number, + windowId: number | null +): RendererRouteCaller { + return { + kind: 'renderer', + webContentsId, + windowId + } +} + +export function createRendererRouteContext( + webContentsId: number, + windowId: number | null +): RouteContext { + return { + caller: createRendererRouteCaller(webContentsId, windowId) + } +} + +export function requireRendererCaller(context: RouteContext): RendererRouteCaller { + if (context.caller.kind !== 'renderer') { + throw new Error('Route requires a renderer caller') + } + return context.caller } export type DeepchatRouteHandler = (rawInput: unknown, context: RouteContext) => Promise diff --git a/src/main/session/routes.ts b/src/main/session/routes.ts index c0a3c6ebe..d29c11656 100644 --- a/src/main/session/routes.ts +++ b/src/main/session/routes.ts @@ -61,7 +61,11 @@ import type { SessionPermissionPort } from '@/session/contracts' import type { UsageStatsService } from '@/session/usageStatsService' import type { AgentSessionExportService } from '@/exporter/agentSessionExporter' import { listAvailableAgents } from '@/agent/shared/availableAgentCatalog' -import { createRouteMap, type DeepchatRouteMap } from '@/routes/routeRegistry' +import { + createRouteMap, + requireRendererCaller, + type DeepchatRouteMap +} from '@/routes/routeRegistry' import type { Scheduler } from '@/routes/scheduler' import type { SessionAgentAssignmentPort, SessionLifecyclePort, SessionTurnPort } from './contracts' import type { SessionQuery } from './query' @@ -139,14 +143,15 @@ export function createSessionRoutes(deps: { sessionsCreateRoute.name, async (rawInput, context) => { const input = sessionsCreateRoute.input.parse(rawInput) + const caller = requireRendererCaller(context) const { submissionId, ...createInput } = input const created = await withSubmissionCancellation( - context.webContentsId, + caller.webContentsId, submissionId, async (signal) => signal - ? await sessionService.createSession(createInput, context, { signal }) - : await sessionService.createSession(createInput, context) + ? await sessionService.createSession(createInput, caller, { signal }) + : await sessionService.createSession(createInput, caller) ) const { initialTurn, ...session } = created return sessionsCreateRoute.output.parse({ @@ -206,7 +211,7 @@ export function createSessionRoutes(deps: { sessionsActivateRoute.name, async (rawInput, context) => { const input = sessionsActivateRoute.input.parse(rawInput) - await sessionService.activateSession(context, input.sessionId) + await sessionService.activateSession(requireRendererCaller(context), input.sessionId) return sessionsActivateRoute.output.parse({ activated: true }) } ], @@ -214,7 +219,7 @@ export function createSessionRoutes(deps: { sessionsDeactivateRoute.name, async (rawInput, context) => { sessionsDeactivateRoute.input.parse(rawInput) - await sessionService.deactivateSession(context) + await sessionService.deactivateSession(requireRendererCaller(context)) return sessionsDeactivateRoute.output.parse({ deactivated: true }) } ], @@ -223,7 +228,7 @@ export function createSessionRoutes(deps: { async (rawInput, context) => { sessionsGetActiveRoute.input.parse(rawInput) return sessionsGetActiveRoute.output.parse({ - session: await sessionService.getActiveSession(context) + session: await sessionService.getActiveSession(requireRendererCaller(context)) }) } ], @@ -646,9 +651,10 @@ export function createSessionRoutes(deps: { chatSendMessageRoute.name, async (rawInput, context) => { const input = chatSendMessageRoute.input.parse(rawInput) + const caller = requireRendererCaller(context) return chatSendMessageRoute.output.parse( await withSubmissionCancellation( - context.webContentsId, + caller.webContentsId, input.submissionId, async (signal) => signal @@ -662,9 +668,10 @@ export function createSessionRoutes(deps: { chatSteerActiveTurnRoute.name, async (rawInput, context) => { const input = chatSteerActiveTurnRoute.input.parse(rawInput) + const caller = requireRendererCaller(context) return chatSteerActiveTurnRoute.output.parse( await withSubmissionCancellation( - context.webContentsId, + caller.webContentsId, input.submissionId, async (signal) => signal @@ -678,8 +685,9 @@ export function createSessionRoutes(deps: { chatCancelSubmissionRoute.name, async (rawInput, context) => { const input = chatCancelSubmissionRoute.input.parse(rawInput) + const caller = requireRendererCaller(context) return chatCancelSubmissionRoute.output.parse({ - cancelled: submissionCancellations.cancel(context.webContentsId, input.submissionId) + cancelled: submissionCancellations.cancel(caller.webContentsId, input.submissionId) }) } ], diff --git a/src/main/session/sessionService.ts b/src/main/session/sessionService.ts index b8b54d708..c92a1a9f7 100644 --- a/src/main/session/sessionService.ts +++ b/src/main/session/sessionService.ts @@ -6,15 +6,13 @@ import type { SessionWithState } from '@shared/types/agent-interface' import type { Scheduler } from '@/routes/scheduler' +import type { RendererRouteCaller } from '@/routes/routeRegistry' const SESSION_OPERATION_TIMEOUT_MS = 5_000 const SESSION_LIST_TIMEOUT_MS = 15_000 const DEFAULT_RESTORE_MESSAGE_LIMIT = 100 -export type SessionRouteContext = { - webContentsId: number - windowId: number | null -} +export type SessionRouteContext = RendererRouteCaller export type SessionListFilters = { agentId?: string diff --git a/src/shared/contracts/localControl.ts b/src/shared/contracts/localControl.ts new file mode 100644 index 000000000..e2e8562f6 --- /dev/null +++ b/src/shared/contracts/localControl.ts @@ -0,0 +1,221 @@ +import { z } from 'zod' +import { JsonValueSchema, TimestampMsSchema, type JsonValue } from './common' + +export const LOCAL_CONTROL_PROTOCOL_VERSION = 1 as const +export const LOCAL_CONTROL_SURFACE_VERSION = 1 as const +export const LOCAL_CONTROL_DESCRIPTOR_FILENAME = 'local-control.json' + +export const LOCAL_CONTROL_EFFECTS = [ + 'read', + 'compute', + 'local-maintenance', + 'preference-write', + 'security-config', + 'execution-config', + 'supply-chain', + 'credential', + 'destructive' +] as const + +export const LocalControlEffectSchema = z.enum(LOCAL_CONTROL_EFFECTS) + +export const LOCAL_CONTROL_SCOPES = [ + 'system:read', + 'models:read', + 'models:invoke', + 'media:generate', + 'audio:transcribe', + 'ocr:read', + 'ocr:extract', + 'sessions:run', + 'runs:read', + 'runs:cancel', + 'artifacts:read', + 'artifacts:manage', + 'settings:read', + 'settings:write', + 'providers:read', + 'providers:write', + 'providers:credential', + 'skills:read', + 'skills:write', + 'mcp:read', + 'mcp:write' +] as const + +export const LocalControlScopeSchema = z.enum(LOCAL_CONTROL_SCOPES) +export const LocalControlScopesSchema = z + .array(LocalControlScopeSchema) + .max(LOCAL_CONTROL_SCOPES.length) + .superRefine((scopes, context) => { + const seen = new Set() + scopes.forEach((scope, index) => { + if (seen.has(scope)) { + context.addIssue({ + code: 'custom', + message: `Duplicate local-control scope: ${scope}`, + path: [index] + }) + } + seen.add(scope) + }) + }) + +export const LocalControlPrincipalSchema = z.enum(['human', 'agent']) + +export const LocalControlEndpointSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('unix'), + path: z + .string() + .min(1) + .max(4096) + .refine((value) => !value.includes('\0'), { + message: 'Unix socket path must not contain NUL' + }) + }), + z.object({ + kind: z.literal('pipe'), + name: z + .string() + .min(1) + .max(1024) + .refine((value) => !value.includes('\0'), { + message: 'Named pipe must not contain NUL' + }) + }) +]) + +export const LocalControlDescriptorSchema = z + .object({ + protocolVersion: z.literal(LOCAL_CONTROL_PROTOCOL_VERSION), + surfaceVersion: z.literal(LOCAL_CONTROL_SURFACE_VERSION), + appVersion: z.string().min(1).max(128), + endpoint: LocalControlEndpointSchema, + pid: z.number().int().positive().max(2_147_483_647), + token: z + .string() + .min(43) + .max(256) + .regex(/^[A-Za-z0-9_-]+$/), + startedAt: TimestampMsSchema + }) + .strict() + +const LocalControlRequestIdSchema = z + .string() + .min(1) + .max(128) + .regex(/^[A-Za-z0-9._:-]+$/) + +export const LocalControlMethodSchema = z + .string() + .min(3) + .max(128) + .regex(/^[a-z][A-Za-z0-9]*(?:\.[a-z][A-Za-z0-9]*)+$/) + +export const LocalControlRpcRequestSchema = z + .object({ + protocolVersion: z.literal(LOCAL_CONTROL_PROTOCOL_VERSION), + surfaceVersion: z.literal(LOCAL_CONTROL_SURFACE_VERSION), + id: LocalControlRequestIdSchema, + method: LocalControlMethodSchema, + params: JsonValueSchema.default({}) + }) + .strict() + +export const LOCAL_CONTROL_ERROR_CODES = [ + 'invalid_request', + 'unsupported_version', + 'authentication_failed', + 'permission_denied', + 'approval_denied', + 'approval_timeout', + 'not_found', + 'conflict', + 'rate_limited', + 'body_too_large', + 'unavailable', + 'cancelled', + 'timeout', + 'internal_error' +] as const + +export const LocalControlErrorCodeSchema = z.enum(LOCAL_CONTROL_ERROR_CODES) + +export const LocalControlErrorSchema = z + .object({ + code: LocalControlErrorCodeSchema, + message: z.string().min(1).max(4096), + retriable: z.boolean(), + details: z.record(z.string().max(128), JsonValueSchema).optional() + }) + .strict() + +export const LocalControlRpcResponseSchema = z.discriminatedUnion('ok', [ + z + .object({ + protocolVersion: z.literal(LOCAL_CONTROL_PROTOCOL_VERSION), + surfaceVersion: z.literal(LOCAL_CONTROL_SURFACE_VERSION), + id: LocalControlRequestIdSchema, + ok: z.literal(true), + result: JsonValueSchema + }) + .strict(), + z + .object({ + protocolVersion: z.literal(LOCAL_CONTROL_PROTOCOL_VERSION), + surfaceVersion: z.literal(LOCAL_CONTROL_SURFACE_VERSION), + id: LocalControlRequestIdSchema, + ok: z.literal(false), + error: LocalControlErrorSchema + }) + .strict() +]) + +export const LocalControlEventEnvelopeSchema = z + .object({ + protocolVersion: z.literal(LOCAL_CONTROL_PROTOCOL_VERSION), + surfaceVersion: z.literal(LOCAL_CONTROL_SURFACE_VERSION), + sequence: z.number().int().nonnegative(), + timestamp: TimestampMsSchema, + requestId: LocalControlRequestIdSchema.optional(), + runId: z.string().min(1).max(128).optional(), + event: z.string().min(1).max(128), + data: JsonValueSchema + }) + .strict() + +export type LocalControlEffect = z.infer +export type LocalControlScope = z.infer +export type LocalControlPrincipal = z.infer +export type LocalControlEndpoint = z.infer +export type LocalControlDescriptor = z.infer +export type LocalControlRpcRequest = z.infer +export type LocalControlErrorCode = z.infer +export type LocalControlError = z.infer +export type LocalControlRpcResponse = z.infer +export type LocalControlEventEnvelope = z.infer + +export function createLocalControlSuccess(id: string, result: JsonValue): LocalControlRpcResponse { + return LocalControlRpcResponseSchema.parse({ + protocolVersion: LOCAL_CONTROL_PROTOCOL_VERSION, + surfaceVersion: LOCAL_CONTROL_SURFACE_VERSION, + id, + ok: true, + result + }) +} + +export function createLocalControlFailure( + id: string, + error: LocalControlError +): LocalControlRpcResponse { + return LocalControlRpcResponseSchema.parse({ + protocolVersion: LOCAL_CONTROL_PROTOCOL_VERSION, + surfaceVersion: LOCAL_CONTROL_SURFACE_VERSION, + id, + ok: false, + error + }) +} diff --git a/test/main/app/routes.test.ts b/test/main/app/routes.test.ts index e5483c0a6..2dceecdf1 100644 --- a/test/main/app/routes.test.ts +++ b/test/main/app/routes.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { createRendererRouteContext } from '@/routes/routeRegistry' import { app } from 'electron' import { debugCloseSplashScenarioRoute, @@ -63,10 +64,10 @@ describe('app debug splash routes', () => { const show = routes.get(debugShowSplashScenarioRoute.name) const close = routes.get(debugCloseSplashScenarioRoute.name) - await expect(show?.({ mode: 'unlock' }, { webContentsId: 1, windowId: 1 })).resolves.toEqual({ + await expect(show?.({ mode: 'unlock' }, createRendererRouteContext(1, 1))).resolves.toEqual({ shown: false }) - await expect(close?.({}, { webContentsId: 1, windowId: 1 })).resolves.toEqual({ + await expect(close?.({}, createRendererRouteContext(1, 1))).resolves.toEqual({ closed: false }) expect(splash.showDebugScenario).not.toHaveBeenCalled() @@ -85,13 +86,13 @@ describe('app debug splash routes', () => { const show = routes.get(debugShowSplashScenarioRoute.name) const close = routes.get(debugCloseSplashScenarioRoute.name) - await expect(show?.({ mode: 'unlock' }, { webContentsId: 1, windowId: 1 })).resolves.toEqual({ + await expect(show?.({ mode: 'unlock' }, createRendererRouteContext(1, 1))).resolves.toEqual({ shown: true }) expect(splash.showDebugScenario).toHaveBeenCalledWith('unlock') - await expect(close?.({}, { webContentsId: 1, windowId: 1 })).resolves.toEqual({ closed: true }) + await expect(close?.({}, createRendererRouteContext(1, 1))).resolves.toEqual({ closed: true }) expect(splash.closeDebugScenario).toHaveBeenCalledTimes(1) - await expect(show?.({ mode: 'invalid' }, { webContentsId: 1, windowId: 1 })).rejects.toThrow() + await expect(show?.({ mode: 'invalid' }, createRendererRouteContext(1, 1))).rejects.toThrow() }) }) @@ -101,7 +102,7 @@ describe('app performance diagnostics route', () => { const routes = createRoutes({ rendererPerformance }) const handler = routes.get(performanceRecordRendererRoute.name) - await expect(handler?.(validRecord, { webContentsId: 1, windowId: 1 })).resolves.toEqual({ + await expect(handler?.(validRecord, createRendererRouteContext(1, 1))).resolves.toEqual({ accepted: true }) expect(rendererPerformance.record).toHaveBeenCalledWith(validRecord) @@ -115,7 +116,7 @@ describe('app performance diagnostics route', () => { }) const handler = routes.get(performanceRecordRendererRoute.name) - await expect(handler?.(validRecord, { webContentsId: 2, windowId: 2 })).resolves.toEqual({ + await expect(handler?.(validRecord, createRendererRouteContext(2, 2))).resolves.toEqual({ accepted: false }) expect(rendererPerformance.record).not.toHaveBeenCalled() @@ -128,7 +129,7 @@ describe('app performance diagnostics route', () => { await expect( handler?.( { ...validRecord, metadata: { sessionId: 'sensitive' } }, - { webContentsId: 1, windowId: 1 } + createRendererRouteContext(1, 1) ) ).rejects.toThrow() }) diff --git a/test/main/contracts/localControl.test.ts b/test/main/contracts/localControl.test.ts new file mode 100644 index 000000000..20eff7496 --- /dev/null +++ b/test/main/contracts/localControl.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { + LOCAL_CONTROL_PROTOCOL_VERSION, + LOCAL_CONTROL_SURFACE_VERSION, + LocalControlDescriptorSchema, + LocalControlRpcRequestSchema, + LocalControlScopesSchema, + createLocalControlFailure, + createLocalControlSuccess +} from '@shared/contracts/localControl' + +describe('local-control contracts', () => { + it('accepts a bounded private endpoint descriptor', () => { + expect( + LocalControlDescriptorSchema.parse({ + protocolVersion: LOCAL_CONTROL_PROTOCOL_VERSION, + surfaceVersion: LOCAL_CONTROL_SURFACE_VERSION, + appVersion: '1.2.3', + endpoint: { kind: 'unix', path: '/tmp/deepchat.sock' }, + pid: 42, + token: 'a'.repeat(43), + startedAt: 1_000 + }) + ).toMatchObject({ + endpoint: { kind: 'unix', path: '/tmp/deepchat.sock' }, + token: 'a'.repeat(43) + }) + }) + + it('rejects malformed descriptors and duplicate scopes', () => { + expect(() => + LocalControlDescriptorSchema.parse({ + protocolVersion: 2, + surfaceVersion: LOCAL_CONTROL_SURFACE_VERSION, + appVersion: '1.2.3', + endpoint: { kind: 'unix', path: '/tmp/deepchat.sock\0hidden' }, + pid: 0, + token: 'secret', + startedAt: 1_000, + ignored: true + }) + ).toThrow() + expect(() => LocalControlScopesSchema.parse(['models:invoke', 'models:invoke'])).toThrow( + 'Duplicate local-control scope' + ) + }) + + it('requires versioned JSON RPC requests with domain methods', () => { + expect( + LocalControlRpcRequestSchema.parse({ + protocolVersion: LOCAL_CONTROL_PROTOCOL_VERSION, + surfaceVersion: LOCAL_CONTROL_SURFACE_VERSION, + id: 'request-1', + method: 'models.invoke', + params: { prompt: 'hello' } + }) + ).toMatchObject({ id: 'request-1', method: 'models.invoke' }) + + expect(() => + LocalControlRpcRequestSchema.parse({ + protocolVersion: LOCAL_CONTROL_PROTOCOL_VERSION, + surfaceVersion: LOCAL_CONTROL_SURFACE_VERSION, + id: 'request with spaces', + method: 'invoke', + params: {} + }) + ).toThrow() + }) + + it('creates stable success and failure envelopes', () => { + expect(createLocalControlSuccess('request-1', { ready: true })).toEqual({ + protocolVersion: LOCAL_CONTROL_PROTOCOL_VERSION, + surfaceVersion: LOCAL_CONTROL_SURFACE_VERSION, + id: 'request-1', + ok: true, + result: { ready: true } + }) + expect( + createLocalControlFailure('request-2', { + code: 'unavailable', + message: 'Desktop application is not running', + retriable: true + }) + ).toMatchObject({ + id: 'request-2', + ok: false, + error: { code: 'unavailable', retriable: true } + }) + }) +}) diff --git a/test/main/notifications/routes.test.ts b/test/main/notifications/routes.test.ts index b5a3e61e5..18661f7fb 100644 --- a/test/main/notifications/routes.test.ts +++ b/test/main/notifications/routes.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { createRendererRouteContext } from '@/routes/routeRegistry' import { notificationAcknowledgePresentationRoute, notificationRendererReadyRoute @@ -14,7 +15,7 @@ describe('notification routes', () => { }) await expect( - routes.get(notificationRendererReadyRoute.name)?.({}, { webContentsId: 42, windowId: 7 }) + routes.get(notificationRendererReadyRoute.name)?.({}, createRendererRouteContext(42, 7)) ).resolves.toEqual({ ready: true }) expect(rendererReady).toHaveBeenCalledWith(42) }) @@ -29,7 +30,7 @@ describe('notification routes', () => { await expect( routes.get(notificationAcknowledgePresentationRoute.name)?.( { episodeId: 'episode-1' }, - { webContentsId: 42, windowId: 7 } + createRendererRouteContext(42, 7) ) ).resolves.toEqual({ accepted: true }) expect(acknowledgePresentation).toHaveBeenCalledWith('episode-1', 42) @@ -45,9 +46,32 @@ describe('notification routes', () => { await expect( routes.get(notificationAcknowledgePresentationRoute.name)?.( { episodeId: '' }, - { webContentsId: 42, windowId: 7 } + createRendererRouteContext(42, 7) ) ).rejects.toThrow() expect(acknowledgePresentation).not.toHaveBeenCalled() }) + + it('rejects non-renderer callers before invoking renderer ownership services', async () => { + const rendererReady = vi.fn() + const routes = createNotificationRoutes({ + rendererReady, + acknowledgePresentation: vi.fn() + }) + + await expect( + routes.get(notificationRendererReadyRoute.name)?.( + {}, + { + caller: { + kind: 'cli', + principal: 'human', + connectionId: 'connection-1', + scopes: ['system:read'] + } + } + ) + ).rejects.toThrow('Route requires a renderer caller') + expect(rendererReady).not.toHaveBeenCalled() + }) }) diff --git a/test/main/ocr/routes.test.ts b/test/main/ocr/routes.test.ts index b05b8779f..71e544d5c 100644 --- a/test/main/ocr/routes.test.ts +++ b/test/main/ocr/routes.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { createRendererRouteContext } from '@/routes/routeRegistry' import { createOcrRoutes } from '@/ocr/routes' import type { OcrRuntimeServiceStatus } from '@/ocr/ocrRuntimeService' @@ -60,7 +61,7 @@ describe('OCR routes', () => { const routes = createOcrRoutes({ runtime, platform: 'darwin', arch: 'arm64' }) const handler = routes.get('ocr.getRuntimeStatus') - const result = await handler?.({}, { webContentsId: 1, windowId: 1 }) + const result = await handler?.({}, createRendererRouteContext(1, 1)) expect(result).toEqual({ platform: 'darwin', @@ -104,7 +105,7 @@ describe('OCR routes', () => { } const routes = createOcrRoutes({ runtime }) - const result = await routes.get('ocr.clearCache')?.({}, { webContentsId: 1, windowId: null }) + const result = await routes.get('ocr.clearCache')?.({}, createRendererRouteContext(1, null)) expect(runtime.clearCache).toHaveBeenCalledOnce() expect(result).toEqual({ cache: clearedStatus.cache }) @@ -127,7 +128,7 @@ describe('OCR routes', () => { const routes = createOcrRoutes({ runtime, platform: 'win32', arch: 'ia32' }) await expect( - routes.get('ocr.getRuntimeStatus')?.({}, { webContentsId: 1, windowId: null }) + routes.get('ocr.getRuntimeStatus')?.({}, createRendererRouteContext(1, null)) ).resolves.toMatchObject({ platform: 'win32', arch: 'ia32', diff --git a/test/main/orchestration/orchestrationRoutes.test.ts b/test/main/orchestration/orchestrationRoutes.test.ts index 062f35b07..3b4d283c7 100644 --- a/test/main/orchestration/orchestrationRoutes.test.ts +++ b/test/main/orchestration/orchestrationRoutes.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { createRendererRouteContext } from '@/routes/routeRegistry' import { orchestrationGetCapabilityRoute, orchestrationInspectLiveDelegationRoute, @@ -9,7 +10,7 @@ import { import { createOrchestrationRoutes } from '@/orchestration/routes' import type { OrchestrationPolicy } from '@shared/orchestration/policy' -const context = { webContentsId: 1, windowId: 1 } +const context = createRendererRouteContext(1, 1) const liveSummary = { schemaVersion: 1 as const, id: 'delegation-1', diff --git a/test/main/provider/routes.test.ts b/test/main/provider/routes.test.ts index b81993e86..d6784b7de 100644 --- a/test/main/provider/routes.test.ts +++ b/test/main/provider/routes.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { createRendererRouteContext } from '@/routes/routeRegistry' import { createProviderRoutes } from '@/provider/routes' import { modelsGetCapabilitiesRoute, @@ -10,7 +11,7 @@ import { } from '@shared/contracts/routes' import { ModelType } from '@shared/model' -const context = { webContentsId: 42, windowId: 7 } +const context = createRendererRouteContext(42, 7) function createRoutes(deps: { providerSettings: Record diff --git a/test/main/routes/dispatcher.test.ts b/test/main/routes/dispatcher.test.ts index 92a3402a6..4bb012051 100644 --- a/test/main/routes/dispatcher.test.ts +++ b/test/main/routes/dispatcher.test.ts @@ -30,6 +30,7 @@ import { decodeMemoryPageCursor } from '@shared/contracts/routes' import { createRouteDispatcher, dispatchDeepchatRoute } from '@/routes' +import { createRendererRouteContext } from '@/routes/routeRegistry' import { createNodeScheduler } from '@/routes/scheduler' import { ProviderImportService } from '@/provider/providerImportService' import { createProviderRoutes } from '@/provider/routes' @@ -1845,7 +1846,7 @@ function createRuntime() { describe('dispatchDeepchatRoute', () => { it('routes database imports through the App maintenance owner', async () => { const { runtime, appDatabaseMaintenance } = createRuntime() - const context = { webContentsId: 42, windowId: 7 } + const context = createRendererRouteContext(42, 7) await dispatchDeepchatRoute( runtime, @@ -1861,7 +1862,7 @@ describe('dispatchDeepchatRoute', () => { it('routes database security migrations through the App maintenance owner', async () => { const { runtime, appDatabaseMaintenance } = createRuntime() - const context = { webContentsId: 42, windowId: 7 } + const context = createRendererRouteContext(42, 7) await dispatchDeepchatRoute( runtime, @@ -1897,7 +1898,7 @@ describe('dispatchDeepchatRoute', () => { }) await expect( - dispatchDeepchatRoute(runtime, 'sessions.list', {}, { webContentsId: 42, windowId: 7 }) + dispatchDeepchatRoute(runtime, 'sessions.list', {}, createRendererRouteContext(42, 7)) ).rejects.toThrow('maintenance') expect(sessionProjectionPort.listSessions).not.toHaveBeenCalled() @@ -1911,10 +1912,12 @@ describe('dispatchDeepchatRoute', () => { disabledAgentTools: ['read'] } - const result = await dispatchDeepchatRoute(runtime, 'tools.listDefinitions', input, { - webContentsId: 42, - windowId: 7 - }) + const result = await dispatchDeepchatRoute( + runtime, + 'tools.listDefinitions', + input, + createRendererRouteContext(42, 7) + ) expect(result).toMatchObject({ tools: [{ source: 'agent', function: { name: 'read' } }] @@ -1925,10 +1928,7 @@ describe('dispatchDeepchatRoute', () => { it('dispatches Cron Jobs routes through the runtime service', async () => { const { runtime, cronJobs } = createRuntime() - const context = { - webContentsId: 42, - windowId: 7 - } + const context = createRendererRouteContext(42, 7) const listResult = await dispatchDeepchatRoute(runtime, 'cronJobs.list', {}, context) const upsertResult = await dispatchDeepchatRoute( @@ -2109,10 +2109,7 @@ describe('dispatchDeepchatRoute', () => { it('reconciles Cron Jobs after agent mutation routes', async () => { const { runtime, cronJobs } = createRuntime() - const context = { - webContentsId: 42, - windowId: 7 - } + const context = createRendererRouteContext(42, 7) await dispatchDeepchatRoute( runtime, @@ -2140,10 +2137,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'startup.getBootstrap', {}, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) expect(projectPresenter.ensureDefaultWorkspace).toHaveBeenCalledTimes(1) @@ -2160,10 +2154,7 @@ describe('dispatchDeepchatRoute', () => { { keys: ['fontSizeLevel', 'fontFamily'] }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) expect(result).toEqual({ @@ -2182,10 +2173,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'settings.listSystemFonts', {}, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) expect(fontSettings.getSystemFonts).toHaveBeenCalledTimes(1) @@ -2228,7 +2216,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'memory.listAuditEvents', { agentId: 'deepchat' }, - { webContentsId: 42, windowId: 7 } + createRendererRouteContext(42, 7) ) expect(listByAgent).toHaveBeenCalledWith( @@ -2273,7 +2261,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'memory.listAuditEvents', { agentId: 'deleted' }, - { webContentsId: 42, windowId: 7 } + createRendererRouteContext(42, 7) ) ).resolves.toEqual({ events: [] }) await expect( @@ -2281,7 +2269,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'memory.listAuditEvents', { agentId: 'acp-agent' }, - { webContentsId: 42, windowId: 7 } + createRendererRouteContext(42, 7) ) ).resolves.toEqual({ events: [] }) expect(listByAgent).not.toHaveBeenCalled() @@ -2324,7 +2312,7 @@ describe('dispatchDeepchatRoute', () => { deleteDirectiveResult } - const context = { webContentsId: 42, windowId: 7 } + const context = createRendererRouteContext(42, 7) const listed = await dispatchDeepchatRoute( runtime, 'memory.listDirectives', @@ -2405,7 +2393,7 @@ describe('dispatchDeepchatRoute', () => { rejectDirectiveResult, deleteDirectiveResult } - const context = { webContentsId: 42, windowId: 7 } + const context = createRendererRouteContext(42, 7) await expect( dispatchDeepchatRoute( @@ -2466,7 +2454,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'memory.getHealth', { agentId: 'other' }, - { webContentsId: 42, windowId: 7 } + createRendererRouteContext(42, 7) ) ).resolves.toEqual({ health: createEmptyMemoryHealth() }) expect(getHealth).not.toHaveBeenCalled() @@ -2477,7 +2465,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'memory.getHealth', { agentId: 'deepchat' }, - { webContentsId: 42, windowId: 7 } + createRendererRouteContext(42, 7) ) ).resolves.toEqual({ health }) expect(getHealth).toHaveBeenCalledWith('deepchat') @@ -2553,7 +2541,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'memory.getLifecycle', { agentId: 'other', memoryId: 'm1' }, - { webContentsId: 42, windowId: 7 } + createRendererRouteContext(42, 7) ) ).resolves.toEqual({ lifecycle: null }) expect(getLifecycle).not.toHaveBeenCalled() @@ -2564,7 +2552,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'memory.getLifecycle', { agentId: 'deepchat', memoryId: 'm1' }, - { webContentsId: 42, windowId: 7 } + createRendererRouteContext(42, 7) ) ).resolves.toEqual({ lifecycle }) expect(getLifecycle).toHaveBeenCalledWith('deepchat', 'm1') @@ -2574,7 +2562,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'memory.getArchiveCandidateLifecyclePreview', { agentId: 'other' }, - { webContentsId: 42, windowId: 7 } + createRendererRouteContext(42, 7) ) ).resolves.toEqual({ preview: createEmptyArchiveCandidateLifecyclePreview() }) expect(getArchiveCandidateLifecyclePreview).not.toHaveBeenCalled() @@ -2585,7 +2573,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'memory.getArchiveCandidateLifecyclePreview', { agentId: 'deepchat' }, - { webContentsId: 42, windowId: 7 } + createRendererRouteContext(42, 7) ) ).resolves.toEqual({ preview }) expect(getArchiveCandidateLifecyclePreview).toHaveBeenCalledWith('deepchat') @@ -2599,7 +2587,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'memory.listAuditEvents', { agentId: 'deepchat' }, - { webContentsId: 42, windowId: 7 } + createRendererRouteContext(42, 7) ) ).resolves.toEqual({ events: [] }) }) @@ -2635,7 +2623,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'memory.listViewManifests', { agentId: 'a', sessionId: 's1', messageId: 'msg-old', limit: 1 }, - { webContentsId: 42, windowId: 7 } + createRendererRouteContext(42, 7) ) expect(listSessions).not.toHaveBeenCalled() @@ -2680,7 +2668,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'memory.listViewManifests', { agentId: 'deepchat', sessionId: 's1', messageId: 'msg-1', limit: 1 }, - { webContentsId: 42, windowId: 7 } + createRendererRouteContext(42, 7) ) expect(result).toEqual({ @@ -2723,7 +2711,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'memory.getSourceSpan', { agentId: 'deepchat', memoryId: 'memory-1' }, - { webContentsId: 42, windowId: 7 } + createRendererRouteContext(42, 7) ) expect(getEffectiveMessageSourceSpan).toHaveBeenCalledWith('s1', [2, 3]) @@ -2805,7 +2793,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'memory.getByIds', { agentId: 'other', memoryIds: ['m1'] }, - { webContentsId: 42, windowId: 7 } + createRendererRouteContext(42, 7) ) expect(guarded).toEqual({ memories: [] }) expect(getByIds).not.toHaveBeenCalled() @@ -2814,7 +2802,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'memory.getByIds', { agentId: 'deepchat', memoryIds: ['m2', 'm1'] }, - { webContentsId: 42, windowId: 7 } + createRendererRouteContext(42, 7) ) expect(getByIds).toHaveBeenCalledWith('deepchat', ['m2', 'm1']) @@ -2846,7 +2834,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'memory.archive', { agentId: 'other', memoryId: 'm1' }, - { webContentsId: 42, windowId: 7 } + createRendererRouteContext(42, 7) ) ).resolves.toEqual({ action: 'rejected', reason: 'unavailable' }) expect(archiveUserMemory).not.toHaveBeenCalled() @@ -2856,7 +2844,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'memory.archive', { agentId: 'deepchat', memoryId: 'm1' }, - { webContentsId: 42, windowId: 7 } + createRendererRouteContext(42, 7) ) ).resolves.toEqual({ action: 'applied' }) expect(archiveUserMemory).toHaveBeenCalledWith('deepchat', 'm1') @@ -2874,7 +2862,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'memory.reindex', { agentId: 'other' }, - { webContentsId: 42, windowId: 7 } + createRendererRouteContext(42, 7) ) ).resolves.toEqual({ started: false }) expect(canReindex).not.toHaveBeenCalled() @@ -2886,7 +2874,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'memory.reindex', { agentId: 'deepchat' }, - { webContentsId: 42, windowId: 7 } + createRendererRouteContext(42, 7) ) ).resolves.toEqual({ started: false }) expect(canReindex).toHaveBeenCalledWith('deepchat') @@ -2899,7 +2887,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'memory.reindex', { agentId: 'deepchat' }, - { webContentsId: 42, windowId: 7 } + createRendererRouteContext(42, 7) ) ).resolves.toEqual({ started: false }) expect(reindexEmbeddings).toHaveBeenCalledTimes(1) @@ -2911,7 +2899,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'memory.reindex', { agentId: 'deepchat' }, - { webContentsId: 42, windowId: 7 } + createRendererRouteContext(42, 7) ) ).resolves.toEqual({ started: true }) expect(reindexEmbeddings).toHaveBeenCalledTimes(2) @@ -2922,7 +2910,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'memory.reindex', { agentId: 'deepchat' }, - { webContentsId: 42, windowId: 7 } + createRendererRouteContext(42, 7) ) ).resolves.toEqual({ started: false }) expect(reindexEmbeddings).toHaveBeenCalledTimes(2) @@ -2940,7 +2928,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'memory.listViewManifests', { agentId: 'deleted' }, - { webContentsId: 42, windowId: 7 } + createRendererRouteContext(42, 7) ) ).resolves.toEqual({ manifests: [] }) await expect( @@ -2948,7 +2936,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'memory.listViewManifests', { agentId: 'acp-agent' }, - { webContentsId: 42, windowId: 7 } + createRendererRouteContext(42, 7) ) ).resolves.toEqual({ manifests: [] }) expect(listMemoryViewManifestsByAgent).not.toHaveBeenCalled() @@ -2998,7 +2986,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'memory.page', { agentId: 'deepchat', limit: 25 }, - { webContentsId: 42, windowId: 7 } + createRendererRouteContext(42, 7) ) expect(pageMemories).toHaveBeenCalledWith('deepchat', null, 25) @@ -3022,7 +3010,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'memory.page', { agentId: 'external-agent', limit: 25 }, - { webContentsId: 42, windowId: 7 } + createRendererRouteContext(42, 7) ) ).resolves.toEqual({ items: [], nextCursor: null }) expect(pageMemories).not.toHaveBeenCalled() @@ -3061,7 +3049,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'memory.listViewManifests', { agentId: 'a', limit: 100 }, - { webContentsId: 42, windowId: 7 } + createRendererRouteContext(42, 7) ) expect(listSessions).not.toHaveBeenCalled() @@ -3077,10 +3065,7 @@ describe('dispatchDeepchatRoute', () => { it('dispatches ACP terminal command routes through the terminal helper', async () => { const { runtime } = createRuntime() - const context = { - webContentsId: 42, - windowId: 7 - } + const context = createRendererRouteContext(42, 7) const inputResult = await dispatchDeepchatRoute( runtime, @@ -3098,10 +3083,7 @@ describe('dispatchDeepchatRoute', () => { it('dispatches shortcut routes through ShortcutPresenter', async () => { const { runtime, shortcutPresenter } = createRuntime() - const context = { - webContentsId: 42, - windowId: 7 - } + const context = createRendererRouteContext(42, 7) const registerResult = await dispatchDeepchatRoute(runtime, 'shortcut.register', {}, context) const unregisterResult = await dispatchDeepchatRoute( @@ -3145,10 +3127,7 @@ describe('dispatchDeepchatRoute', () => { { key: 'ocrBackend', value: 'cpu' } ] }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) expect(desktopSettings.setFontSizeLevel).toHaveBeenCalledWith(4) @@ -3217,10 +3196,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'config.getKnowledgeConfigs', {}, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const setResult = await dispatchDeepchatRoute( runtime, @@ -3228,10 +3204,7 @@ describe('dispatchDeepchatRoute', () => { { configs: nextConfigs }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) expect(getResult).toEqual({ @@ -3248,10 +3221,7 @@ describe('dispatchDeepchatRoute', () => { it('dispatches knowledge file routes through KnowledgeService', async () => { const { runtime, knowledgeService } = createRuntime() - const context = { - webContentsId: 42, - windowId: 7 - } + const context = createRendererRouteContext(42, 7) const supportedResult = await dispatchDeepchatRoute( runtime, @@ -3372,10 +3342,7 @@ describe('dispatchDeepchatRoute', () => { it('dispatches skill sync routes through SkillSyncService', async () => { const { runtime, skillSyncService } = createRuntime() - const context = { - webContentsId: 42, - windowId: 7 - } + const context = createRendererRouteContext(42, 7) const scanResult = await dispatchDeepchatRoute( runtime, 'skillSync.scanExternalTools', @@ -3418,10 +3385,7 @@ describe('dispatchDeepchatRoute', () => { it('dispatches GitHub Copilot OAuth routes through OAuthService', async () => { const { runtime, oauthService } = createRuntime() - const context = { - webContentsId: 42, - windowId: 7 - } + const context = createRendererRouteContext(42, 7) const loginResult = await dispatchDeepchatRoute( runtime, @@ -3444,10 +3408,7 @@ describe('dispatchDeepchatRoute', () => { it('dispatches OpenAI Codex OAuth routes through OAuthService', async () => { const { runtime, oauthService } = createRuntime() - const context = { - webContentsId: 42, - windowId: 7 - } + const context = createRendererRouteContext(42, 7) const statusResult = await dispatchDeepchatRoute( runtime, @@ -3486,10 +3447,7 @@ describe('dispatchDeepchatRoute', () => { it('dispatches database schema repair through MainDatabase', async () => { const { runtime, sqlitePresenter } = createRuntime() - const context = { - webContentsId: 42, - windowId: 7 - } + const context = createRendererRouteContext(42, 7) const repairResult = await dispatchDeepchatRoute( runtime, @@ -3510,10 +3468,7 @@ describe('dispatchDeepchatRoute', () => { it('dispatches NowledgeMem routes through ConversationExporter', async () => { const { runtime, exporter } = createRuntime() - const context = { - webContentsId: 42, - windowId: 7 - } + const context = createRendererRouteContext(42, 7) const getResult = await dispatchDeepchatRoute(runtime, 'nowledgeMem.getConfig', {}, context) const updateResult = await dispatchDeepchatRoute( @@ -3576,10 +3531,7 @@ describe('dispatchDeepchatRoute', () => { it('dispatches scoped skill script requests through SkillService', async () => { const { runtime, skillService } = createRuntime() - const context = { - webContentsId: 42, - windowId: 7 - } + const context = createRendererRouteContext(42, 7) ;(skillService as any).listSkillScriptsForAgent = vi.fn().mockResolvedValue([]) const result = await dispatchDeepchatRoute( @@ -3598,10 +3550,7 @@ describe('dispatchDeepchatRoute', () => { it('dispatches Agent Skill import source discovery', async () => { const { runtime, skillSyncService, providerSettings } = createRuntime() - const context = { - webContentsId: 42, - windowId: 7 - } + const context = createRendererRouteContext(42, 7) const result = await dispatchDeepchatRoute( runtime, @@ -3626,10 +3575,7 @@ describe('dispatchDeepchatRoute', () => { it('dispatches skill file reads through SkillService', async () => { const { runtime, skillService } = createRuntime() - const context = { - webContentsId: 42, - windowId: 7 - } + const context = createRendererRouteContext(42, 7) const result = await dispatchDeepchatRoute( runtime, @@ -3646,10 +3592,7 @@ describe('dispatchDeepchatRoute', () => { it('dispatches MCP Router marketplace routes through McpService', async () => { const { runtime, mcpService } = createRuntime() - const context = { - webContentsId: 42, - windowId: 7 - } + const context = createRendererRouteContext(42, 7) const listResult = await dispatchDeepchatRoute( runtime, @@ -3722,7 +3665,7 @@ describe('dispatchDeepchatRoute', () => { it('returns typed MCP add results and records only persisted additions', async () => { const { runtime, mcpService, sqlitePresenter } = createRuntime() - const context = { webContentsId: 42, windowId: 7 } + const context = createRendererRouteContext(42, 7) const config = { type: 'stdio', command: 'node', @@ -3754,7 +3697,7 @@ describe('dispatchDeepchatRoute', () => { it('dispatches NPM registry routes through McpService', async () => { const { runtime, mcpService } = createRuntime() - const context = { webContentsId: 42, windowId: 7 } + const context = createRendererRouteContext(42, 7) await dispatchDeepchatRoute(runtime, 'mcp.getNpmRegistryStatus', {}, context) await dispatchDeepchatRoute(runtime, 'mcp.refreshNpmRegistry', {}, context) @@ -3781,10 +3724,7 @@ describe('dispatchDeepchatRoute', () => { it('dispatches remote control routes through RemoteService', async () => { const { runtime, remoteService } = createRuntime() - const context = { - webContentsId: 42, - windowId: 7 - } + const context = createRendererRouteContext(42, 7) await dispatchDeepchatRoute(runtime, 'remoteControl.listChannels', {}, context) await dispatchDeepchatRoute( @@ -3932,10 +3872,7 @@ describe('dispatchDeepchatRoute', () => { it('dispatches DeepChat agent config routes through AgentSettings', async () => { const { runtime, providerSettings } = createRuntime() - const context = { - webContentsId: 42, - windowId: 7 - } + const context = createRendererRouteContext(42, 7) const listResult = await dispatchDeepchatRoute( runtime, @@ -4027,10 +3964,7 @@ describe('dispatchDeepchatRoute', () => { skillSettings, testHookCommand } = createRuntime() - const context = { - webContentsId: 42, - windowId: 7 - } + const context = createRendererRouteContext(42, 7) const initialProxy = await dispatchDeepchatRoute( runtime, @@ -4212,10 +4146,7 @@ describe('dispatchDeepchatRoute', () => { it('dispatches ACP config routes through AgentSettings', async () => { const { runtime, providerSettings } = createRuntime() - const context = { - webContentsId: 42, - windowId: 7 - } + const context = createRendererRouteContext(42, 7) const setEnabledResult = await dispatchDeepchatRoute( runtime, @@ -4336,10 +4267,7 @@ describe('dispatchDeepchatRoute', () => { agentId: 'deepchat', message: 'hello world' }, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) expect(sessionLifecyclePort.createSession).toHaveBeenCalledWith( @@ -4362,10 +4290,7 @@ describe('dispatchDeepchatRoute', () => { sessionId: 'session-1', content: 'follow up' }, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) expect(sessionTurnPort.sendMessage).toHaveBeenCalledWith('session-1', 'follow up', { @@ -4379,10 +4304,7 @@ describe('dispatchDeepchatRoute', () => { sessionId: 'session-1', content: 'refine the active answer' }, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) expect(sessionTurnPort.steerActiveTurn).toHaveBeenCalledWith( @@ -4397,10 +4319,7 @@ describe('dispatchDeepchatRoute', () => { { sessionId: 'session-1' }, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) expect(sessionTurnPort.compactSession).toHaveBeenCalledWith('session-1') @@ -4421,10 +4340,7 @@ describe('dispatchDeepchatRoute', () => { messageId: 'message-1', attachmentFallbackPolicy: 'send_without_image_content' }, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) expect(sessionTurnPort.retryMessage).toHaveBeenCalledWith('session-1', 'message-1', { @@ -4446,7 +4362,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'sessions.retryMessage', { sessionId: 'session-1', messageId: 'message-1' }, - { webContentsId: 88, windowId: 3 } + createRendererRouteContext(88, 3) ) expect(sessionTurnPort.retryMessage).toHaveBeenLastCalledWith('session-1', 'message-1') @@ -4491,7 +4407,7 @@ describe('dispatchDeepchatRoute', () => { }, submissionId: 'submission-1' }, - { webContentsId: 88, windowId: 3 } + createRendererRouteContext(88, 3) ) await started @@ -4500,7 +4416,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'chat.cancelSubmission', { submissionId: 'submission-1' }, - { webContentsId: 99, windowId: 4 } + createRendererRouteContext(99, 4) ) ).resolves.toEqual({ cancelled: false }) expect(acceptanceSignal?.aborted).toBe(false) @@ -4510,7 +4426,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'chat.cancelSubmission', { submissionId: 'submission-1' }, - { webContentsId: 88, windowId: 3 } + createRendererRouteContext(88, 3) ) ).resolves.toEqual({ cancelled: true }) await expect(pendingSend).rejects.toMatchObject({ name: 'AbortError' }) @@ -4521,7 +4437,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'chat.cancelSubmission', { submissionId: 'submission-1' }, - { webContentsId: 88, windowId: 3 } + createRendererRouteContext(88, 3) ) ).resolves.toEqual({ cancelled: false }) }) @@ -4562,7 +4478,7 @@ describe('dispatchDeepchatRoute', () => { }, submissionId: 'steer-submission-1' }, - { webContentsId: 88, windowId: 3 } + createRendererRouteContext(88, 3) ) await started @@ -4571,7 +4487,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'chat.cancelSubmission', { submissionId: 'steer-submission-1' }, - { webContentsId: 99, windowId: 4 } + createRendererRouteContext(99, 4) ) ).resolves.toEqual({ cancelled: false }) expect(acceptanceSignal?.aborted).toBe(false) @@ -4581,7 +4497,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'chat.cancelSubmission', { submissionId: 'steer-submission-1' }, - { webContentsId: 88, windowId: 3 } + createRendererRouteContext(88, 3) ) ).resolves.toEqual({ cancelled: true }) await expect(pendingSteer).rejects.toMatchObject({ name: 'AbortError' }) @@ -4592,7 +4508,7 @@ describe('dispatchDeepchatRoute', () => { runtime, 'chat.cancelSubmission', { submissionId: 'steer-submission-1' }, - { webContentsId: 88, windowId: 3 } + createRendererRouteContext(88, 3) ) ).resolves.toEqual({ cancelled: false }) }) @@ -4609,10 +4525,7 @@ describe('dispatchDeepchatRoute', () => { timeout: 5000 } }, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) const getResult = await dispatchDeepchatRoute( @@ -4621,10 +4534,7 @@ describe('dispatchDeepchatRoute', () => { { sessionId: 'session-1' }, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) expect(sessionAssignmentPort.updateSessionGenerationSettings).toHaveBeenCalledWith( @@ -4656,10 +4566,7 @@ describe('dispatchDeepchatRoute', () => { it('dispatches dashboard maintenance routes through explicit owners', async () => { const { runtime, providerSettings, usageStatsService, rtkRuntimeService } = createRuntime() - const context = { - webContentsId: 88, - windowId: 3 - } + const context = createRendererRouteContext(88, 3) const agentsResult = await dispatchDeepchatRoute(runtime, 'sessions.getAgents', {}, context) const dashboardResult = await dispatchDeepchatRoute( @@ -4698,7 +4605,7 @@ describe('dispatchDeepchatRoute', () => { agentSessionExportService, providerSettings } = createRuntime() - const context = { webContentsId: 88, windowId: 3 } + const context = createRendererRouteContext(88, 3) await dispatchDeepchatRoute( runtime, @@ -4779,10 +4686,7 @@ describe('dispatchDeepchatRoute', () => { { providerId: 'openai' }, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) const checkResult = await dispatchDeepchatRoute( @@ -4792,10 +4696,7 @@ describe('dispatchDeepchatRoute', () => { providerId: 'openai', modelId: 'gpt-5.4' }, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) const keyStatusResult = await dispatchDeepchatRoute( @@ -4804,10 +4705,7 @@ describe('dispatchDeepchatRoute', () => { { providerId: 'openai' }, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) const rateLimitStatusResult = await dispatchDeepchatRoute( @@ -4816,10 +4714,7 @@ describe('dispatchDeepchatRoute', () => { { providerId: 'openai' }, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) const updateRateLimitResult = await dispatchDeepchatRoute( @@ -4830,10 +4725,7 @@ describe('dispatchDeepchatRoute', () => { enabled: true, qpsLimit: 2 }, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) const embeddingDimensionsResult = await dispatchDeepchatRoute( @@ -4843,10 +4735,7 @@ describe('dispatchDeepchatRoute', () => { providerId: 'openai', modelId: 'text-embedding-3-small' }, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) const modelScopeSyncResult = await dispatchDeepchatRoute( @@ -4859,10 +4748,7 @@ describe('dispatchDeepchatRoute', () => { page_size: 50 } }, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) const acpDebugResult = await dispatchDeepchatRoute( @@ -4874,23 +4760,20 @@ describe('dispatchDeepchatRoute', () => { action: 'initialize', payload: {} }, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) const acpWarmupResult = await dispatchDeepchatRoute( runtime, 'providers.warmupAcpProcess', { agentId: 'codex-acp', workdir: '/repo' }, - { webContentsId: 88, windowId: 3 } + createRendererRouteContext(88, 3) ) const acpConfigResult = await dispatchDeepchatRoute( runtime, 'providers.getAcpProcessConfigOptions', { agentId: 'codex-acp', workdir: '/repo' }, - { webContentsId: 88, windowId: 3 } + createRendererRouteContext(88, 3) ) const interactionResult = await dispatchDeepchatRoute( @@ -4905,10 +4788,7 @@ describe('dispatchDeepchatRoute', () => { granted: true } }, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) expect(providerSettings.getProviderModels).toHaveBeenCalledWith('openai') @@ -5051,30 +4931,21 @@ describe('dispatchDeepchatRoute', () => { { sessionId: 'session-1' }, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) const deactivateResult = await dispatchDeepchatRoute( runtime, 'sessions.deactivate', {}, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) const activeResult = await dispatchDeepchatRoute( runtime, 'sessions.getActive', {}, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) expect(desktopSessionBinding.activate).toHaveBeenCalledWith(88, 'session-1') @@ -5099,10 +4970,7 @@ describe('dispatchDeepchatRoute', () => { { requestId: 'message-1' }, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) expect(sessionProjectionPort.getMessage).toHaveBeenCalledWith('message-1') @@ -5118,30 +4986,21 @@ describe('dispatchDeepchatRoute', () => { runtime, 'window.getCurrentState', {}, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const minimizedState = await dispatchDeepchatRoute( runtime, 'window.minimizeCurrent', {}, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const maximizedState = await dispatchDeepchatRoute( runtime, 'window.toggleMaximizeCurrent', {}, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const previewResult = await dispatchDeepchatRoute( @@ -5150,70 +5009,49 @@ describe('dispatchDeepchatRoute', () => { { filePath: 'C:/workspace/README.md' }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const closeFloatingResult = await dispatchDeepchatRoute( runtime, 'window.closeFloatingCurrent', {}, - { - webContentsId: 444, - windowId: 7 - } + createRendererRouteContext(444, 7) ) const closeResult = await dispatchDeepchatRoute( runtime, 'window.closeCurrent', {}, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const closeSettingsResult = await dispatchDeepchatRoute( runtime, 'window.closeSettings', {}, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const focusMainResult = await dispatchDeepchatRoute( runtime, 'window.focusMain', {}, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const notifySettingsReadyResult = await dispatchDeepchatRoute( runtime, 'window.notifySettingsReady', {}, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const pendingProviderInstallResult = await dispatchDeepchatRoute( runtime, 'window.consumePendingSettingsProviderInstall', {}, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const requeueProviderInstallResult = await dispatchDeepchatRoute( @@ -5222,20 +5060,14 @@ describe('dispatchDeepchatRoute', () => { { preview: pendingProviderInstallResult.preview }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const startGuidedOnboardingResult = await dispatchDeepchatRoute( runtime, 'window.startGuidedOnboarding', {}, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) expect(initialState).toEqual({ @@ -5317,28 +5149,19 @@ describe('dispatchDeepchatRoute', () => { runtime, 'device.getAppVersion', {}, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const deviceInfo = await dispatchDeepchatRoute( runtime, 'device.getInfo', {}, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const directorySelection = await dispatchDeepchatRoute( runtime, 'device.selectDirectory', {}, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const fileSelection = await dispatchDeepchatRoute( runtime, @@ -5346,19 +5169,13 @@ describe('dispatchDeepchatRoute', () => { { filters: [{ name: 'ZIP Files', extensions: ['zip'] }] }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const restartResult = await dispatchDeepchatRoute( runtime, 'device.restartApp', {}, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const resetDataResult = await dispatchDeepchatRoute( runtime, @@ -5366,10 +5183,7 @@ describe('dispatchDeepchatRoute', () => { { resetType: 'chat' }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const sanitizeResult = await dispatchDeepchatRoute( runtime, @@ -5377,10 +5191,7 @@ describe('dispatchDeepchatRoute', () => { { svgContent: '' }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const recentProjects = await dispatchDeepchatRoute( @@ -5389,19 +5200,13 @@ describe('dispatchDeepchatRoute', () => { { limit: 5 }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const environments = await dispatchDeepchatRoute( runtime, 'project.listEnvironments', {}, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const reorderEnvironmentsResult = await dispatchDeepchatRoute( runtime, @@ -5409,10 +5214,7 @@ describe('dispatchDeepchatRoute', () => { { paths: ['C:/workspace', 'C:/other'] }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const archiveEnvironmentResult = await dispatchDeepchatRoute( runtime, @@ -5420,10 +5222,7 @@ describe('dispatchDeepchatRoute', () => { { path: 'C:/workspace' }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const restoreEnvironmentResult = await dispatchDeepchatRoute( runtime, @@ -5431,10 +5230,7 @@ describe('dispatchDeepchatRoute', () => { { path: 'C:/workspace' }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const removeEnvironmentResult = await dispatchDeepchatRoute( runtime, @@ -5442,10 +5238,7 @@ describe('dispatchDeepchatRoute', () => { { path: 'C:/workspace' }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const openDirectoryResult = await dispatchDeepchatRoute( runtime, @@ -5453,10 +5246,7 @@ describe('dispatchDeepchatRoute', () => { { path: 'C:/workspace' }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const pathExistsResult = await dispatchDeepchatRoute( runtime, @@ -5464,19 +5254,13 @@ describe('dispatchDeepchatRoute', () => { { path: 'C:/workspace' }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const selectedDirectory = await dispatchDeepchatRoute( runtime, 'project.selectDirectory', {}, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const mimeType = await dispatchDeepchatRoute( @@ -5485,10 +5269,7 @@ describe('dispatchDeepchatRoute', () => { { path: '/workspace/demo.txt' }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const preparedFile = await dispatchDeepchatRoute( runtime, @@ -5497,10 +5278,7 @@ describe('dispatchDeepchatRoute', () => { path: '/workspace/demo.txt', mimeType: 'text/plain' }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const preparedDirectory = await dispatchDeepchatRoute( runtime, @@ -5508,10 +5286,7 @@ describe('dispatchDeepchatRoute', () => { { path: '/workspace' }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const readFile = await dispatchDeepchatRoute( runtime, @@ -5519,10 +5294,7 @@ describe('dispatchDeepchatRoute', () => { { path: '/workspace/demo.txt' }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const isDirectory = await dispatchDeepchatRoute( runtime, @@ -5530,10 +5302,7 @@ describe('dispatchDeepchatRoute', () => { { path: '/workspace' }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const imagePath = await dispatchDeepchatRoute( runtime, @@ -5542,10 +5311,7 @@ describe('dispatchDeepchatRoute', () => { name: 'capture.png', content: 'data:image/png;base64,abc' }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const registerWorkspace = await dispatchDeepchatRoute( @@ -5555,10 +5321,7 @@ describe('dispatchDeepchatRoute', () => { workspacePath: '/workspace', mode: 'workspace' }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const registerWorkdir = await dispatchDeepchatRoute( runtime, @@ -5567,10 +5330,7 @@ describe('dispatchDeepchatRoute', () => { workspacePath: '/workspace', mode: 'workdir' }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const readDirectory = await dispatchDeepchatRoute( runtime, @@ -5578,10 +5338,7 @@ describe('dispatchDeepchatRoute', () => { { path: '/workspace' }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const preview = await dispatchDeepchatRoute( runtime, @@ -5589,10 +5346,7 @@ describe('dispatchDeepchatRoute', () => { { path: '/workspace/src/app.ts' }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const gitStatus = await dispatchDeepchatRoute( runtime, @@ -5600,10 +5354,7 @@ describe('dispatchDeepchatRoute', () => { { workspacePath: '/workspace' }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const gitDiff = await dispatchDeepchatRoute( runtime, @@ -5612,10 +5363,7 @@ describe('dispatchDeepchatRoute', () => { workspacePath: '/workspace', filePath: '/workspace/src/app.ts' }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const resolution = await dispatchDeepchatRoute( runtime, @@ -5625,10 +5373,7 @@ describe('dispatchDeepchatRoute', () => { href: './docs/guide.md', sourceFilePath: '/workspace/README.md' }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const searchResult = await dispatchDeepchatRoute( runtime, @@ -5637,10 +5382,7 @@ describe('dispatchDeepchatRoute', () => { workspacePath: '/workspace', query: 'app' }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const openFileResult = await dispatchDeepchatRoute( runtime, @@ -5648,10 +5390,7 @@ describe('dispatchDeepchatRoute', () => { { path: '/workspace/src/app.ts' }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const revealResult = await dispatchDeepchatRoute( runtime, @@ -5659,10 +5398,7 @@ describe('dispatchDeepchatRoute', () => { { path: '/workspace/src/app.ts' }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const unwatchResult = await dispatchDeepchatRoute( runtime, @@ -5670,10 +5406,7 @@ describe('dispatchDeepchatRoute', () => { { workspacePath: '/workspace' }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) const unregisterResult = await dispatchDeepchatRoute( runtime, @@ -5682,10 +5415,7 @@ describe('dispatchDeepchatRoute', () => { workspacePath: '/workspace', mode: 'workspace' }, - { - webContentsId: 42, - windowId: 7 - } + createRendererRouteContext(42, 7) ) expect(deviceService.getAppVersion).toHaveBeenCalledTimes(1) @@ -5857,10 +5587,7 @@ describe('dispatchDeepchatRoute', () => { { sessionId: 'session-1' }, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) const loadResult = await dispatchDeepchatRoute( runtime, @@ -5870,10 +5597,7 @@ describe('dispatchDeepchatRoute', () => { url: 'https://example.com/docs', timeoutMs: 5000 }, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) const attachResult = await dispatchDeepchatRoute( runtime, @@ -5881,10 +5605,7 @@ describe('dispatchDeepchatRoute', () => { { sessionId: 'session-1' }, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) const updateResult = await dispatchDeepchatRoute( runtime, @@ -5899,10 +5620,7 @@ describe('dispatchDeepchatRoute', () => { }, visible: true }, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) const backResult = await dispatchDeepchatRoute( runtime, @@ -5910,10 +5628,7 @@ describe('dispatchDeepchatRoute', () => { { sessionId: 'session-1' }, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) const detachResult = await dispatchDeepchatRoute( runtime, @@ -5921,10 +5636,7 @@ describe('dispatchDeepchatRoute', () => { { sessionId: 'session-1' }, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) const destroyResult = await dispatchDeepchatRoute( runtime, @@ -5932,19 +5644,13 @@ describe('dispatchDeepchatRoute', () => { { sessionId: 'session-1' }, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) const clearSandboxResult = await dispatchDeepchatRoute( runtime, 'browser.clearSandboxData', {}, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) expect(statusResult).toEqual({ @@ -5998,7 +5704,7 @@ describe('dispatchDeepchatRoute', () => { it('scopes Computer Use preview routes to the active sender session', async () => { const { runtime, computerUsePreviewPresenter, desktopSessionBinding, yoBrowserPresenter } = createRuntime() - const context = { webContentsId: 88, windowId: 3 } + const context = createRendererRouteContext(88, 3) desktopSessionBinding.getActiveId.mockReturnValue('session-1') const eligible = await dispatchDeepchatRoute( @@ -6069,10 +5775,7 @@ describe('dispatchDeepchatRoute', () => { height: 80 } }, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) const stitchResult = await dispatchDeepchatRoute( runtime, @@ -6087,10 +5790,7 @@ describe('dispatchDeepchatRoute', () => { } } }, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) expect(tabPresenter.captureTabArea).toHaveBeenCalledWith(88, { @@ -6127,10 +5827,7 @@ describe('dispatchDeepchatRoute', () => { routeName: 'settings-display', section: 'fonts' }, - { - webContentsId: 88, - windowId: 3 - } + createRendererRouteContext(88, 3) ) expect(windowPresenter.createSettingsWindow).toHaveBeenCalledWith({ diff --git a/test/main/routes/routeRegistry.test.ts b/test/main/routes/routeRegistry.test.ts new file mode 100644 index 000000000..fd3408dae --- /dev/null +++ b/test/main/routes/routeRegistry.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { + createRendererRouteCaller, + createRendererRouteContext, + requireRendererCaller, + type RouteContext +} from '@/routes/routeRegistry' + +describe('route caller context', () => { + it('wraps renderer identity in a discriminated caller', () => { + const caller = createRendererRouteCaller(42, 7) + + expect(caller).toEqual({ + kind: 'renderer', + webContentsId: 42, + windowId: 7 + }) + expect(createRendererRouteContext(42, null)).toEqual({ + caller: { + kind: 'renderer', + webContentsId: 42, + windowId: null + } + }) + }) + + it.each([ + { + caller: { + kind: 'cli', + principal: 'human', + connectionId: 'connection-1', + scopes: ['system:read'] + } + }, + { + caller: { + kind: 'cli', + principal: 'agent', + connectionId: 'connection-2', + scopes: ['models:invoke'], + conversationId: 'session-1', + expiresAt: Date.now() + 60_000 + } + }, + { + caller: { + kind: 'internal', + component: 'scheduler' + } + } + ])('rejects non-renderer identity %# at renderer boundaries', (context) => { + expect(() => requireRendererCaller(context)).toThrow('Route requires a renderer caller') + }) +}) diff --git a/test/main/session/sessionService.test.ts b/test/main/session/sessionService.test.ts index 2d118e817..779f632e2 100644 --- a/test/main/session/sessionService.test.ts +++ b/test/main/session/sessionService.test.ts @@ -1,4 +1,5 @@ import { SessionService } from '@/session/sessionService' +import { createRendererRouteCaller } from '@/routes/routeRegistry' describe('SessionService', () => { const createScheduler = () => ({ @@ -134,7 +135,7 @@ describe('SessionService', () => { getActive: vi.fn().mockResolvedValue(session) } const service = new SessionService({ lifecycle, projection, desktop, scheduler }) - const context = { webContentsId: 42, windowId: 7 } + const context = createRendererRouteCaller(42, 7) const input = { agentId: 'deepchat', message: 'hello' } const filters = { agentId: 'deepchat' } const pageOptions = { limit: 20, cursor: null } From b532ae2ff362470457cb1844fc317e7856f0deef Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 10:51:02 +0800 Subject: [PATCH 03/51] feat(cli): add local transport foundation --- docs/architecture/local-control-plane/spec.md | 4 +- .../architecture/local-control-plane/tasks.md | 10 +- src/main/app/composition.ts | 38 +- src/main/cli/body.ts | 246 +++++++ src/main/cli/descriptor.ts | 239 +++++++ src/main/cli/errors.ts | 25 + src/main/cli/index.ts | 3 + src/main/cli/routes.ts | 103 +++ src/main/cli/server.ts | 601 ++++++++++++++++++ src/main/cli/surface.ts | 85 +++ src/shared/contracts/routes.ts | 11 + src/shared/contracts/routes/cli.routes.ts | 79 +++ test/main/cli/body.test.ts | 126 ++++ test/main/cli/descriptor.test.ts | 127 ++++ test/main/cli/server.test.ts | 294 +++++++++ test/main/cli/surface.test.ts | 40 ++ 16 files changed, 2021 insertions(+), 10 deletions(-) create mode 100644 src/main/cli/body.ts create mode 100644 src/main/cli/descriptor.ts create mode 100644 src/main/cli/errors.ts create mode 100644 src/main/cli/index.ts create mode 100644 src/main/cli/routes.ts create mode 100644 src/main/cli/server.ts create mode 100644 src/main/cli/surface.ts create mode 100644 src/shared/contracts/routes/cli.routes.ts create mode 100644 test/main/cli/body.test.ts create mode 100644 test/main/cli/descriptor.test.ts create mode 100644 test/main/cli/server.test.ts create mode 100644 test/main/cli/surface.test.ts diff --git a/docs/architecture/local-control-plane/spec.md b/docs/architecture/local-control-plane/spec.md index fcfd5bfee..626896a5a 100644 --- a/docs/architecture/local-control-plane/spec.md +++ b/docs/architecture/local-control-plane/spec.md @@ -100,7 +100,7 @@ flowchart LR Human["Human or benchmark"] --> CLI["Bundled deepchat CLI"] Agent["DeepChat Agent shell"] --> Gate["CommandPermissionService"] Gate --> CLI - CLI -->|"HTTP over UDS or named pipe"| Server["LocalControlServer in main"] + CLI -->|"HTTP over UDS or named pipe"| Server["CliServer in main"] Server --> Auth["Connection authentication and caller scopes"] Auth --> Surface["Versioned CLI_SURFACE"] Surface --> Policy["Effect policy and quotas"] @@ -119,7 +119,7 @@ flowchart LR Events --> CLI ``` -`LocalControlServer`, `ArtifactSpool`, and the CLI process are lifecycle clients of the existing main +`CliServer`, `ArtifactSpool`, and the CLI process are lifecycle clients of the existing main composition. The server starts only after its route dependencies are ready. Shutdown first stops accepting connections, aborts in-flight non-detached requests, closes event subscribers, cancels CLI approval scopes, and removes the descriptor/socket; mutable services and databases close afterward. diff --git a/docs/architecture/local-control-plane/tasks.md b/docs/architecture/local-control-plane/tasks.md index 85d0926df..014af6137 100644 --- a/docs/architecture/local-control-plane/tasks.md +++ b/docs/architecture/local-control-plane/tasks.md @@ -15,14 +15,14 @@ - [x] Add `RouteCaller` and migrate renderer-dependent integrations without behavior change. - [ ] Add canonical local-control contracts and redacted public DTOs. -- [ ] Define and test the deny-by-default versioned surface registry. -- [ ] Add local-control error codes, request/result envelopes, and route limits. +- [x] Define and test the deny-by-default versioned surface registry. +- [x] Add local-control error codes, request/result envelopes, and route limits. ## Local Transport and CLI -- [ ] Implement atomic private descriptor creation, token rotation, and stale cleanup. -- [ ] Implement UDS/named-pipe HTTP server lifecycle and authentication. -- [ ] Implement fixed/chunked body bounds, spill-to-disk, abort handling, and cleanup. +- [x] Implement atomic private descriptor creation, token rotation, and stale cleanup. +- [x] Implement UDS/named-pipe HTTP server lifecycle and authentication. +- [x] Implement fixed/chunked body bounds, spill-to-disk, abort handling, and cleanup. - [ ] Implement the bundled thin CLI, two-token grammar, version negotiation, output modes, signals, fail-closed Agent-token selection, timeouts, and exit codes. - [ ] Add descriptor, transport, auth, body-boundary, parser, and shutdown tests. diff --git a/src/main/app/composition.ts b/src/main/app/composition.ts index adf614da3..5afe283ba 100644 --- a/src/main/app/composition.ts +++ b/src/main/app/composition.ts @@ -200,8 +200,14 @@ import { createAgentRoutes } from '@/agent/routes' import { createPromptRoutes } from '@/agent/promptRoutes' import { AgentSessionExportService } from '../exporter/agentSessionExporter' import { createInMemoryServerFactory } from '../mcp/inMemoryServers/builder' -import { createRouteDispatcher, registerDeepchatRoutes } from '@/routes' +import { + createRouteDispatcher, + dispatchDeepchatRoute, + registerDeepchatRoutes, + type RouteDispatcher +} from '@/routes' import { createNodeScheduler } from '@/routes/scheduler' +import { CliServer, createCliRoutes } from '@/cli' import { AcpRegistryMigrationService } from '@/agent/acp/catalog/acpRegistryMigrationService' import { killTerminal } from '@/agent/acp/launch/acpInitHelper' import { rtkRuntimeService } from '@/agent/shared/process/rtkRuntimeService' @@ -330,6 +336,7 @@ export async function createMainProcessControl(dependencies: { let liveDelegationService: LiveDelegationService let acpAsLlmProviderSessionControl: AcpAsLlmProviderSessionControlPort let acpAsLlmProviderPermission: AcpAsLlmProviderPermissionPort + let routeDispatcher: RouteDispatcher | undefined let hasInitialized = false let databaseMaintenanceState: 'running' | 'maintenance' | 'failed' = 'running' let appLifecycleState: 'starting' | 'running' | 'stopping' | 'stopped' = 'starting' @@ -350,6 +357,18 @@ export async function createMainProcessControl(dependencies: { dependencies.onWindowCreated, startupWorkloadCoordinator ) + const cliServer = new CliServer({ + userDataPath: app.getPath('userData'), + appVersion: app.getVersion(), + dispatch: async (method, input, caller, signal) => { + if (!routeDispatcher) throw new Error('CLI route dispatcher is not ready') + signal.throwIfAborted() + const output = await dispatchDeepchatRoute(routeDispatcher, method, input, { caller }) + signal.throwIfAborted() + return output + }, + log: logger + }) const semanticNotificationScheduler = new TimeoutNotificationScheduler() const semanticNotificationEpisodes = new EpisodeRegistry( systemNotificationClock, @@ -1853,6 +1872,7 @@ export async function createMainProcessControl(dependencies: { } async function destroy(): Promise { + await runDestroyStep('cliServer.stop', () => cliServer.stop()) await runDestroyStep('providerCatalog.unsubscribe', () => unsubscribeProviderDbCatalog()) await runDestroyStep('liveDelegationService.stop', () => liveDelegationService.stop()) await runDestroyStep('cronJobs.destroy', () => cronJobs.destroy()) @@ -2210,7 +2230,13 @@ export async function createMainProcessControl(dependencies: { }, splash: dependencies.splash }) - const routeDispatcher = createRouteDispatcher({ + const cliRoutes = createCliRoutes({ + appVersion: app.getVersion(), + getStatus: () => cliServer.getStatus(), + hasTrustedRenderer: () => + windowPresenter.getAllWindows().some((window) => !window.isDestroyed()) + }) + routeDispatcher = createRouteDispatcher({ appDatabaseMaintenance: { assertRouteAllowed: (routeName) => assertRouteAllowedDuringDatabaseMaintenance(routeName) }, @@ -2243,7 +2269,8 @@ export async function createMainProcessControl(dependencies: { hookRoutes, notificationRoutes, appSettingsRoutes, - appRoutes + appRoutes, + cliRoutes ], settingsWindow: windowPresenter, startupWorkloadCoordinator @@ -2632,6 +2659,11 @@ export async function createMainProcessControl(dependencies: { cronJobs.start() memoryService.startBackgroundMaintenance() appLifecycleState = 'running' + try { + await cliServer.start() + } catch (error) { + logger.error('[CLI] Failed to start local control server', error) + } init(dependencies.startupRunId) scheduleBackgroundWork() return control diff --git a/src/main/cli/body.ts b/src/main/cli/body.ts new file mode 100644 index 000000000..e0b2d4390 --- /dev/null +++ b/src/main/cli/body.ts @@ -0,0 +1,246 @@ +import { randomUUID } from 'node:crypto' +import { mkdir, open, readFile, unlink, type FileHandle } from 'node:fs/promises' +import path from 'node:path' +import type { IncomingMessage } from 'node:http' +import { CliRequestError } from './errors' + +const DEFAULT_MAX_JSON_DEPTH = 64 +const DEFAULT_MAX_JSON_KEYS = 10_000 +const DEFAULT_MAX_JSON_NODES = 50_000 +const UNSAFE_JSON_KEYS = new Set(['__proto__', 'constructor', 'prototype']) + +export type BoundedRequestBody = + | Readonly<{ + kind: 'memory' + bytes: Buffer + size: number + cleanup(): Promise + }> + | Readonly<{ + kind: 'file' + path: string + size: number + cleanup(): Promise + }> + +export type BoundedBodyOptions = Readonly<{ + maxBytes: number + memoryThresholdBytes: number + tempDirectory: string + requireContentLength: boolean +}> + +function parseDeclaredLength(request: IncomingMessage): number | null { + const distinctValues = request.headersDistinct['content-length'] + if (distinctValues && distinctValues.length !== 1) { + throw new CliRequestError('invalid_request', 'Content-Length must be singular') + } + + const rawValue = distinctValues?.[0] ?? request.headers['content-length'] + if (rawValue === undefined) return null + if (Array.isArray(rawValue) || !/^(0|[1-9][0-9]*)$/.test(rawValue)) { + throw new CliRequestError('invalid_request', 'Content-Length is invalid') + } + + const parsed = Number(rawValue) + if (!Number.isSafeInteger(parsed)) { + throw new CliRequestError('invalid_request', 'Content-Length is too large') + } + return parsed +} + +async function removeFile(filePath: string): Promise { + try { + await unlink(filePath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } +} + +async function writeAll(handle: FileHandle, bytes: Buffer, position: number): Promise { + let offset = 0 + while (offset < bytes.length) { + const { bytesWritten } = await handle.write( + bytes, + offset, + bytes.length - offset, + position + offset + ) + if (bytesWritten === 0) throw new Error('Failed to persist request body') + offset += bytesWritten + } + return position + bytes.length +} + +export async function readBoundedRequestBody( + request: IncomingMessage, + options: BoundedBodyOptions +): Promise { + if ( + !Number.isSafeInteger(options.maxBytes) || + options.maxBytes <= 0 || + !Number.isSafeInteger(options.memoryThresholdBytes) || + options.memoryThresholdBytes < 0 || + options.memoryThresholdBytes > options.maxBytes + ) { + throw new Error('Invalid bounded-body limits') + } + + if (request.headers['content-encoding'] !== undefined) { + throw new CliRequestError('invalid_request', 'Content-Encoding is not supported') + } + + const declaredLength = parseDeclaredLength(request) + if (options.requireContentLength && declaredLength === null) { + throw new CliRequestError('invalid_request', 'Content-Length is required', { + httpStatus: 411 + }) + } + if (declaredLength !== null && declaredLength > options.maxBytes) { + throw new CliRequestError('body_too_large', 'Request body exceeds its byte limit', { + httpStatus: 413 + }) + } + + const chunks: Buffer[] = [] + let size = 0 + let filePosition = 0 + let fileHandle: FileHandle | undefined + let tempPath: string | undefined + + const cleanupPartial = async (): Promise => { + const cleanupErrors: unknown[] = [] + if (fileHandle) { + await fileHandle.close().catch((error: unknown) => cleanupErrors.push(error)) + fileHandle = undefined + } + if (tempPath) { + await removeFile(tempPath).catch((error: unknown) => cleanupErrors.push(error)) + } + if (cleanupErrors.length > 0) { + throw new AggregateError(cleanupErrors, 'Failed to clean up partial request body') + } + } + + try { + for await (const rawChunk of request) { + const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk) + size += chunk.length + if (size > options.maxBytes) { + request.pause() + throw new CliRequestError('body_too_large', 'Request body exceeds its byte limit', { + httpStatus: 413 + }) + } + + if (!fileHandle && size > options.memoryThresholdBytes) { + await mkdir(options.tempDirectory, { recursive: true, mode: 0o700 }) + tempPath = path.join(options.tempDirectory, `body-${randomUUID()}.tmp`) + fileHandle = await open(tempPath, 'wx', 0o600) + for (const buffered of chunks) { + filePosition = await writeAll(fileHandle, buffered, filePosition) + } + chunks.length = 0 + } + + if (fileHandle) filePosition = await writeAll(fileHandle, chunk, filePosition) + else chunks.push(chunk) + } + + if (declaredLength !== null && size !== declaredLength) { + throw new CliRequestError('invalid_request', 'Request body length does not match') + } + + if (fileHandle && tempPath) { + await fileHandle.close() + fileHandle = undefined + const persistedPath = tempPath + let cleaned = false + return { + kind: 'file', + path: persistedPath, + size, + cleanup: async () => { + if (cleaned) return + await removeFile(persistedPath) + cleaned = true + } + } + } + + return { + kind: 'memory', + bytes: Buffer.concat(chunks, size), + size, + cleanup: async () => undefined + } + } catch (error) { + try { + await cleanupPartial() + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + 'Request body failed and cleanup was incomplete' + ) + } + throw error + } +} + +function assertBoundedJsonShape(value: unknown): void { + const pending: Array<{ value: unknown; depth: number }> = [{ value, depth: 0 }] + let keys = 0 + let nodes = 0 + + while (pending.length > 0) { + const current = pending.pop()! + nodes += 1 + if (nodes > DEFAULT_MAX_JSON_NODES) { + throw new CliRequestError('invalid_request', 'JSON body has too many values') + } + if (current.depth > DEFAULT_MAX_JSON_DEPTH) { + throw new CliRequestError('invalid_request', 'JSON body is nested too deeply') + } + if (Array.isArray(current.value)) { + for (const entry of current.value) { + pending.push({ value: entry, depth: current.depth + 1 }) + } + continue + } + if (!current.value || typeof current.value !== 'object') continue + + for (const [key, entry] of Object.entries(current.value)) { + keys += 1 + if (keys > DEFAULT_MAX_JSON_KEYS) { + throw new CliRequestError('invalid_request', 'JSON body has too many keys') + } + if (UNSAFE_JSON_KEYS.has(key)) { + throw new CliRequestError('invalid_request', `JSON key is not allowed: ${key}`) + } + pending.push({ value: entry, depth: current.depth + 1 }) + } + } +} + +export async function parseBoundedJsonBody(body: BoundedRequestBody): Promise { + try { + const bytes = body.kind === 'memory' ? body.bytes : await readFile(body.path) + let text: string + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(bytes) + } catch { + throw new CliRequestError('invalid_request', 'Request body is not valid UTF-8') + } + + let parsed: unknown + try { + parsed = JSON.parse(text) as unknown + } catch { + throw new CliRequestError('invalid_request', 'Request body is not valid JSON') + } + assertBoundedJsonShape(parsed) + return parsed + } finally { + await body.cleanup() + } +} diff --git a/src/main/cli/descriptor.ts b/src/main/cli/descriptor.ts new file mode 100644 index 000000000..50585a7ea --- /dev/null +++ b/src/main/cli/descriptor.ts @@ -0,0 +1,239 @@ +import { createHash, randomBytes, randomUUID } from 'node:crypto' +import { execFile } from 'node:child_process' +import { chmod, lstat, mkdir, open, readFile, rename, rmdir, unlink } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { promisify } from 'node:util' +import { + LOCAL_CONTROL_DESCRIPTOR_FILENAME, + LOCAL_CONTROL_PROTOCOL_VERSION, + LOCAL_CONTROL_SURFACE_VERSION, + LocalControlDescriptorSchema, + type LocalControlDescriptor, + type LocalControlEndpoint +} from '@shared/contracts/localControl' + +const execFileAsync = promisify(execFile) +const MAX_POSIX_SOCKET_PATH_BYTES = 100 + +export type CliControlLayout = Readonly<{ + controlDirectory: string + descriptorPath: string + tempDirectory: string + endpointDirectory: string + endpoint: LocalControlEndpoint +}> + +async function removeIfPresent(filePath: string): Promise { + try { + await unlink(filePath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } +} + +async function applyWindowsOwnerAcl(targetPath: string, directory: boolean): Promise { + const username = os.userInfo().username + const grant = directory ? `${username}:(OI)(CI)F` : `${username}:F` + await execFileAsync('icacls.exe', [targetPath, '/inheritance:r', '/grant:r', grant], { + windowsHide: true, + timeout: 5_000 + }) +} + +async function preparePrivatePosixDirectory(directory: string): Promise { + await mkdir(directory, { recursive: true, mode: 0o700 }) + const directoryStat = await lstat(directory) + if (!directoryStat.isDirectory()) throw new Error('Local-control path is not a directory') + if (typeof process.getuid === 'function' && directoryStat.uid !== process.getuid()) { + throw new Error('Local-control directory is not owned by the current user') + } + await chmod(directory, 0o700) + const protectedStat = await lstat(directory) + if ((protectedStat.mode & 0o077) !== 0) { + throw new Error('Local-control directory permissions are not private') + } +} + +function createFallbackSocketPath(userDataPath: string): string { + const profileIdentity = createHash('sha256') + .update(path.resolve(userDataPath)) + .digest('hex') + .slice(0, 16) + const ownerIdentity = + typeof process.getuid === 'function' + ? String(process.getuid()) + : createHash('sha256').update(os.userInfo().username).digest('hex').slice(0, 8) + const directoryName = `deepchat-${ownerIdentity}-${profileIdentity}` + const bases = [process.env.XDG_RUNTIME_DIR, os.tmpdir(), '/tmp'] + + for (const base of bases) { + if (!base || !path.isAbsolute(base)) continue + const candidate = path.join(base, directoryName, 'control.sock') + if (Buffer.byteLength(candidate) <= MAX_POSIX_SOCKET_PATH_BYTES) return candidate + } + throw new Error('No private path is short enough for the DeepChat Unix socket') +} + +export function createLocalControlLayout( + userDataPath: string, + platform: NodeJS.Platform = process.platform +): CliControlLayout { + const controlDirectory = path.join(userDataPath, 'local-control') + const descriptorPath = path.join(controlDirectory, LOCAL_CONTROL_DESCRIPTOR_FILENAME) + const tempDirectory = path.join(controlDirectory, 'tmp') + + if (platform === 'win32') { + const identity = createHash('sha256') + .update(path.resolve(userDataPath)) + .digest('hex') + .slice(0, 16) + return { + controlDirectory, + descriptorPath, + tempDirectory, + endpointDirectory: controlDirectory, + endpoint: { + kind: 'pipe', + name: `\\\\.\\pipe\\deepchat-${identity}-${randomUUID()}` + } + } + } + + const preferredSocketPath = path.join(controlDirectory, 'control.sock') + const socketPath = + Buffer.byteLength(preferredSocketPath) <= MAX_POSIX_SOCKET_PATH_BYTES + ? preferredSocketPath + : createFallbackSocketPath(userDataPath) + return { + controlDirectory, + descriptorPath, + tempDirectory, + endpointDirectory: path.dirname(socketPath), + endpoint: { kind: 'unix', path: socketPath } + } +} + +export async function prepareLocalControlLayout( + layout: CliControlLayout, + platform: NodeJS.Platform = process.platform +): Promise { + if (platform === 'win32') { + await mkdir(layout.controlDirectory, { recursive: true, mode: 0o700 }) + await mkdir(layout.tempDirectory, { recursive: true, mode: 0o700 }) + await applyWindowsOwnerAcl(layout.controlDirectory, true) + await applyWindowsOwnerAcl(layout.tempDirectory, true) + } else { + await preparePrivatePosixDirectory(layout.controlDirectory) + await preparePrivatePosixDirectory(layout.tempDirectory) + if (layout.endpointDirectory !== layout.controlDirectory) { + await preparePrivatePosixDirectory(layout.endpointDirectory) + } + } + + if (layout.endpoint.kind === 'unix') { + try { + const socketStat = await lstat(layout.endpoint.path) + if (!socketStat.isSocket()) { + throw new Error('Refusing to replace a non-socket local-control endpoint') + } + await unlink(layout.endpoint.path) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + } + await removeIfPresent(layout.descriptorPath) +} + +export function createLocalControlToken(): string { + return randomBytes(32).toString('base64url') +} + +export async function writeLocalControlDescriptor( + layout: CliControlLayout, + input: { + appVersion: string + endpoint: LocalControlEndpoint + pid: number + token: string + startedAt: number + }, + platform: NodeJS.Platform = process.platform +): Promise { + const descriptor = LocalControlDescriptorSchema.parse({ + protocolVersion: LOCAL_CONTROL_PROTOCOL_VERSION, + surfaceVersion: LOCAL_CONTROL_SURFACE_VERSION, + ...input + }) + const tempPath = path.join(layout.controlDirectory, `.descriptor-${randomUUID()}.tmp`) + const handle = await open(tempPath, 'wx', 0o600) + try { + try { + await handle.writeFile(`${JSON.stringify(descriptor)}\n`, 'utf8') + await handle.sync() + } finally { + await handle.close() + } + if (platform === 'win32') await applyWindowsOwnerAcl(tempPath, false) + else await chmod(tempPath, 0o600) + await rename(tempPath, layout.descriptorPath) + if (platform === 'win32') await applyWindowsOwnerAcl(layout.descriptorPath, false) + else await chmod(layout.descriptorPath, 0o600) + return descriptor + } catch (error) { + await removeIfPresent(tempPath).catch(() => undefined) + throw error + } +} + +export async function protectUnixSocket(socketPath: string): Promise { + await chmod(socketPath, 0o600) + const socketStat = await lstat(socketPath) + if (!socketStat.isSocket()) throw new Error('Local-control endpoint is not a Unix socket') + if (typeof process.getuid === 'function' && socketStat.uid !== process.getuid()) { + throw new Error('Local-control socket is not owned by the current user') + } + if ((socketStat.mode & 0o077) !== 0) { + throw new Error('Local-control socket permissions are not private') + } +} + +export async function cleanupLocalControlLayout( + layout: CliControlLayout, + token: string +): Promise { + let endpointBelongsToCaller = true + try { + const raw = await readFile(layout.descriptorPath, 'utf8') + let parsed: unknown + try { + parsed = JSON.parse(raw) as unknown + } catch { + parsed = null + } + const current = LocalControlDescriptorSchema.safeParse(parsed) + if (current.success && current.data.token === token) { + await removeIfPresent(layout.descriptorPath) + } else if (current.success) { + endpointBelongsToCaller = false + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + + if (!endpointBelongsToCaller || layout.endpoint.kind !== 'unix') return + try { + const socketStat = await lstat(layout.endpoint.path) + if (socketStat.isSocket()) await unlink(layout.endpoint.path) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + if (layout.endpointDirectory !== layout.controlDirectory) { + try { + await rmdir(layout.endpointDirectory) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code !== 'ENOENT' && code !== 'ENOTEMPTY') throw error + } + } +} diff --git a/src/main/cli/errors.ts b/src/main/cli/errors.ts new file mode 100644 index 000000000..a07c3f857 --- /dev/null +++ b/src/main/cli/errors.ts @@ -0,0 +1,25 @@ +import type { JsonValue } from '@shared/contracts/common' +import type { LocalControlErrorCode } from '@shared/contracts/localControl' + +export class CliRequestError extends Error { + constructor( + readonly code: LocalControlErrorCode, + message: string, + readonly options: { + httpStatus?: number + retriable?: boolean + details?: Record + } = {} + ) { + super(message) + this.name = 'CliRequestError' + } + + get httpStatus(): number { + return this.options.httpStatus ?? 400 + } + + get retriable(): boolean { + return this.options.retriable ?? false + } +} diff --git a/src/main/cli/index.ts b/src/main/cli/index.ts new file mode 100644 index 000000000..96a1231d5 --- /dev/null +++ b/src/main/cli/index.ts @@ -0,0 +1,3 @@ +export { CliServer, type CliServerDependencies } from './server' +export { createCliRoutes, type CliRuntimeStatus } from './routes' +export { CLI_SURFACE_V1, getCliSurfaceEntry, listCliSurfaceCapabilities } from './surface' diff --git a/src/main/cli/routes.ts b/src/main/cli/routes.ts new file mode 100644 index 000000000..225ca6c29 --- /dev/null +++ b/src/main/cli/routes.ts @@ -0,0 +1,103 @@ +import { + cliCapabilitiesRoute, + cliDoctorRoute, + cliStatusRoute, + cliVersionRoute +} from '@shared/contracts/routes' +import { + LOCAL_CONTROL_PROTOCOL_VERSION, + LOCAL_CONTROL_SURFACE_VERSION +} from '@shared/contracts/localControl' +import { createRouteMap, type DeepchatRouteMap } from '@/routes/routeRegistry' +import { listCliSurfaceCapabilities } from './surface' + +export type CliRuntimeStatus = Readonly<{ + running: boolean + pid: number + startedAt: number + uptimeMs: number + endpointKind: 'unix' | 'pipe' + activeConnections: number + pendingRequests: number + descriptorReady: boolean +}> + +export function createCliRoutes(deps: { + appVersion: string + getStatus(): CliRuntimeStatus + hasTrustedRenderer(): boolean +}): DeepchatRouteMap { + return createRouteMap([ + [ + cliStatusRoute.name, + async (rawInput) => { + cliStatusRoute.input.parse(rawInput) + const { descriptorReady: _descriptorReady, ...status } = deps.getStatus() + return cliStatusRoute.output.parse(status) + } + ], + [ + cliVersionRoute.name, + async (rawInput) => { + cliVersionRoute.input.parse(rawInput) + return cliVersionRoute.output.parse({ + appVersion: deps.appVersion, + protocolVersion: LOCAL_CONTROL_PROTOCOL_VERSION, + surfaceVersion: LOCAL_CONTROL_SURFACE_VERSION + }) + } + ], + [ + cliCapabilitiesRoute.name, + async (rawInput) => { + cliCapabilitiesRoute.input.parse(rawInput) + return cliCapabilitiesRoute.output.parse({ + protocolVersion: LOCAL_CONTROL_PROTOCOL_VERSION, + surfaceVersion: LOCAL_CONTROL_SURFACE_VERSION, + capabilities: listCliSurfaceCapabilities() + }) + } + ], + [ + cliDoctorRoute.name, + async (rawInput) => { + cliDoctorRoute.input.parse(rawInput) + const status = deps.getStatus() + const capabilities = listCliSurfaceCapabilities() + const hasTrustedRenderer = deps.hasTrustedRenderer() + const checks = [ + { + id: 'transport' as const, + status: status.running ? ('ok' as const) : ('error' as const), + message: status.running + ? 'Local transport is accepting requests' + : 'Local transport is stopped' + }, + { + id: 'descriptor' as const, + status: status.descriptorReady ? ('ok' as const) : ('error' as const), + message: status.descriptorReady + ? 'Private discovery descriptor is available' + : 'Private discovery descriptor is unavailable' + }, + { + id: 'surface' as const, + status: capabilities.length > 0 ? ('ok' as const) : ('error' as const), + message: `${capabilities.length} V1 methods are registered` + }, + { + id: 'renderer' as const, + status: hasTrustedRenderer ? ('ok' as const) : ('warning' as const), + message: hasTrustedRenderer + ? 'A trusted renderer can present approvals' + : 'No trusted renderer is currently available for approvals' + } + ] + return cliDoctorRoute.output.parse({ + healthy: checks.every((check) => check.status !== 'error'), + checks + }) + } + ] + ]) +} diff --git a/src/main/cli/server.ts b/src/main/cli/server.ts new file mode 100644 index 000000000..8c033b320 --- /dev/null +++ b/src/main/cli/server.ts @@ -0,0 +1,601 @@ +import { createHash, randomUUID, timingSafeEqual } from 'node:crypto' +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' +import type { Socket } from 'node:net' +import path from 'node:path' +import { z } from 'zod' +import { JsonValueSchema, TimestampMsSchema, type JsonValue } from '@shared/contracts/common' +import { + LOCAL_CONTROL_DESCRIPTOR_FILENAME, + LOCAL_CONTROL_PROTOCOL_VERSION, + LOCAL_CONTROL_SCOPES, + LOCAL_CONTROL_SURFACE_VERSION, + LocalControlScopesSchema, + LocalControlRpcRequestSchema, + createLocalControlFailure, + createLocalControlSuccess, + type LocalControlDescriptor +} from '@shared/contracts/localControl' +import type { CliRouteCaller } from '@/routes/routeRegistry' +import { parseBoundedJsonBody, readBoundedRequestBody } from './body' +import { + cleanupLocalControlLayout, + createLocalControlLayout, + createLocalControlToken, + prepareLocalControlLayout, + protectUnixSocket, + writeLocalControlDescriptor, + type CliControlLayout +} from './descriptor' +import { CliRequestError } from './errors' +import { CLI_SURFACE_V1, getCliSurfaceEntry } from './surface' +import type { CliRuntimeStatus } from './routes' + +const MAX_HEADER_BYTES = 8 * 1024 +const MAX_CONNECTIONS = 64 +const MAX_PENDING_REQUESTS = 64 +const MAX_PENDING_PER_CONNECTION = 8 +const SHUTDOWN_GRACE_MS = 2_000 +const UNKNOWN_REQUEST_ID = 'unknown' + +const AgentCliTokenSchema = z + .object({ + conversationId: z.string().min(1).max(128), + expiresAt: TimestampMsSchema.max(Number.MAX_SAFE_INTEGER), + scopes: LocalControlScopesSchema + }) + .strict() + +export type AgentCliToken = z.infer + +export type CliServerDependencies = Readonly<{ + userDataPath: string + appVersion: string + dispatch( + method: string, + input: unknown, + caller: CliRouteCaller, + signal: AbortSignal + ): Promise + resolveAgentToken?(token: string): AgentCliToken | null + now?: () => number + platform?: NodeJS.Platform + pid?: number + log?: Pick +}> + +function hashToken(token: string): Buffer { + return createHash('sha256').update(token).digest() +} + +function tokensEqual(left: string, right: string): boolean { + return timingSafeEqual(hashToken(left), hashToken(right)) +} + +function readBearerToken(request: IncomingMessage): string | null { + const authorization = request.headers.authorization + if (typeof authorization !== 'string') return null + const match = /^Bearer ([A-Za-z0-9_-]{43,256})$/.exec(authorization) + return match?.[1] ?? null +} + +function requestContentTypeIsJson(request: IncomingMessage): boolean { + const contentType = request.headers['content-type'] + if (typeof contentType !== 'string') return false + const [mediaType, ...parameters] = contentType.split(';').map((part) => part.trim().toLowerCase()) + if (mediaType !== 'application/json') return false + return parameters.every((parameter) => parameter === 'charset=utf-8') +} + +function getMaxRpcBodyBytes(): number { + let maxBytes = 1 + for (const entry of CLI_SURFACE_V1.values()) { + if (entry.transport === 'rpc') maxBytes = Math.max(maxBytes, entry.limits.maxBodyBytes) + } + return maxBytes +} + +function toSafeRequestId(value: unknown): string { + if (typeof value !== 'string') return UNKNOWN_REQUEST_ID + return /^[A-Za-z0-9._:-]{1,128}$/.test(value) ? value : UNKNOWN_REQUEST_ID +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function requestAbortError(signal: AbortSignal): CliRequestError { + return signal.reason instanceof CliRequestError + ? signal.reason + : new CliRequestError('cancelled', 'Request was cancelled', { retriable: true }) +} + +function abortRequest(controller: AbortController, error: CliRequestError): void { + if (!controller.signal.aborted) controller.abort(error) +} + +async function runAbortable(signal: AbortSignal, action: () => Promise): Promise { + if (signal.aborted) throw requestAbortError(signal) + + return await new Promise((resolve, reject) => { + let settled = false + const finish = (callback: () => void) => { + if (settled) return + settled = true + signal.removeEventListener('abort', onAbort) + callback() + } + const onAbort = () => finish(() => reject(requestAbortError(signal))) + signal.addEventListener('abort', onAbort, { once: true }) + + void Promise.resolve() + .then(action) + .then( + (value) => finish(() => resolve(value)), + (error: unknown) => finish(() => reject(error)) + ) + }) +} + +export class CliServer { + private readonly now: () => number + private readonly platform: NodeJS.Platform + private readonly pid: number + private readonly log: Pick + private readonly sockets = new Set() + private readonly connectionIds = new WeakMap() + private readonly pendingByConnection = new Map() + private readonly requestControllers = new Set() + private server: Server | undefined + private layout: CliControlLayout | undefined + private token = '' + private startedAt = 0 + private descriptorReady = false + private pendingRequests = 0 + private startPromise: Promise | undefined + private stopPromise: Promise | undefined + + constructor(private readonly dependencies: CliServerDependencies) { + this.now = dependencies.now ?? Date.now + this.platform = dependencies.platform ?? process.platform + this.pid = dependencies.pid ?? process.pid + this.log = dependencies.log ?? console + } + + getStatus(): CliRuntimeStatus { + const running = this.server?.listening === true + return { + running, + pid: this.pid, + startedAt: this.startedAt, + uptimeMs: running ? Math.max(0, this.now() - this.startedAt) : 0, + endpointKind: this.platform === 'win32' ? 'pipe' : 'unix', + activeConnections: this.sockets.size, + pendingRequests: this.pendingRequests, + descriptorReady: this.descriptorReady + } + } + + getDescriptorPath(): string { + return path.join( + this.dependencies.userDataPath, + 'local-control', + LOCAL_CONTROL_DESCRIPTOR_FILENAME + ) + } + + async start(): Promise { + if (this.stopPromise) await this.stopPromise + if (this.startPromise) return await this.startPromise + + this.startPromise = this.startInternal() + try { + return await this.startPromise + } catch (error) { + this.startPromise = undefined + throw error + } + } + + async stop(): Promise { + if (this.stopPromise) return await this.stopPromise + this.stopPromise = (async () => { + if (this.startPromise) await this.startPromise.catch(() => undefined) + await this.stopInternal() + })() + try { + await this.stopPromise + } finally { + this.stopPromise = undefined + this.startPromise = undefined + } + } + + private async startInternal(): Promise { + const layout = createLocalControlLayout(this.dependencies.userDataPath, this.platform) + const token = createLocalControlToken() + const startedAt = this.now() + await prepareLocalControlLayout(layout, this.platform) + + const server = createServer({ maxHeaderSize: MAX_HEADER_BYTES }, (request, response) => { + void this.handleRequest(request, response).catch((error) => { + this.log.error('[CLI] Unhandled request failure', error) + if (!response.headersSent && !response.destroyed) { + this.sendFailure( + response, + 500, + UNKNOWN_REQUEST_ID, + new CliRequestError('internal_error', 'Internal local-control error', { + httpStatus: 500 + }) + ) + } else if (!response.destroyed) { + response.destroy() + } + }) + }) + server.maxConnections = MAX_CONNECTIONS + server.headersTimeout = 10_000 + server.requestTimeout = 30_000 + server.keepAliveTimeout = 5_000 + server.on('connection', (socket) => { + this.sockets.add(socket) + this.connectionIds.set(socket, randomUUID()) + socket.once('close', () => { + const connectionId = this.connectionIds.get(socket) + this.sockets.delete(socket) + if (connectionId) this.pendingByConnection.delete(connectionId) + }) + }) + server.on('clientError', (_error, socket) => { + if (!socket.destroyed) { + socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n') + } + }) + server.on('error', (error) => { + this.log.error('[CLI] Server error', error) + }) + + this.layout = layout + this.token = token + this.startedAt = startedAt + this.server = server + + try { + await new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error) + server.once('error', onError) + server.listen( + layout.endpoint.kind === 'unix' ? layout.endpoint.path : layout.endpoint.name, + () => { + server.off('error', onError) + resolve() + } + ) + }) + if (layout.endpoint.kind === 'unix') await protectUnixSocket(layout.endpoint.path) + const descriptor = await writeLocalControlDescriptor( + layout, + { + appVersion: this.dependencies.appVersion, + endpoint: layout.endpoint, + pid: this.pid, + token, + startedAt + }, + this.platform + ) + this.descriptorReady = true + return descriptor + } catch (error) { + await this.closeServer(server) + await cleanupLocalControlLayout(layout, token).catch(() => undefined) + this.resetRuntimeState() + throw error + } + } + + private async stopInternal(): Promise { + for (const controller of this.requestControllers) { + abortRequest( + controller, + new CliRequestError('unavailable', 'CLI server is stopping', { + httpStatus: 503, + retriable: true + }) + ) + } + const server = this.server + const layout = this.layout + const token = this.token + this.descriptorReady = false + + if (server) await this.closeServer(server) + if (layout && token) await cleanupLocalControlLayout(layout, token) + this.resetRuntimeState() + } + + private resetRuntimeState(): void { + this.server = undefined + this.layout = undefined + this.token = '' + this.startedAt = 0 + this.descriptorReady = false + this.pendingRequests = 0 + this.pendingByConnection.clear() + this.requestControllers.clear() + this.sockets.clear() + } + + private async closeServer(server: Server): Promise { + if (!server.listening) return + await new Promise((resolve) => { + let settled = false + const finish = () => { + if (settled) return + settled = true + clearTimeout(forceTimer) + clearTimeout(fallbackTimer) + resolve() + } + const forceTimer = setTimeout(() => { + for (const socket of this.sockets) socket.destroy() + server.closeAllConnections() + }, SHUTDOWN_GRACE_MS) + forceTimer.unref() + const fallbackTimer = setTimeout(finish, SHUTDOWN_GRACE_MS + 1_000) + fallbackTimer.unref() + server.close(finish) + server.closeIdleConnections() + }) + } + + private authenticate(request: IncomingMessage, connectionId: string): CliRouteCaller | null { + const token = readBearerToken(request) + if (!token) return null + if (this.token && tokensEqual(token, this.token)) { + return { + kind: 'cli', + principal: 'human', + connectionId, + scopes: LOCAL_CONTROL_SCOPES + } + } + + const agent = AgentCliTokenSchema.safeParse(this.dependencies.resolveAgentToken?.(token)) + if (!agent.success || agent.data.expiresAt <= this.now()) return null + return { + kind: 'cli', + principal: 'agent', + connectionId, + scopes: agent.data.scopes, + conversationId: agent.data.conversationId, + expiresAt: agent.data.expiresAt + } + } + + private async handleRequest(request: IncomingMessage, response: ServerResponse): Promise { + response.setHeader('Cache-Control', 'no-store') + response.setHeader('X-Content-Type-Options', 'nosniff') + + const connectionId = this.connectionIds.get(request.socket) ?? randomUUID() + if (request.method !== 'POST' || request.url !== '/v1/rpc') { + this.sendFailure( + response, + 404, + UNKNOWN_REQUEST_ID, + new CliRequestError('not_found', 'Local-control endpoint was not found', { + httpStatus: 404 + }) + ) + return + } + if (request.headers.expect !== undefined) { + this.sendFailure( + response, + 417, + UNKNOWN_REQUEST_ID, + new CliRequestError('invalid_request', 'Expect is not supported', { + httpStatus: 417 + }) + ) + return + } + + const caller = this.authenticate(request, connectionId) + if (!caller) { + this.sendFailure( + response, + 401, + UNKNOWN_REQUEST_ID, + new CliRequestError('authentication_failed', 'Authentication failed', { + httpStatus: 401 + }) + ) + return + } + if (!requestContentTypeIsJson(request)) { + this.sendFailure( + response, + 415, + UNKNOWN_REQUEST_ID, + new CliRequestError('invalid_request', 'Content-Type must be application/json', { + httpStatus: 415 + }) + ) + return + } + + const connectionPending = this.pendingByConnection.get(connectionId) ?? 0 + if ( + this.pendingRequests >= MAX_PENDING_REQUESTS || + connectionPending >= MAX_PENDING_PER_CONNECTION + ) { + this.sendFailure( + response, + 429, + UNKNOWN_REQUEST_ID, + new CliRequestError('rate_limited', 'Too many pending local-control requests', { + httpStatus: 429, + retriable: true + }) + ) + return + } + + this.pendingRequests += 1 + this.pendingByConnection.set(connectionId, connectionPending + 1) + const controller = new AbortController() + this.requestControllers.add(controller) + const abort = () => { + abortRequest(controller, new CliRequestError('cancelled', 'Request was cancelled')) + } + request.once('aborted', abort) + response.once('close', () => { + if (!response.writableEnded) abort() + }) + + let requestId = UNKNOWN_REQUEST_ID + let routeMethod = 'unknown' + try { + const body = await readBoundedRequestBody(request, { + maxBytes: getMaxRpcBodyBytes(), + memoryThresholdBytes: getMaxRpcBodyBytes(), + tempDirectory: this.layout?.tempDirectory ?? this.dependencies.userDataPath, + requireContentLength: true + }) + const bodySize = body.size + const rawRequest = await parseBoundedJsonBody(body) + if (isRecord(rawRequest)) requestId = toSafeRequestId(rawRequest.id) + if ( + isRecord(rawRequest) && + (rawRequest.protocolVersion !== LOCAL_CONTROL_PROTOCOL_VERSION || + rawRequest.surfaceVersion !== LOCAL_CONTROL_SURFACE_VERSION) + ) { + throw new CliRequestError( + 'unsupported_version', + `Expected protocol ${LOCAL_CONTROL_PROTOCOL_VERSION} and surface ${LOCAL_CONTROL_SURFACE_VERSION}`, + { httpStatus: 409 } + ) + } + + const parsedRequest = LocalControlRpcRequestSchema.safeParse(rawRequest) + if (!parsedRequest.success) { + throw new CliRequestError('invalid_request', 'Request does not match the RPC contract') + } + const rpcRequest = parsedRequest.data + requestId = rpcRequest.id + routeMethod = rpcRequest.method + const entry = getCliSurfaceEntry(rpcRequest.method) + if (!entry || entry.transport !== 'rpc') { + throw new CliRequestError('not_found', 'Method is not exposed by CLI surface V1', { + httpStatus: 404 + }) + } + if (bodySize > entry.limits.maxBodyBytes) { + throw new CliRequestError('body_too_large', 'Request body exceeds method limit', { + httpStatus: 413 + }) + } + if (!entry.callers.includes(caller.principal)) { + throw new CliRequestError('permission_denied', 'Caller is not allowed for method', { + httpStatus: 403 + }) + } + if (!entry.scopes.every((scope) => caller.scopes.includes(scope))) { + throw new CliRequestError('permission_denied', 'Required scope is missing', { + httpStatus: 403 + }) + } + + const parsedInput = entry.contract.input.safeParse(rpcRequest.params) + if (!parsedInput.success) { + throw new CliRequestError('invalid_request', 'Request does not match the route contract') + } + const input = parsedInput.data + const timeout = setTimeout(() => { + abortRequest( + controller, + new CliRequestError('timeout', 'Request timed out', { + httpStatus: 504 + }) + ) + }, entry.limits.timeoutMs) + timeout.unref() + let rawOutput: unknown + try { + rawOutput = await runAbortable(controller.signal, async () => + this.dependencies.dispatch(entry.contract.name, input, caller, controller.signal) + ) + } finally { + clearTimeout(timeout) + } + if (controller.signal.aborted) { + throw requestAbortError(controller.signal) + } + const parsedOutput = entry.contract.output.safeParse(rawOutput) + const parsedResult = parsedOutput.success + ? JsonValueSchema.safeParse(parsedOutput.data) + : { success: false as const } + if (!parsedOutput.success || !parsedResult.success) { + this.log.error('[CLI] Route returned invalid output', { method: routeMethod }) + throw new CliRequestError('internal_error', 'Route returned an invalid result', { + httpStatus: 500 + }) + } + const result = parsedResult.data as JsonValue + this.sendJson(response, 200, createLocalControlSuccess(requestId, result)) + } catch (error) { + if (error instanceof CliRequestError) { + this.sendFailure(response, error.httpStatus, requestId, error) + } else { + this.log.warn('[CLI] Route dispatch failed', { method: routeMethod }, error) + this.sendFailure( + response, + 500, + requestId, + new CliRequestError('internal_error', 'Internal local-control error', { + httpStatus: 500 + }) + ) + } + } finally { + request.off('aborted', abort) + this.requestControllers.delete(controller) + this.pendingRequests = Math.max(0, this.pendingRequests - 1) + const remaining = (this.pendingByConnection.get(connectionId) ?? 1) - 1 + if (remaining > 0) this.pendingByConnection.set(connectionId, remaining) + else this.pendingByConnection.delete(connectionId) + } + } + + private sendFailure( + response: ServerResponse, + status: number, + requestId: string, + error: CliRequestError + ): void { + this.sendJson( + response, + status, + createLocalControlFailure(requestId, { + code: error.code, + message: error.message, + retriable: error.retriable, + ...(error.options.details ? { details: error.options.details } : {}) + }) + ) + } + + private sendJson(response: ServerResponse, status: number, body: JsonValue): void { + if (response.destroyed || response.writableEnded) return + const serialized = Buffer.from(JSON.stringify(body), 'utf8') + response.statusCode = status + if (status >= 400) { + response.shouldKeepAlive = false + response.setHeader('Connection', 'close') + } + response.setHeader('Content-Type', 'application/json; charset=utf-8') + response.setHeader('Content-Length', serialized.length) + response.end(serialized) + } +} diff --git a/src/main/cli/surface.ts b/src/main/cli/surface.ts new file mode 100644 index 000000000..5634ff7a8 --- /dev/null +++ b/src/main/cli/surface.ts @@ -0,0 +1,85 @@ +import type { RouteContract } from '@shared/contracts/common' +import { + cliCapabilitiesRoute, + cliDoctorRoute, + cliStatusRoute, + cliVersionRoute, + type CliCapability +} from '@shared/contracts/routes' +import type { + LocalControlEffect, + LocalControlPrincipal, + LocalControlScope +} from '@shared/contracts/localControl' + +export type LocalControlTransport = 'rpc' | 'stream' | 'upload' +export type LocalControlApprovalMode = 'never' | 'policy' + +export type CliRouteLimits = Readonly<{ + maxBodyBytes: number + timeoutMs: number +}> + +export type CliSurfaceEntry = Readonly<{ + contract: RouteContract + effect: LocalControlEffect + callers: readonly LocalControlPrincipal[] + scopes: readonly LocalControlScope[] + transport: LocalControlTransport + approval: LocalControlApprovalMode + limits: CliRouteLimits +}> + +const DIAGNOSTIC_LIMITS = { + maxBodyBytes: 16 * 1024, + timeoutMs: 5_000 +} as const satisfies CliRouteLimits + +const diagnosticEntry = (contract: RouteContract): CliSurfaceEntry => ({ + contract, + effect: 'read', + callers: ['human', 'agent'], + scopes: ['system:read'], + transport: 'rpc', + approval: 'never', + limits: DIAGNOSTIC_LIMITS +}) + +const CLI_SURFACE_V1_ENTRIES = [ + diagnosticEntry(cliStatusRoute), + diagnosticEntry(cliVersionRoute), + diagnosticEntry(cliCapabilitiesRoute), + diagnosticEntry(cliDoctorRoute) +] as const + +function createSurfaceRegistry( + entries: readonly CliSurfaceEntry[] +): ReadonlyMap { + const registry = new Map() + for (const entry of entries) { + if (registry.has(entry.contract.name)) { + throw new Error(`Duplicate CLI surface method: ${entry.contract.name}`) + } + registry.set(entry.contract.name, entry) + } + return registry +} + +export const CLI_SURFACE_V1 = createSurfaceRegistry(CLI_SURFACE_V1_ENTRIES) + +export function getCliSurfaceEntry(method: string): CliSurfaceEntry | undefined { + return CLI_SURFACE_V1.get(method) +} + +export function listCliSurfaceCapabilities(): CliCapability[] { + return Array.from(CLI_SURFACE_V1.values(), (entry) => ({ + method: entry.contract.name, + effect: entry.effect, + callers: [...entry.callers], + scopes: [...entry.scopes], + transport: entry.transport, + approval: entry.approval, + maxBodyBytes: entry.limits.maxBodyBytes, + timeoutMs: entry.limits.timeoutMs + })).sort((left, right) => left.method.localeCompare(right.method)) +} diff --git a/src/shared/contracts/routes.ts b/src/shared/contracts/routes.ts index 9628df395..3163a2e54 100644 --- a/src/shared/contracts/routes.ts +++ b/src/shared/contracts/routes.ts @@ -74,6 +74,12 @@ import { memorySetPersonaAnchorRoute, memoryUpdateRoute } from './routes/memory.routes' +import { + cliCapabilitiesRoute, + cliDoctorRoute, + cliStatusRoute, + cliVersionRoute +} from './routes/cli.routes' import { configAddCustomPromptRoute, configAddManualAcpAgentRoute, @@ -564,6 +570,7 @@ export * from './routes/dialog.routes' export * from './routes/device.routes' export * from './routes/file.routes' export * from './routes/knowledge.routes' +export * from './routes/cli.routes' export * from './routes/mcp.routes' export * from './routes/memory.routes' export * from './routes/models.routes' @@ -933,6 +940,10 @@ const DEEPCHAT_ROUTE_CATALOG_PART_4 = { } satisfies Record const DEEPCHAT_ROUTE_CATALOG_PART_5 = { + [cliStatusRoute.name]: cliStatusRoute, + [cliVersionRoute.name]: cliVersionRoute, + [cliCapabilitiesRoute.name]: cliCapabilitiesRoute, + [cliDoctorRoute.name]: cliDoctorRoute, [chatCancelSubmissionRoute.name]: chatCancelSubmissionRoute, [chatSendMessageRoute.name]: chatSendMessageRoute, [chatSteerActiveTurnRoute.name]: chatSteerActiveTurnRoute, diff --git a/src/shared/contracts/routes/cli.routes.ts b/src/shared/contracts/routes/cli.routes.ts new file mode 100644 index 000000000..51b2b1d59 --- /dev/null +++ b/src/shared/contracts/routes/cli.routes.ts @@ -0,0 +1,79 @@ +import { z } from 'zod' +import { defineRouteContract } from '../common' +import { + LOCAL_CONTROL_PROTOCOL_VERSION, + LOCAL_CONTROL_SURFACE_VERSION, + LocalControlEffectSchema, + LocalControlMethodSchema, + LocalControlPrincipalSchema, + LocalControlScopeSchema +} from '../localControl' + +export const LocalControlTransportSchema = z.enum(['rpc', 'stream', 'upload']) +export const LocalControlApprovalModeSchema = z.enum(['never', 'policy']) + +export const LocalControlCapabilitySchema = z + .object({ + method: LocalControlMethodSchema, + effect: LocalControlEffectSchema, + callers: z.array(LocalControlPrincipalSchema).min(1).max(2), + scopes: z.array(LocalControlScopeSchema).min(1), + transport: LocalControlTransportSchema, + approval: LocalControlApprovalModeSchema, + maxBodyBytes: z.number().int().positive(), + timeoutMs: z.number().int().positive() + }) + .strict() + +export const cliStatusRoute = defineRouteContract({ + name: 'cli.status', + input: z.object({}).default({}), + output: z.object({ + running: z.boolean(), + pid: z.number().int().positive(), + startedAt: z.number().int().nonnegative(), + uptimeMs: z.number().int().nonnegative(), + endpointKind: z.enum(['unix', 'pipe']), + activeConnections: z.number().int().nonnegative(), + pendingRequests: z.number().int().nonnegative() + }) +}) + +export const cliVersionRoute = defineRouteContract({ + name: 'cli.version', + input: z.object({}).default({}), + output: z.object({ + appVersion: z.string().min(1), + protocolVersion: z.literal(LOCAL_CONTROL_PROTOCOL_VERSION), + surfaceVersion: z.literal(LOCAL_CONTROL_SURFACE_VERSION) + }) +}) + +export const cliCapabilitiesRoute = defineRouteContract({ + name: 'cli.capabilities', + input: z.object({}).default({}), + output: z.object({ + protocolVersion: z.literal(LOCAL_CONTROL_PROTOCOL_VERSION), + surfaceVersion: z.literal(LOCAL_CONTROL_SURFACE_VERSION), + capabilities: z.array(LocalControlCapabilitySchema) + }) +}) + +export const cliDoctorRoute = defineRouteContract({ + name: 'cli.doctor', + input: z.object({}).default({}), + output: z.object({ + healthy: z.boolean(), + checks: z.array( + z + .object({ + id: z.enum(['transport', 'descriptor', 'surface', 'renderer']), + status: z.enum(['ok', 'warning', 'error']), + message: z.string().min(1).max(1024) + }) + .strict() + ) + }) +}) + +export type CliCapability = z.infer diff --git a/test/main/cli/body.test.ts b/test/main/cli/body.test.ts new file mode 100644 index 000000000..d689a5492 --- /dev/null +++ b/test/main/cli/body.test.ts @@ -0,0 +1,126 @@ +import { Readable } from 'node:stream' +import type { IncomingMessage } from 'node:http' +import { mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { parseBoundedJsonBody, readBoundedRequestBody } from '@/cli/body' +import { CliRequestError } from '@/cli/errors' + +const temporaryDirectories: string[] = [] + +async function createTemporaryDirectory(): Promise { + const directory = await mkdtemp(path.join(os.tmpdir(), 'deepchat-cli-body-')) + temporaryDirectories.push(directory) + return directory +} + +function createRequest( + chunks: Array, + headers: Record = {} +): IncomingMessage { + const request = Readable.from(chunks) + Object.defineProperties(request, { + headers: { value: headers }, + headersDistinct: { + value: Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, [value]])) + } + }) + return request as IncomingMessage +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })) + ) +}) + +describe('bounded CLI request bodies', () => { + it('keeps small bodies in memory without creating the spill directory', async () => { + const root = await createTemporaryDirectory() + const tempDirectory = path.join(root, 'spill') + const request = createRequest(['{"value":', '42}'], { 'content-length': '12' }) + + const body = await readBoundedRequestBody(request, { + maxBytes: 64, + memoryThresholdBytes: 32, + tempDirectory, + requireContentLength: true + }) + + expect(body).toMatchObject({ kind: 'memory', size: 12 }) + expect(body.kind === 'memory' ? body.bytes.toString('utf8') : '').toBe('{"value":42}') + await expect(stat(tempDirectory)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('spills large bodies to a private file and removes it idempotently', async () => { + const root = await createTemporaryDirectory() + const tempDirectory = path.join(root, 'spill') + const request = createRequest(['12345', '67890'], { 'content-length': '10' }) + + const body = await readBoundedRequestBody(request, { + maxBytes: 16, + memoryThresholdBytes: 4, + tempDirectory, + requireContentLength: true + }) + + expect(body.kind).toBe('file') + if (body.kind !== 'file') throw new Error('Expected a spilled body') + expect(await readFile(body.path, 'utf8')).toBe('1234567890') + if (process.platform !== 'win32') { + expect((await stat(body.path)).mode & 0o777).toBe(0o600) + } + await body.cleanup() + await body.cleanup() + await expect(stat(body.path)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('rejects cumulative overflow and removes a partial spill', async () => { + const root = await createTemporaryDirectory() + const tempDirectory = path.join(root, 'spill') + const request = createRequest(['12345', '6789']) + + await expect( + readBoundedRequestBody(request, { + maxBytes: 8, + memoryThresholdBytes: 4, + tempDirectory, + requireContentLength: false + }) + ).rejects.toMatchObject>({ code: 'body_too_large', httpStatus: 413 }) + expect(await readdir(tempDirectory)).toEqual([]) + }) + + it('requires an exact singular Content-Length for RPC requests', async () => { + const root = await createTemporaryDirectory() + const options = { + maxBytes: 64, + memoryThresholdBytes: 64, + tempDirectory: path.join(root, 'spill'), + requireContentLength: true + } + + await expect(readBoundedRequestBody(createRequest(['{}']), options)).rejects.toMatchObject({ + code: 'invalid_request', + httpStatus: 411 + }) + await expect( + readBoundedRequestBody(createRequest(['{}'], { 'content-length': '3' }), options) + ).rejects.toMatchObject({ code: 'invalid_request' }) + }) + + it('rejects unsafe JSON keys and always releases the body', async () => { + const cleanup = vi.fn(async () => undefined) + + await expect( + parseBoundedJsonBody({ + kind: 'memory', + bytes: Buffer.from('{"nested":{"__proto__":true}}'), + size: 31, + cleanup + }) + ).rejects.toMatchObject({ code: 'invalid_request' }) + expect(cleanup).toHaveBeenCalledOnce() + }) +}) diff --git a/test/main/cli/descriptor.test.ts b/test/main/cli/descriptor.test.ts new file mode 100644 index 000000000..54589dec2 --- /dev/null +++ b/test/main/cli/descriptor.test.ts @@ -0,0 +1,127 @@ +import { createServer, type Server } from 'node:net' +import { lstat, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { LocalControlDescriptorSchema } from '@shared/contracts/localControl' +import { + cleanupLocalControlLayout, + createLocalControlLayout, + prepareLocalControlLayout, + writeLocalControlDescriptor +} from '@/cli/descriptor' + +const temporaryDirectories: string[] = [] + +async function createTemporaryDirectory(): Promise { + const directory = await mkdtemp(path.join(os.tmpdir(), 'deepchat-cli-descriptor-')) + temporaryDirectories.push(directory) + return directory +} + +async function listenOnUnixSocket(socketPath: string): Promise { + const server = createServer() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(socketPath, resolve) + }) + return server +} + +async function closeServer(server: Server): Promise { + await new Promise((resolve) => server.close(() => resolve())) +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })) + ) +}) + +describe.skipIf(process.platform === 'win32')('CLI discovery descriptor', () => { + it('writes a private validated descriptor and cleans matching state', async () => { + const userDataPath = await createTemporaryDirectory() + const layout = createLocalControlLayout(userDataPath, 'darwin') + await prepareLocalControlLayout(layout, 'darwin') + + const descriptor = await writeLocalControlDescriptor( + layout, + { + appVersion: '1.2.3', + endpoint: layout.endpoint, + pid: 42, + token: 'a'.repeat(43), + startedAt: 1 + }, + 'darwin' + ) + + expect( + LocalControlDescriptorSchema.parse(JSON.parse(await readFile(layout.descriptorPath, 'utf8'))) + ).toEqual(descriptor) + expect((await stat(layout.controlDirectory)).mode & 0o777).toBe(0o700) + expect((await stat(layout.descriptorPath)).mode & 0o777).toBe(0o600) + + await cleanupLocalControlLayout(layout, descriptor.token) + await expect(stat(layout.descriptorPath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('refuses a non-socket endpoint without deleting discovery state', async () => { + const userDataPath = await createTemporaryDirectory() + const layout = createLocalControlLayout(userDataPath, 'darwin') + if (layout.endpoint.kind !== 'unix') throw new Error('Expected a Unix endpoint') + await mkdir(layout.controlDirectory, { recursive: true }) + await mkdir(layout.endpointDirectory, { recursive: true }) + await writeFile(layout.descriptorPath, 'keep-me') + await writeFile(layout.endpoint.path, 'not-a-socket') + + await expect(prepareLocalControlLayout(layout, 'darwin')).rejects.toThrow( + 'Refusing to replace a non-socket' + ) + expect(await readFile(layout.descriptorPath, 'utf8')).toBe('keep-me') + }) + + it('cleans its socket when the descriptor is malformed', async () => { + const userDataPath = await createTemporaryDirectory() + const layout = createLocalControlLayout(userDataPath, 'darwin') + if (layout.endpoint.kind !== 'unix') throw new Error('Expected a Unix endpoint') + await prepareLocalControlLayout(layout, 'darwin') + const server = await listenOnUnixSocket(layout.endpoint.path) + await writeFile(layout.descriptorPath, '{broken-json') + + try { + await cleanupLocalControlLayout(layout, 'a'.repeat(43)) + await expect(lstat(layout.endpoint.path)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await readFile(layout.descriptorPath, 'utf8')).toBe('{broken-json') + } finally { + await closeServer(server) + } + }) + + it('does not remove an endpoint claimed by a different valid descriptor', async () => { + const userDataPath = await createTemporaryDirectory() + const layout = createLocalControlLayout(userDataPath, 'darwin') + if (layout.endpoint.kind !== 'unix') throw new Error('Expected a Unix endpoint') + await prepareLocalControlLayout(layout, 'darwin') + const server = await listenOnUnixSocket(layout.endpoint.path) + await writeLocalControlDescriptor( + layout, + { + appVersion: '1.2.3', + endpoint: layout.endpoint, + pid: 42, + token: 'b'.repeat(43), + startedAt: 1 + }, + 'darwin' + ) + + try { + await cleanupLocalControlLayout(layout, 'a'.repeat(43)) + expect((await lstat(layout.endpoint.path)).isSocket()).toBe(true) + expect((await stat(layout.descriptorPath)).isFile()).toBe(true) + } finally { + await closeServer(server) + } + }) +}) diff --git a/test/main/cli/server.test.ts b/test/main/cli/server.test.ts new file mode 100644 index 000000000..53b6d6010 --- /dev/null +++ b/test/main/cli/server.test.ts @@ -0,0 +1,294 @@ +import { request as httpRequest } from 'node:http' +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { DeepchatRouteName } from '@shared/contracts/routes' +import { + LOCAL_CONTROL_PROTOCOL_VERSION, + LOCAL_CONTROL_SCOPES, + LOCAL_CONTROL_SURFACE_VERSION, + LocalControlDescriptorSchema, + LocalControlRpcResponseSchema, + type LocalControlDescriptor, + type LocalControlRpcResponse, + type LocalControlScope +} from '@shared/contracts/localControl' +import { createCliRoutes } from '@/cli/routes' +import { CliServer, type AgentCliToken } from '@/cli/server' +import type { CliRouteCaller } from '@/routes/routeRegistry' + +type RpcResult = Readonly<{ + status: number + connection: string | undefined + body: LocalControlRpcResponse +}> + +const servers: CliServer[] = [] +const temporaryDirectories: string[] = [] + +async function createTemporaryDirectory(): Promise { + const directory = await mkdtemp(path.join(os.tmpdir(), 'deepchat-cli-server-')) + temporaryDirectories.push(directory) + return directory +} + +function rpcRequest( + descriptor: LocalControlDescriptor, + input: { + token?: string + id?: string + method?: string + params?: unknown + protocolVersion?: number + surfaceVersion?: number + }, + options: { includeContentLength?: boolean } = {} +): Promise { + const serialized = Buffer.from( + JSON.stringify({ + protocolVersion: input.protocolVersion ?? LOCAL_CONTROL_PROTOCOL_VERSION, + surfaceVersion: input.surfaceVersion ?? LOCAL_CONTROL_SURFACE_VERSION, + id: input.id ?? 'request-1', + method: input.method ?? 'cli.version', + params: input.params ?? {} + }) + ) + const headers: Record = { + authorization: `Bearer ${input.token ?? descriptor.token}`, + 'content-type': 'application/json' + } + if (options.includeContentLength !== false) headers['content-length'] = serialized.length + + return new Promise((resolve, reject) => { + const request = httpRequest( + { + socketPath: + descriptor.endpoint.kind === 'unix' ? descriptor.endpoint.path : descriptor.endpoint.name, + path: '/v1/rpc', + method: 'POST', + headers + }, + (response) => { + const chunks: Buffer[] = [] + response.on('data', (chunk: Buffer) => chunks.push(chunk)) + response.once('error', reject) + response.once('end', () => { + try { + resolve({ + status: response.statusCode ?? 0, + connection: + typeof response.headers.connection === 'string' + ? response.headers.connection + : undefined, + body: LocalControlRpcResponseSchema.parse( + JSON.parse(Buffer.concat(chunks).toString('utf8')) + ) + }) + } catch (error) { + reject(error) + } + }) + } + ) + request.once('error', reject) + if (options.includeContentLength === false) { + const midpoint = Math.max(1, Math.floor(serialized.length / 2)) + request.write(serialized.subarray(0, midpoint)) + request.end(serialized.subarray(midpoint)) + } else { + request.end(serialized) + } + }) +} + +async function createTestServer( + options: { + resolveAgentToken?: (token: string) => AgentCliToken | null + dispatchOutput?: (method: string) => unknown + } = {} +): Promise<{ + server: CliServer + descriptor: LocalControlDescriptor + dispatch: ReturnType +}> { + const userDataPath = await createTemporaryDirectory() + let server: CliServer + const routes = createCliRoutes({ + appVersion: '1.2.3', + getStatus: () => server.getStatus(), + hasTrustedRenderer: () => true + }) + const dispatch = vi.fn( + async (method: string, input: unknown, caller: CliRouteCaller): Promise => { + if (options.dispatchOutput) return options.dispatchOutput(method) + const route = routes.get(method as DeepchatRouteName) + if (!route) throw new Error(`Unknown test route: ${method}`) + return await route(input, { caller }) + } + ) + server = new CliServer({ + userDataPath, + appVersion: '1.2.3', + dispatch, + resolveAgentToken: options.resolveAgentToken, + log: { warn: vi.fn(), error: vi.fn() } + }) + servers.push(server) + const descriptor = await server.start() + return { server, descriptor, dispatch } +} + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => server.stop())) + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })) + ) +}) + +describe('CLI local transport', () => { + it('serializes a stop that races with startup', async () => { + const userDataPath = await createTemporaryDirectory() + const server = new CliServer({ + userDataPath, + appVersion: '1.2.3', + dispatch: async () => ({}) + }) + servers.push(server) + + const starting = server.start() + const stopping = server.stop() + await Promise.all([starting, stopping]) + + expect(server.getStatus().running).toBe(false) + await expect(stat(server.getDescriptorPath())).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('discovers, authenticates, dispatches, and cleans up the server', async () => { + const { server, descriptor, dispatch } = await createTestServer() + const descriptorPath = server.getDescriptorPath() + + expect( + LocalControlDescriptorSchema.parse(JSON.parse(await readFile(descriptorPath, 'utf8'))) + ).toEqual(descriptor) + + const response = await rpcRequest(descriptor, {}) + expect(response).toMatchObject({ + status: 200, + body: { + ok: true, + result: { + appVersion: '1.2.3', + protocolVersion: LOCAL_CONTROL_PROTOCOL_VERSION, + surfaceVersion: LOCAL_CONTROL_SURFACE_VERSION + } + } + }) + expect(dispatch).toHaveBeenCalledOnce() + expect(dispatch.mock.calls[0]?.[2]).toMatchObject({ + kind: 'cli', + principal: 'human', + scopes: LOCAL_CONTROL_SCOPES + }) + + await server.stop() + await expect(stat(descriptorPath)).rejects.toMatchObject({ code: 'ENOENT' }) + if (descriptor.endpoint.kind === 'unix') { + await expect(stat(descriptor.endpoint.path)).rejects.toMatchObject({ code: 'ENOENT' }) + } + }) + + it('fails closed on invalid authentication without dispatching', async () => { + const { descriptor, dispatch } = await createTestServer() + + const response = await rpcRequest(descriptor, { token: 'x'.repeat(43) }) + + expect(response).toMatchObject({ + status: 401, + connection: 'close', + body: { ok: false, error: { code: 'authentication_failed' } } + }) + expect(dispatch).not.toHaveBeenCalled() + }) + + it('enforces protocol versions and the explicit surface before dispatch', async () => { + const { descriptor, dispatch } = await createTestServer() + + const incompatible = await rpcRequest(descriptor, { protocolVersion: 2 }) + const hidden = await rpcRequest(descriptor, { method: 'settings.getSnapshot' }) + + expect(incompatible).toMatchObject({ + status: 409, + body: { ok: false, error: { code: 'unsupported_version' } } + }) + expect(hidden).toMatchObject({ + status: 404, + body: { ok: false, error: { code: 'not_found' } } + }) + expect(dispatch).not.toHaveBeenCalled() + }) + + it('reports invalid route output as an internal contract failure', async () => { + const { descriptor } = await createTestServer({ + dispatchOutput: () => ({ appVersion: '' }) + }) + + const response = await rpcRequest(descriptor, {}) + + expect(response).toMatchObject({ + status: 500, + body: { ok: false, error: { code: 'internal_error' } } + }) + }) + + it('requires Content-Length and never accepts implicit chunked RPC input', async () => { + const { descriptor, dispatch } = await createTestServer() + + const response = await rpcRequest(descriptor, {}, { includeContentLength: false }) + + expect(response).toMatchObject({ + status: 411, + body: { ok: false, error: { code: 'invalid_request' } } + }) + expect(dispatch).not.toHaveBeenCalled() + }) + + it('applies agent expiry and scopes independently of the bearer token', async () => { + let scopes: readonly LocalControlScope[] = ['models:read'] + let expiresAt = Date.now() - 1 + const agentToken = 'g'.repeat(43) + const { descriptor, dispatch } = await createTestServer({ + resolveAgentToken: (token) => + token === agentToken + ? { + conversationId: 'conversation-1', + expiresAt, + scopes + } + : null + }) + + const expired = await rpcRequest(descriptor, { token: agentToken }) + expiresAt = Date.now() + 60_000 + const denied = await rpcRequest(descriptor, { token: agentToken }) + scopes = ['system:read'] + const allowed = await rpcRequest(descriptor, { token: agentToken }) + + expect(expired).toMatchObject({ + status: 401, + body: { ok: false, error: { code: 'authentication_failed' } } + }) + expect(denied).toMatchObject({ + status: 403, + body: { ok: false, error: { code: 'permission_denied' } } + }) + expect(allowed).toMatchObject({ status: 200, body: { ok: true } }) + expect(dispatch).toHaveBeenCalledOnce() + expect(dispatch.mock.calls[0]?.[2]).toMatchObject({ + kind: 'cli', + principal: 'agent', + conversationId: 'conversation-1', + scopes: ['system:read'] + }) + }) +}) diff --git a/test/main/cli/surface.test.ts b/test/main/cli/surface.test.ts new file mode 100644 index 000000000..1033890a0 --- /dev/null +++ b/test/main/cli/surface.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { DEEPCHAT_ROUTE_CATALOG } from '@shared/contracts/routes' +import { CLI_SURFACE_V1, getCliSurfaceEntry, listCliSurfaceCapabilities } from '@/cli/surface' + +describe('CLI surface V1', () => { + it('contains only explicit canonical route contracts', () => { + const methods = Array.from(CLI_SURFACE_V1.keys()).sort() + + expect(methods).toEqual(['cli.capabilities', 'cli.doctor', 'cli.status', 'cli.version']) + for (const [method, entry] of CLI_SURFACE_V1) { + expect(entry.contract).toBe( + DEEPCHAT_ROUTE_CATALOG[method as keyof typeof DEEPCHAT_ROUTE_CATALOG] + ) + } + }) + + it('denies methods that are not explicitly listed', () => { + expect(getCliSurfaceEntry('settings.getSnapshot')).toBeUndefined() + expect(getCliSurfaceEntry('mcp.callTool')).toBeUndefined() + expect(getCliSurfaceEntry('databaseSecurity.disable')).toBeUndefined() + }) + + it('publishes stable sorted capability metadata', () => { + expect(listCliSurfaceCapabilities()).toEqual([ + expect.objectContaining({ method: 'cli.capabilities', effect: 'read' }), + expect.objectContaining({ method: 'cli.doctor', effect: 'read' }), + expect.objectContaining({ method: 'cli.status', effect: 'read' }), + expect.objectContaining({ method: 'cli.version', effect: 'read' }) + ]) + expect( + listCliSurfaceCapabilities().every( + (capability) => + capability.callers.join(',') === 'human,agent' && + capability.scopes.join(',') === 'system:read' && + capability.transport === 'rpc' && + capability.approval === 'never' + ) + ).toBe(true) + }) +}) From 61c7ade7d96190de3886ca1aefece2789bb52e5e Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 11:13:58 +0800 Subject: [PATCH 04/51] feat(cli): ship bundled diagnostic client --- docs/architecture/local-control-plane/spec.md | 19 +- .../architecture/local-control-plane/tasks.md | 6 +- electron-builder.yml | 4 + package.json | 3 +- scripts/build-cli.mjs | 84 +++++++ src/cli/args.ts | 163 ++++++++++++++ src/cli/discovery.ts | 190 ++++++++++++++++ src/cli/errors.ts | 60 +++++ src/cli/format.ts | 62 ++++++ src/cli/globals.d.ts | 1 + src/cli/index.ts | 36 +++ src/cli/run.ts | 191 ++++++++++++++++ src/cli/transport.ts | 192 ++++++++++++++++ src/main/cli/server.ts | 10 +- src/main/cli/surface.ts | 2 +- src/shared/contracts/common.ts | 66 +----- src/shared/contracts/contract.ts | 38 ++++ src/shared/contracts/json.ts | 24 ++ src/shared/contracts/localControl.ts | 16 +- src/shared/contracts/routes/cli.routes.ts | 2 +- test/main/cli/args.test.ts | 62 ++++++ test/main/cli/client.test.ts | 210 ++++++++++++++++++ test/main/cli/discovery.test.ts | 94 ++++++++ test/main/cli/errors.test.ts | 43 ++++ test/main/cli/transport.test.ts | 107 +++++++++ test/main/scripts/buildCli.test.ts | 53 +++++ tsconfig.node.json | 1 + 27 files changed, 1659 insertions(+), 80 deletions(-) create mode 100644 scripts/build-cli.mjs create mode 100644 src/cli/args.ts create mode 100644 src/cli/discovery.ts create mode 100644 src/cli/errors.ts create mode 100644 src/cli/format.ts create mode 100644 src/cli/globals.d.ts create mode 100644 src/cli/index.ts create mode 100644 src/cli/run.ts create mode 100644 src/cli/transport.ts create mode 100644 src/shared/contracts/contract.ts create mode 100644 src/shared/contracts/json.ts create mode 100644 test/main/cli/args.test.ts create mode 100644 test/main/cli/client.test.ts create mode 100644 test/main/cli/discovery.test.ts create mode 100644 test/main/cli/errors.test.ts create mode 100644 test/main/cli/transport.test.ts create mode 100644 test/main/scripts/buildCli.test.ts diff --git a/docs/architecture/local-control-plane/spec.md b/docs/architecture/local-control-plane/spec.md index 626896a5a..844c5aad5 100644 --- a/docs/architecture/local-control-plane/spec.md +++ b/docs/architecture/local-control-plane/spec.md @@ -128,8 +128,10 @@ approval scopes, and removes the descriptor/socket; mutable services and databas ### Endpoint -- POSIX: an application-owned Unix domain socket below the DeepChat user-data directory. After - bind, its mode is verified as `0600`. +- POSIX: an application-owned Unix domain socket below the DeepChat user-data directory. If that + would exceed the platform socket-path limit, main uses a deterministic per-user, per-profile + private runtime directory and keeps discovery in user data. After bind, socket type, ownership, + and `0600` mode are verified. - Windows: a per-start random named-pipe name. The descriptor is protected with an owner-only ACL; the random endpoint and bearer token provide defense in depth where Node does not expose a portable pipe-DACL API. @@ -521,12 +523,15 @@ Human-friendly output goes to stdout, diagnostics to stderr, and machine modes a - SIGINT cancels once, waits a bounded grace period, then exits; - no ANSI/progress UI appears in machine modes. -Exit codes are stable: success, usage, unavailable/version mismatch, authentication/authorization, -approval denied/timeout, domain failure, timeout/cancel, and internal/protocol failure are distinct. +Exit codes are stable: `0` success, `2` usage, `3` unavailable/version mismatch, `4` +authentication/authorization, `5` approval denied/timeout, `6` domain failure, `7` timeout/cancel, +and `8` internal/protocol failure. -The packaged CLI uses the bundled Node runtime and ships as an application resource. Installation is -opt-in and places a small launcher in the platform's user command location. It does not install an npm -package or copy credentials. Upgrades replace app-owned resources while keeping the launcher stable. +The packaged CLI source lives in `src/cli`; main-side transport adapters live in `src/main/cli`. +The built standalone entry and launchers use the bundled Node runtime and ship outside `app.asar` as +application resources. Installation is opt-in and places a small launcher in the platform's user +command location. It does not install an npm package or copy credentials. Upgrades replace app-owned +resources while keeping the launcher stable. ## Agent Token and Bundled Skill diff --git a/docs/architecture/local-control-plane/tasks.md b/docs/architecture/local-control-plane/tasks.md index 014af6137..e70d800b9 100644 --- a/docs/architecture/local-control-plane/tasks.md +++ b/docs/architecture/local-control-plane/tasks.md @@ -23,9 +23,9 @@ - [x] Implement atomic private descriptor creation, token rotation, and stale cleanup. - [x] Implement UDS/named-pipe HTTP server lifecycle and authentication. - [x] Implement fixed/chunked body bounds, spill-to-disk, abort handling, and cleanup. -- [ ] Implement the bundled thin CLI, two-token grammar, version negotiation, output modes, signals, +- [x] Implement the bundled thin CLI, two-token grammar, version negotiation, output modes, signals, fail-closed Agent-token selection, timeouts, and exit codes. -- [ ] Add descriptor, transport, auth, body-boundary, parser, and shutdown tests. +- [x] Add descriptor, transport, auth, body-boundary, parser, and shutdown tests. ## Compute and Artifacts @@ -73,7 +73,7 @@ ## Packaging and Agent Use -- [ ] Package the CLI with the bundled Node runtime on all supported targets. +- [x] Package the CLI with the bundled Node runtime on all supported targets. - [ ] Add opt-in, reversible platform launcher/PATH integration. - [ ] Add in-memory scoped Agent token issuance, expiry, revocation, and quotas. - [ ] Harden shell permission checks for redirection and compound syntax before Agent enablement. diff --git a/electron-builder.yml b/electron-builder.yml index 43558cd25..6a05fe9ab 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -13,6 +13,7 @@ files: - '!docs/*' - '!plugins/**' - '!out/main/lightOcrHelper.js' + - '!out/cli/**' - '!keys/*' - '!scripts/*' - '!.github/*' @@ -42,6 +43,9 @@ asarUnpack: - '**/node_modules/@arcships/light-ocr*/**/*' - '**/node_modules/@zerob13/nativekit/prebuilds/**/*' extraResources: + - from: ./out/cli/ + to: app.asar.unpacked/cli + filter: ['deepchat', 'deepchat.cmd', 'deepchat.mjs'] - from: ./runtime/ to: app.asar.unpacked/runtime filter: ['**/*'] diff --git a/package.json b/package.json index 7741a6d19..0215f522c 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,8 @@ "dev:trace": "cross-env DEEPCHAT_VUE_DEVTOOLS_OVERLAY=0 electron-vite dev --watch", "dev:inspect": "electron-vite dev --watch --inspect=9229", "dev:linux": "electron-vite dev --watch --noSandbox", - "build": "pnpm run typecheck && electron-vite build", + "build": "pnpm run typecheck && electron-vite build && pnpm run cli:build", + "cli:build": "node scripts/build-cli.mjs", "release:ff": "node scripts/release-fast-forward.mjs", "postinstall": "electron-builder install-app-deps", "hooks:install": "git config core.hooksPath .githooks", diff --git a/scripts/build-cli.mjs b/scripts/build-cli.mjs new file mode 100644 index 000000000..1f7440d2d --- /dev/null +++ b/scripts/build-cli.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node + +import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { build } from 'vite' + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)) +export const repositoryRoot = path.resolve(scriptDirectory, '..') +export const cliOutputDirectory = path.join(repositoryRoot, 'out', 'cli') + +export const POSIX_LAUNCHER = `#!/bin/sh +set -eu + +script_path=$0 +while [ -L "$script_path" ]; do + script_dir=$(CDPATH= cd -P -- "$(dirname -- "$script_path")" && pwd) + link_target=$(readlink "$script_path") + case "$link_target" in + /*) script_path=$link_target ;; + *) script_path=$script_dir/$link_target ;; + esac +done +script_dir=$(CDPATH= cd -P -- "$(dirname -- "$script_path")" && pwd) +exec "$script_dir/../runtime/node/bin/node" "$script_dir/deepchat.mjs" "$@" +` + +export const WINDOWS_LAUNCHER = `@echo off\r +"%~dp0..\\runtime\\node\\node.exe" "%~dp0deepchat.mjs" %*\r +` + +export async function buildCli(options = {}) { + const packageJson = JSON.parse( + await readFile(path.join(repositoryRoot, 'package.json'), 'utf8') + ) + const outDir = options.outDir ? path.resolve(options.outDir) : cliOutputDirectory + + await build({ + configFile: false, + root: repositoryRoot, + publicDir: false, + resolve: { + alias: { + '@shared': path.join(repositoryRoot, 'src', 'shared') + } + }, + define: { + __DEEPCHAT_CLI_VERSION__: JSON.stringify(packageJson.version) + }, + build: { + target: 'node24', + outDir, + emptyOutDir: true, + copyPublicDir: false, + minify: 'esbuild', + lib: { + entry: path.join(repositoryRoot, 'src', 'cli', 'index.ts'), + formats: ['es'] + }, + rollupOptions: { + external: [/^node:/], + output: { + format: 'es', + entryFileNames: 'deepchat.mjs', + inlineDynamicImports: true, + banner: '#!/usr/bin/env node' + } + } + }, + logLevel: options.logLevel ?? 'info' + }) + + await mkdir(outDir, { recursive: true }) + await writeFile(path.join(outDir, 'deepchat'), POSIX_LAUNCHER, { mode: 0o755 }) + await chmod(path.join(outDir, 'deepchat'), 0o755) + await writeFile(path.join(outDir, 'deepchat.cmd'), WINDOWS_LAUNCHER, 'utf8') +} + +if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) { + buildCli().catch((error) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + }) +} diff --git a/src/cli/args.ts b/src/cli/args.ts new file mode 100644 index 000000000..4bb1bda5f --- /dev/null +++ b/src/cli/args.ts @@ -0,0 +1,163 @@ +import { + cliCapabilitiesRoute, + cliDoctorRoute, + cliStatusRoute, + cliVersionRoute +} from '@shared/contracts/routes/cli.routes' +import { CliUsageError } from './errors' + +export const CLI_OUTPUT_ENV = 'DEEPCHAT_CLI_OUTPUT' +export const CLI_TIMEOUT_ENV = 'DEEPCHAT_CLI_TIMEOUT_MS' +export const DEFAULT_CLI_TIMEOUT_MS = 30_000 +export const MAX_CLI_TIMEOUT_MS = 30 * 60_000 + +export type CliOutputMode = 'text' | 'json' | 'jsonl' +export type CliRpcContract = + | typeof cliStatusRoute + | typeof cliVersionRoute + | typeof cliCapabilitiesRoute + | typeof cliDoctorRoute + +export type ParsedCliArguments = Readonly<{ + domain: string + verb: string + contract: CliRpcContract | null + outputMode: CliOutputMode + timeoutMs: number + helpRequested: boolean +}> + +const COMMANDS = new Map([ + ['system status', cliStatusRoute], + ['system version', cliVersionRoute], + ['system capabilities', cliCapabilitiesRoute], + ['system doctor', cliDoctorRoute] +]) + +function parseOutputMode(value: string | undefined): CliOutputMode { + if (value === undefined || value.trim() === '') return 'text' + const normalized = value.trim().toLowerCase() + if (normalized === 'text' || normalized === 'json' || normalized === 'jsonl') return normalized + throw new CliUsageError(`${CLI_OUTPUT_ENV} must be text, json, or jsonl`) +} + +export function inferCliOutputMode( + argv: readonly string[], + env: NodeJS.ProcessEnv = process.env +): CliOutputMode { + if (!argv[0] || !argv[1] || argv[0].startsWith('-') || argv[1].startsWith('-')) return 'text' + const explicit = argv.slice(2).find((argument) => argument === '--json' || argument === '--jsonl') + if (explicit) return explicit.slice(2) as CliOutputMode + try { + return parseOutputMode(env[CLI_OUTPUT_ENV]) + } catch { + return 'text' + } +} + +function parseTimeout(value: string, source: string): number { + if (!/^[1-9][0-9]*$/.test(value)) { + throw new CliUsageError(`${source} must be a positive integer in milliseconds`) + } + const timeoutMs = Number(value) + if (!Number.isSafeInteger(timeoutMs) || timeoutMs > MAX_CLI_TIMEOUT_MS) { + throw new CliUsageError(`${source} must not exceed ${MAX_CLI_TIMEOUT_MS}`) + } + return timeoutMs +} + +export function parseCliArguments( + argv: readonly string[], + env: NodeJS.ProcessEnv = process.env +): ParsedCliArguments { + const domain = argv[0] + const verb = argv[1] + if (!domain || !verb || domain.startsWith('-') || verb.startsWith('-')) { + throw new CliUsageError('Expected: deepchat [options]') + } + + const commandKey = `${domain} ${verb}` + const isHelpCommand = commandKey === 'help commands' + const contract = COMMANDS.get(commandKey) ?? null + if (!contract && !isHelpCommand) { + throw new CliUsageError(`Unknown command: deepchat ${domain} ${verb}`) + } + + let outputMode = parseOutputMode(env[CLI_OUTPUT_ENV]) + let explicitOutputMode: CliOutputMode | undefined + let timeoutMs = env[CLI_TIMEOUT_ENV] + ? parseTimeout(env[CLI_TIMEOUT_ENV], CLI_TIMEOUT_ENV) + : DEFAULT_CLI_TIMEOUT_MS + let timeoutSeen = false + let helpRequested = false + + for (let index = 2; index < argv.length; index += 1) { + const argument = argv[index] + if (argument === '--json' || argument === '--jsonl') { + const nextMode = argument.slice(2) as CliOutputMode + if (explicitOutputMode && explicitOutputMode !== nextMode) { + throw new CliUsageError('--json and --jsonl are mutually exclusive') + } + explicitOutputMode = nextMode + outputMode = nextMode + continue + } + if (argument === '--help') { + if (helpRequested) throw new CliUsageError('--help may be specified only once') + helpRequested = true + continue + } + if (argument === '--timeout') { + if (timeoutSeen) throw new CliUsageError('--timeout may be specified only once') + const value = argv[index + 1] + if (!value) throw new CliUsageError('Missing value for --timeout') + timeoutMs = parseTimeout(value, '--timeout') + timeoutSeen = true + index += 1 + continue + } + if (argument.startsWith('--timeout=')) { + if (timeoutSeen) throw new CliUsageError('--timeout may be specified only once') + timeoutMs = parseTimeout(argument.slice('--timeout='.length), '--timeout') + timeoutSeen = true + continue + } + throw new CliUsageError(`Unknown option after ${domain} ${verb}: ${argument}`) + } + + return { + domain, + verb, + contract, + outputMode, + timeoutMs, + helpRequested: helpRequested || isHelpCommand + } +} + +export function formatCliHelp(command?: Pick): string { + if (command && command.domain !== 'help') { + return [ + `Usage: deepchat ${command.domain} ${command.verb} [--json|--jsonl] [--timeout ]`, + '', + 'Global flags must follow the domain and verb.' + ].join('\n') + } + + return [ + 'Usage: deepchat [options]', + '', + 'Commands:', + ' system status Show local control-plane status', + ' system version Show app and protocol versions', + ' system capabilities List the exposed CLI surface', + ' system doctor Run local transport diagnostics', + ' help commands Show this help', + '', + 'Options (after domain and verb):', + ' --json Emit one JSON result envelope', + ' --jsonl Emit JSONL records', + ' --timeout Set request timeout', + ' --help Show command usage' + ].join('\n') +} diff --git a/src/cli/discovery.ts b/src/cli/discovery.ts new file mode 100644 index 000000000..6b270c008 --- /dev/null +++ b/src/cli/discovery.ts @@ -0,0 +1,190 @@ +import { lstat, readFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { + LOCAL_CONTROL_AGENT_TOKEN_ENV, + LOCAL_CONTROL_DESCRIPTOR_FILENAME, + LOCAL_CONTROL_PROTOCOL_VERSION, + LOCAL_CONTROL_SURFACE_VERSION, + LocalControlDescriptorSchema, + LocalControlTokenSchema, + type LocalControlDescriptor +} from '@shared/contracts/localControl' +import { CLI_EXIT_CODES, CliClientError } from './errors' + +const MAX_DESCRIPTOR_BYTES = 64 * 1024 +const EXPLICIT_PROFILE_ENV = 'DEEPCHAT_E2E_USER_DATA_DIR' +const MAX_POSIX_SOCKET_PATH_BYTES = 100 + +export type CliDiscoveryOptions = Readonly<{ + env?: NodeJS.ProcessEnv + platform?: NodeJS.Platform + homeDirectory?: string + processAlive?: (pid: number) => boolean +}> + +function resolveDefaultProfilePath( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform, + homeDirectory: string +): string { + if (platform === 'darwin') { + return path.join(homeDirectory, 'Library', 'Application Support', 'DeepChat') + } + if (platform === 'win32') { + return path.join(env.APPDATA ?? path.join(homeDirectory, 'AppData', 'Roaming'), 'DeepChat') + } + return path.join(env.XDG_CONFIG_HOME ?? path.join(homeDirectory, '.config'), 'DeepChat') +} + +export function resolveCliUserDataPath(options: CliDiscoveryOptions = {}): string { + const env = options.env ?? process.env + const explicitPath = env[EXPLICIT_PROFILE_ENV]?.trim() + if (explicitPath) return path.resolve(explicitPath) + return resolveDefaultProfilePath( + env, + options.platform ?? process.platform, + options.homeDirectory ?? os.homedir() + ) +} + +function defaultProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'EPERM') return true + if (code === 'ESRCH') return false + throw error + } +} + +function unavailable(message: string): CliClientError { + return new CliClientError('unavailable', message, CLI_EXIT_CODES.unavailable, true) +} + +export async function loadLocalControlDescriptor( + options: CliDiscoveryOptions = {} +): Promise { + const platform = options.platform ?? process.platform + const descriptorPath = path.join( + resolveCliUserDataPath(options), + 'local-control', + LOCAL_CONTROL_DESCRIPTOR_FILENAME + ) + + let descriptorStat + try { + descriptorStat = await lstat(descriptorPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw unavailable('DeepChat is not running or its CLI descriptor is unavailable') + } + throw unavailable(`Cannot inspect the DeepChat CLI descriptor: ${(error as Error).message}`) + } + if (!descriptorStat.isFile() || descriptorStat.isSymbolicLink()) { + throw unavailable('DeepChat CLI descriptor is not a regular file') + } + if (descriptorStat.size <= 0 || descriptorStat.size > MAX_DESCRIPTOR_BYTES) { + throw unavailable('DeepChat CLI descriptor has an invalid size') + } + if (platform !== 'win32') { + if (typeof process.getuid === 'function' && descriptorStat.uid !== process.getuid()) { + throw unavailable('DeepChat CLI descriptor is owned by another user') + } + if ((descriptorStat.mode & 0o077) !== 0) { + throw unavailable('DeepChat CLI descriptor permissions are not private') + } + } + + let serialized: string + try { + serialized = await readFile(descriptorPath, 'utf8') + } catch (error) { + throw unavailable(`Cannot read the DeepChat CLI descriptor: ${(error as Error).message}`) + } + + let raw: unknown + try { + raw = JSON.parse(serialized) as unknown + } catch { + throw unavailable('DeepChat CLI descriptor is not valid JSON') + } + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw unavailable('DeepChat CLI descriptor has an invalid shape') + } + const versioned = raw as Record + if ( + typeof versioned.protocolVersion !== 'number' || + typeof versioned.surfaceVersion !== 'number' + ) { + throw unavailable('DeepChat CLI descriptor has no valid protocol version') + } + if ( + versioned.protocolVersion !== LOCAL_CONTROL_PROTOCOL_VERSION || + versioned.surfaceVersion !== LOCAL_CONTROL_SURFACE_VERSION + ) { + throw new CliClientError( + 'unsupported_version', + `CLI requires protocol ${LOCAL_CONTROL_PROTOCOL_VERSION} and surface ${LOCAL_CONTROL_SURFACE_VERSION}; descriptor has protocol ${versioned.protocolVersion} and surface ${versioned.surfaceVersion}`, + CLI_EXIT_CODES.unavailable + ) + } + + const parsed = LocalControlDescriptorSchema.safeParse(raw) + if (!parsed.success) throw unavailable('DeepChat CLI descriptor failed validation') + const descriptor = parsed.data + if (!(options.processAlive ?? defaultProcessAlive)(descriptor.pid)) { + throw unavailable('DeepChat CLI descriptor points to a stopped process') + } + + if (platform === 'win32') { + if ( + descriptor.endpoint.kind !== 'pipe' || + !descriptor.endpoint.name.startsWith('\\\\.\\pipe\\') + ) { + throw unavailable('DeepChat CLI descriptor does not contain a local named pipe') + } + } else { + if ( + descriptor.endpoint.kind !== 'unix' || + !path.isAbsolute(descriptor.endpoint.path) || + Buffer.byteLength(descriptor.endpoint.path) > MAX_POSIX_SOCKET_PATH_BYTES + ) { + throw unavailable('DeepChat CLI descriptor does not contain a valid Unix socket') + } + try { + const socketStat = await lstat(descriptor.endpoint.path) + if (!socketStat.isSocket()) throw unavailable('DeepChat CLI endpoint is not a Unix socket') + if (typeof process.getuid === 'function' && socketStat.uid !== process.getuid()) { + throw unavailable('DeepChat CLI endpoint is owned by another user') + } + if ((socketStat.mode & 0o077) !== 0) { + throw unavailable('DeepChat CLI endpoint permissions are not private') + } + } catch (error) { + if (error instanceof CliClientError) throw error + throw unavailable('DeepChat CLI Unix socket is unavailable') + } + } + return descriptor +} + +export function selectLocalControlToken( + descriptor: LocalControlDescriptor, + env: NodeJS.ProcessEnv = process.env +): string { + if (Object.prototype.hasOwnProperty.call(env, LOCAL_CONTROL_AGENT_TOKEN_ENV)) { + const token = LocalControlTokenSchema.safeParse(env[LOCAL_CONTROL_AGENT_TOKEN_ENV]) + if (!token.success) { + throw new CliClientError( + 'authentication_failed', + `${LOCAL_CONTROL_AGENT_TOKEN_ENV} is present but invalid; refusing human-token fallback`, + CLI_EXIT_CODES.authorization + ) + } + return token.data + } + return descriptor.token +} diff --git a/src/cli/errors.ts b/src/cli/errors.ts new file mode 100644 index 000000000..1f1959b81 --- /dev/null +++ b/src/cli/errors.ts @@ -0,0 +1,60 @@ +import type { LocalControlError, LocalControlErrorCode } from '@shared/contracts/localControl' + +const MAX_CLI_ERROR_MESSAGE_LENGTH = 4_096 + +export const CLI_EXIT_CODES = { + success: 0, + usage: 2, + unavailable: 3, + authorization: 4, + approval: 5, + domain: 6, + cancelled: 7, + internal: 8 +} as const + +export type CliExitCode = (typeof CLI_EXIT_CODES)[keyof typeof CLI_EXIT_CODES] + +export class CliUsageError extends Error { + constructor(message: string) { + super(message) + this.name = 'CliUsageError' + } +} + +export class CliClientError extends Error { + constructor( + readonly code: LocalControlErrorCode, + message: string, + readonly exitCode: CliExitCode, + readonly retriable = false + ) { + super( + (message.length > 0 ? message : 'CLI request failed').slice(0, MAX_CLI_ERROR_MESSAGE_LENGTH) + ) + this.name = 'CliClientError' + } +} + +export function exitCodeForRemoteError(error: LocalControlError): CliExitCode { + switch (error.code) { + case 'authentication_failed': + case 'permission_denied': + return CLI_EXIT_CODES.authorization + case 'approval_denied': + case 'approval_timeout': + return CLI_EXIT_CODES.approval + case 'cancelled': + case 'timeout': + return CLI_EXIT_CODES.cancelled + case 'unsupported_version': + case 'unavailable': + return CLI_EXIT_CODES.unavailable + case 'invalid_request': + return CLI_EXIT_CODES.usage + case 'internal_error': + return CLI_EXIT_CODES.internal + default: + return CLI_EXIT_CODES.domain + } +} diff --git a/src/cli/format.ts b/src/cli/format.ts new file mode 100644 index 000000000..eaf48abfc --- /dev/null +++ b/src/cli/format.ts @@ -0,0 +1,62 @@ +import type { JsonValue } from '@shared/contracts/json' +import type { LocalControlRpcResponse } from '@shared/contracts/localControl' +import type { CliRpcContract } from './args' +import { CLI_VERSION } from './transport' + +function formatDuration(milliseconds: number): string { + if (milliseconds < 1_000) return `${milliseconds}ms` + const seconds = Math.floor(milliseconds / 1_000) + const hours = Math.floor(seconds / 3_600) + const minutes = Math.floor((seconds % 3_600) / 60) + const remainder = seconds % 60 + return [hours > 0 ? `${hours}h` : '', minutes > 0 ? `${minutes}m` : '', `${remainder}s`] + .filter(Boolean) + .join(' ') +} + +export function formatHumanResult(contract: CliRpcContract, value: JsonValue): string { + switch (contract.name) { + case 'cli.status': { + const result = contract.output.parse(value) + return [ + result.running ? 'DeepChat is running' : 'DeepChat is stopped', + `PID: ${result.pid}`, + `Uptime: ${formatDuration(result.uptimeMs)}`, + `Endpoint: ${result.endpointKind}`, + `Connections: ${result.activeConnections}`, + `Pending requests: ${result.pendingRequests}` + ].join('\n') + } + case 'cli.version': { + const result = contract.output.parse(value) + return [ + `DeepChat ${result.appVersion}`, + `CLI ${CLI_VERSION}`, + `Protocol ${result.protocolVersion}, surface ${result.surfaceVersion}` + ].join('\n') + } + case 'cli.capabilities': { + const result = contract.output.parse(value) + return [ + `CLI surface ${result.surfaceVersion} (${result.capabilities.length} methods)`, + ...result.capabilities.map( + (capability) => + `${capability.method} ${capability.effect} ${capability.callers.join(',')} ${capability.transport}` + ) + ].join('\n') + } + case 'cli.doctor': { + const result = contract.output.parse(value) + return [ + `DeepChat CLI doctor: ${result.healthy ? 'healthy' : 'unhealthy'}`, + ...result.checks.map( + (check) => `[${check.status.toUpperCase()}] ${check.id}: ${check.message}` + ) + ].join('\n') + } + } +} + +export function serializeMachineResponse(response: LocalControlRpcResponse): string { + return `${JSON.stringify(response)}\n` +} diff --git a/src/cli/globals.d.ts b/src/cli/globals.d.ts new file mode 100644 index 000000000..d1fb96be1 --- /dev/null +++ b/src/cli/globals.d.ts @@ -0,0 +1 @@ +declare const __DEEPCHAT_CLI_VERSION__: string diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 000000000..6cc83fcf5 --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,36 @@ +import { realpathSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { runCli } from './run' + +export { parseCliArguments, formatCliHelp } from './args' +export { loadLocalControlDescriptor, resolveCliUserDataPath } from './discovery' +export { CLI_EXIT_CODES } from './errors' +export { runCli } from './run' +export { CLI_VERSION, invokeLocalControlRpc } from './transport' + +function ignoreBrokenPipe(stream: NodeJS.WriteStream): void { + stream.on('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'EPIPE') process.exit(0) + process.exit(8) + }) +} + +function isDirectExecution(): boolean { + if (!process.argv[1]) return false + try { + return ( + realpathSync(path.resolve(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url)) + ) + } catch { + return false + } +} + +if (isDirectExecution()) { + ignoreBrokenPipe(process.stdout) + ignoreBrokenPipe(process.stderr) + void runCli(process.argv.slice(2)).then((exitCode) => { + process.exitCode = exitCode + }) +} diff --git a/src/cli/run.ts b/src/cli/run.ts new file mode 100644 index 000000000..db79fff33 --- /dev/null +++ b/src/cli/run.ts @@ -0,0 +1,191 @@ +import { randomUUID } from 'node:crypto' +import { + createLocalControlFailure, + type LocalControlDescriptor, + type LocalControlRpcResponse +} from '@shared/contracts/localControl' +import { parseCliArguments, formatCliHelp, inferCliOutputMode, type CliOutputMode } from './args' +import { + loadLocalControlDescriptor, + selectLocalControlToken, + type CliDiscoveryOptions +} from './discovery' +import { + CLI_EXIT_CODES, + CliClientError, + CliUsageError, + exitCodeForRemoteError, + type CliExitCode +} from './errors' +import { formatHumanResult, serializeMachineResponse } from './format' +import { invokeLocalControlRpc, type CliRpcInvocation } from './transport' + +const SIGNAL_GRACE_MS = 1_000 + +type WritableOutput = Pick +type SignalHost = Pick + +export type CliRunDependencies = Readonly<{ + env?: NodeJS.ProcessEnv + discovery?: Omit + stdout?: WritableOutput + stderr?: WritableOutput + signalHost?: SignalHost + randomId?: () => string + loadDescriptor?: (options: CliDiscoveryOptions) => Promise + invokeRpc?: (invocation: CliRpcInvocation) => Promise + forceExit?: (code: number) => void +}> + +function writeText(output: WritableOutput, value: string): void { + output.write(value.endsWith('\n') ? value : `${value}\n`) +} + +function writeClientError( + error: CliClientError, + outputMode: CliOutputMode, + requestId: string, + stdout: WritableOutput, + stderr: WritableOutput +): void { + if (outputMode === 'text') { + writeText(stderr, `${error.code}: ${error.message}`) + return + } + writeText( + stdout, + serializeMachineResponse( + createLocalControlFailure(requestId, { + code: error.code, + message: error.message, + retriable: error.retriable + }) + ) + ) +} + +export async function runCli( + argv: readonly string[], + dependencies: CliRunDependencies = {} +): Promise { + const env = dependencies.env ?? process.env + const stdout = dependencies.stdout ?? process.stdout + const stderr = dependencies.stderr ?? process.stderr + const signalHost = dependencies.signalHost ?? process + const requestId = (dependencies.randomId ?? randomUUID)() + + let parsed + try { + parsed = parseCliArguments(argv, env) + } catch (error) { + const message = error instanceof CliUsageError ? error.message : 'Invalid CLI arguments' + const outputMode = inferCliOutputMode(argv, env) + if (outputMode === 'text') { + writeText(stderr, message) + writeText(stderr, 'Run: deepchat help commands') + } else { + writeClientError( + new CliClientError('invalid_request', message, CLI_EXIT_CODES.usage), + outputMode, + requestId, + stdout, + stderr + ) + } + return CLI_EXIT_CODES.usage + } + + if (parsed.helpRequested) { + writeText(stdout, formatCliHelp(parsed)) + return CLI_EXIT_CODES.success + } + if (!parsed.contract) { + writeText(stderr, 'CLI command is not implemented') + return CLI_EXIT_CODES.internal + } + + const controller = new AbortController() + let forcedExitTimer: NodeJS.Timeout | undefined + let timeout: NodeJS.Timeout | undefined + let signalCount = 0 + const forceExit = dependencies.forceExit ?? ((code: number) => process.exit(code)) + const abortWith = (error: CliClientError) => { + if (!controller.signal.aborted) controller.abort(error) + } + const onSignal = () => { + signalCount += 1 + if (signalCount > 1) { + forceExit(CLI_EXIT_CODES.cancelled) + return + } + abortWith( + new CliClientError('cancelled', 'CLI request was interrupted', CLI_EXIT_CODES.cancelled) + ) + forcedExitTimer = setTimeout(() => forceExit(CLI_EXIT_CODES.cancelled), SIGNAL_GRACE_MS) + } + + signalHost.on('SIGINT', onSignal) + signalHost.on('SIGTERM', onSignal) + timeout = setTimeout(() => { + abortWith( + new CliClientError('timeout', 'CLI request timed out', CLI_EXIT_CODES.cancelled, true) + ) + }, parsed.timeoutMs) + + try { + const descriptor = await (dependencies.loadDescriptor ?? loadLocalControlDescriptor)({ + ...dependencies.discovery, + env + }) + if (controller.signal.aborted) throw controller.signal.reason + const token = selectLocalControlToken(descriptor, env) + const response = await (dependencies.invokeRpc ?? invokeLocalControlRpc)({ + descriptor, + token, + id: requestId, + method: parsed.contract.name, + params: {}, + signal: controller.signal + }) + + if (!response.ok) { + if (parsed.outputMode === 'text') { + writeText(stderr, `${response.error.code}: ${response.error.message}`) + } else { + writeText(stdout, serializeMachineResponse(response)) + } + return exitCodeForRemoteError(response.error) + } + + const result = parsed.contract.output.safeParse(response.result) + if (!result.success) { + throw new CliClientError( + 'internal_error', + 'DeepChat result did not match the command contract', + CLI_EXIT_CODES.internal + ) + } + if (parsed.outputMode === 'text') { + writeText(stdout, formatHumanResult(parsed.contract, response.result)) + } else { + writeText(stdout, serializeMachineResponse(response)) + } + return CLI_EXIT_CODES.success + } catch (error) { + const clientError = + error instanceof CliClientError + ? error + : new CliClientError( + 'internal_error', + error instanceof Error ? error.message : 'Unexpected CLI failure', + CLI_EXIT_CODES.internal + ) + writeClientError(clientError, parsed.outputMode, requestId, stdout, stderr) + return clientError.exitCode + } finally { + if (timeout) clearTimeout(timeout) + if (forcedExitTimer) clearTimeout(forcedExitTimer) + signalHost.off('SIGINT', onSignal) + signalHost.off('SIGTERM', onSignal) + } +} diff --git a/src/cli/transport.ts b/src/cli/transport.ts new file mode 100644 index 000000000..4a41024c2 --- /dev/null +++ b/src/cli/transport.ts @@ -0,0 +1,192 @@ +import { request as httpRequest, type IncomingHttpHeaders } from 'node:http' +import { + LOCAL_CONTROL_RPC_PATH, + LocalControlRpcRequestSchema, + LocalControlRpcResponseSchema, + type LocalControlDescriptor, + type LocalControlRpcResponse +} from '@shared/contracts/localControl' +import type { JsonValue } from '@shared/contracts/json' +import { CLI_EXIT_CODES, CliClientError } from './errors' + +const MAX_RESPONSE_BYTES = 16 * 1024 * 1024 + +export const CLI_VERSION = + typeof __DEEPCHAT_CLI_VERSION__ === 'string' ? __DEEPCHAT_CLI_VERSION__ : 'development' + +export type CliRpcInvocation = Readonly<{ + descriptor: LocalControlDescriptor + token: string + id: string + method: string + params: JsonValue + signal: AbortSignal +}> + +function transportFailure(message: string, retriable = true): CliClientError { + return new CliClientError('unavailable', message, CLI_EXIT_CODES.unavailable, retriable) +} + +function protocolFailure(message: string): CliClientError { + return new CliClientError('internal_error', message, CLI_EXIT_CODES.internal) +} + +function declaredResponseLength( + headers: IncomingHttpHeaders, + distinctValues: readonly string[] | undefined +): number | null { + if (distinctValues && distinctValues.length !== 1) { + throw protocolFailure('DeepChat returned multiple Content-Length headers') + } + const raw = distinctValues?.[0] ?? headers['content-length'] + if (raw === undefined) return null + if (Array.isArray(raw) || !/^(0|[1-9][0-9]*)$/.test(raw)) { + throw protocolFailure('DeepChat returned an invalid Content-Length') + } + const length = Number(raw) + if (!Number.isSafeInteger(length) || length > MAX_RESPONSE_BYTES) { + throw protocolFailure('DeepChat response exceeds the CLI byte limit') + } + return length +} + +function abortReason(signal: AbortSignal): Error { + return signal.reason instanceof Error + ? signal.reason + : new CliClientError('cancelled', 'CLI request was cancelled', CLI_EXIT_CODES.cancelled) +} + +export async function invokeLocalControlRpc( + invocation: CliRpcInvocation +): Promise { + if (invocation.signal.aborted) throw abortReason(invocation.signal) + const body = Buffer.from( + JSON.stringify( + LocalControlRpcRequestSchema.parse({ + protocolVersion: invocation.descriptor.protocolVersion, + surfaceVersion: invocation.descriptor.surfaceVersion, + id: invocation.id, + method: invocation.method, + params: invocation.params + }) + ), + 'utf8' + ) + + return await new Promise((resolve, reject) => { + let settled = false + const finish = (callback: () => void) => { + if (settled) return + settled = true + callback() + } + const request = httpRequest({ + socketPath: + invocation.descriptor.endpoint.kind === 'unix' + ? invocation.descriptor.endpoint.path + : invocation.descriptor.endpoint.name, + path: LOCAL_CONTROL_RPC_PATH, + method: 'POST', + agent: false, + signal: invocation.signal, + headers: { + authorization: `Bearer ${invocation.token}`, + 'content-type': 'application/json', + 'content-length': body.length, + connection: 'close', + 'user-agent': `DeepChat-CLI/${CLI_VERSION}` + } + }) + + request.once('response', (response) => { + const contentTypes = response.headersDistinct['content-type'] + const contentType = contentTypes?.[0] ?? response.headers['content-type'] + const [mediaType, ...parameters] = + typeof contentType === 'string' + ? contentType.split(';').map((part) => part.trim().toLowerCase()) + : [] + if ( + (contentTypes && contentTypes.length !== 1) || + mediaType !== 'application/json' || + !parameters.every((parameter) => parameter === 'charset=utf-8') + ) { + response.resume() + finish(() => reject(protocolFailure('DeepChat returned a non-JSON response'))) + return + } + if (response.headers['content-encoding'] !== undefined) { + response.resume() + finish(() => reject(protocolFailure('Compressed local responses are not supported'))) + return + } + + let expectedLength: number | null + try { + expectedLength = declaredResponseLength( + response.headers, + response.headersDistinct['content-length'] + ) + } catch (error) { + response.destroy() + finish(() => reject(error)) + return + } + + const chunks: Buffer[] = [] + let size = 0 + response.on('data', (rawChunk: Buffer | string) => { + if (settled) return + const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk) + size += chunk.length + if (size > MAX_RESPONSE_BYTES) { + response.destroy() + finish(() => reject(protocolFailure('DeepChat response exceeds the CLI byte limit'))) + return + } + chunks.push(chunk) + }) + response.once('error', (error) => finish(() => reject(transportFailure(error.message)))) + response.once('end', () => { + if (settled) return + if (expectedLength !== null && expectedLength !== size) { + finish(() => reject(protocolFailure('DeepChat response length did not match'))) + return + } + try { + const parsed = LocalControlRpcResponseSchema.parse( + JSON.parse(Buffer.concat(chunks, size).toString('utf8')) + ) + const isHttpSuccess = + (response.statusCode ?? 0) >= 200 && (response.statusCode ?? 0) < 300 + if (parsed.ok !== isHttpSuccess) { + throw protocolFailure('DeepChat HTTP status and response envelope disagree') + } + if (parsed.id !== invocation.id && !(!parsed.ok && parsed.id === 'unknown')) { + throw protocolFailure('DeepChat response ID did not match the request') + } + finish(() => resolve(parsed)) + } catch (error) { + finish(() => + reject( + error instanceof CliClientError + ? error + : protocolFailure('DeepChat returned an invalid response envelope') + ) + ) + } + }) + }) + request.once('error', (error: NodeJS.ErrnoException) => { + if (invocation.signal.aborted) { + finish(() => reject(abortReason(invocation.signal))) + return + } + const message = + error.code === 'ENOENT' || error.code === 'ECONNREFUSED' || error.code === 'EPIPE' + ? 'DeepChat local control server is unavailable' + : `Cannot connect to DeepChat: ${error.message}` + finish(() => reject(transportFailure(message))) + }) + request.end(body) + }) +} diff --git a/src/main/cli/server.ts b/src/main/cli/server.ts index 8c033b320..a951db664 100644 --- a/src/main/cli/server.ts +++ b/src/main/cli/server.ts @@ -7,9 +7,11 @@ import { JsonValueSchema, TimestampMsSchema, type JsonValue } from '@shared/cont import { LOCAL_CONTROL_DESCRIPTOR_FILENAME, LOCAL_CONTROL_PROTOCOL_VERSION, + LOCAL_CONTROL_RPC_PATH, LOCAL_CONTROL_SCOPES, LOCAL_CONTROL_SURFACE_VERSION, LocalControlScopesSchema, + LocalControlTokenSchema, LocalControlRpcRequestSchema, createLocalControlFailure, createLocalControlSuccess, @@ -74,8 +76,10 @@ function tokensEqual(left: string, right: string): boolean { function readBearerToken(request: IncomingMessage): string | null { const authorization = request.headers.authorization if (typeof authorization !== 'string') return null - const match = /^Bearer ([A-Za-z0-9_-]{43,256})$/.exec(authorization) - return match?.[1] ?? null + const match = /^Bearer (\S+)$/.exec(authorization) + if (!match) return null + const token = LocalControlTokenSchema.safeParse(match[1]) + return token.success ? token.data : null } function requestContentTypeIsJson(request: IncomingMessage): boolean { @@ -378,7 +382,7 @@ export class CliServer { response.setHeader('X-Content-Type-Options', 'nosniff') const connectionId = this.connectionIds.get(request.socket) ?? randomUUID() - if (request.method !== 'POST' || request.url !== '/v1/rpc') { + if (request.method !== 'POST' || request.url !== LOCAL_CONTROL_RPC_PATH) { this.sendFailure( response, 404, diff --git a/src/main/cli/surface.ts b/src/main/cli/surface.ts index 5634ff7a8..24cc6cfeb 100644 --- a/src/main/cli/surface.ts +++ b/src/main/cli/surface.ts @@ -1,4 +1,4 @@ -import type { RouteContract } from '@shared/contracts/common' +import type { RouteContract } from '@shared/contracts/contract' import { cliCapabilitiesRoute, cliDoctorRoute, diff --git a/src/shared/contracts/common.ts b/src/shared/contracts/common.ts index 0d937ecad..f27605d8d 100644 --- a/src/shared/contracts/common.ts +++ b/src/shared/contracts/common.ts @@ -30,20 +30,18 @@ import { } from '../types/attachment' import { isValidDocumentOcrTextPageSpans } from '../utils/documentOcrText' import { LiveDelegationSubagentContextSchema } from '../orchestration/liveDelegation' +import { JsonValueSchema, TimestampMsSchema } from './json' -export type JsonValue = - | string - | number - | boolean - | null - | JsonValue[] - | { - [key: string]: JsonValue - } +export { + defineEventContract, + defineRouteContract, + type EventContract, + type RouteContract +} from './contract' +export { JsonValueSchema, TimestampMsSchema, type JsonValue } from './json' export const EntityIdSchema = z.string().min(1) export const SubmissionIdSchema = z.string().min(1).max(128) -export const TimestampMsSchema = z.number().int().nonnegative() // A monotonically increasing state token. Unlike TimestampMsSchema, this is not // tied to wall-clock time and is safe for ordered snapshot/event application. @@ -57,17 +55,6 @@ export const ToolCallImagePreviewSchema = z.object({ source: z.enum(['tool_output', 'file_read', 'screenshot', 'mcp_image']) }) -export const JsonValueSchema: z.ZodType = z.lazy(() => - z.union([ - z.string(), - z.number(), - z.boolean(), - z.null(), - z.array(JsonValueSchema), - z.record(z.string(), JsonValueSchema) - ]) -) - export const FileMetadataValueSchema = z.union([JsonValueSchema, z.date()]) export const ImageGenerationOptionsSchema = z @@ -606,40 +593,3 @@ export const AssistantMessageBlockSchema = z.object({ extra: z.record(z.string(), JsonValueSchema).optional(), action_type: z.enum(['tool_call_permission', 'question_request', 'rate_limit']).optional() }) - -export interface RouteContract< - Name extends string = string, - InputSchema extends z.ZodTypeAny = z.ZodTypeAny, - OutputSchema extends z.ZodTypeAny = z.ZodTypeAny -> { - name: Name - input: InputSchema - output: OutputSchema -} - -export interface EventContract< - Name extends string = string, - PayloadSchema extends z.ZodTypeAny = z.ZodTypeAny -> { - name: Name - payload: PayloadSchema -} - -export function defineRouteContract< - const Name extends string, - InputSchema extends z.ZodTypeAny, - OutputSchema extends z.ZodTypeAny ->(contract: { - name: Name - input: InputSchema - output: OutputSchema -}): RouteContract { - return contract -} - -export function defineEventContract< - const Name extends string, - PayloadSchema extends z.ZodTypeAny ->(contract: { name: Name; payload: PayloadSchema }): EventContract { - return contract -} diff --git a/src/shared/contracts/contract.ts b/src/shared/contracts/contract.ts new file mode 100644 index 000000000..4ba0f2680 --- /dev/null +++ b/src/shared/contracts/contract.ts @@ -0,0 +1,38 @@ +import type { z } from 'zod' + +export interface RouteContract< + Name extends string = string, + InputSchema extends z.ZodTypeAny = z.ZodTypeAny, + OutputSchema extends z.ZodTypeAny = z.ZodTypeAny +> { + name: Name + input: InputSchema + output: OutputSchema +} + +export interface EventContract< + Name extends string = string, + PayloadSchema extends z.ZodTypeAny = z.ZodTypeAny +> { + name: Name + payload: PayloadSchema +} + +export function defineRouteContract< + const Name extends string, + InputSchema extends z.ZodTypeAny, + OutputSchema extends z.ZodTypeAny +>(contract: { + name: Name + input: InputSchema + output: OutputSchema +}): RouteContract { + return contract +} + +export function defineEventContract< + const Name extends string, + PayloadSchema extends z.ZodTypeAny +>(contract: { name: Name; payload: PayloadSchema }): EventContract { + return contract +} diff --git a/src/shared/contracts/json.ts b/src/shared/contracts/json.ts new file mode 100644 index 000000000..7b3a26ffb --- /dev/null +++ b/src/shared/contracts/json.ts @@ -0,0 +1,24 @@ +import { z } from 'zod' + +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { + [key: string]: JsonValue + } + +export const JsonValueSchema: z.ZodType = z.lazy(() => + z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), + z.array(JsonValueSchema), + z.record(z.string(), JsonValueSchema) + ]) +) + +export const TimestampMsSchema = z.number().int().nonnegative() diff --git a/src/shared/contracts/localControl.ts b/src/shared/contracts/localControl.ts index e2e8562f6..9fdcbb37a 100644 --- a/src/shared/contracts/localControl.ts +++ b/src/shared/contracts/localControl.ts @@ -1,9 +1,11 @@ import { z } from 'zod' -import { JsonValueSchema, TimestampMsSchema, type JsonValue } from './common' +import { JsonValueSchema, TimestampMsSchema, type JsonValue } from './json' export const LOCAL_CONTROL_PROTOCOL_VERSION = 1 as const export const LOCAL_CONTROL_SURFACE_VERSION = 1 as const export const LOCAL_CONTROL_DESCRIPTOR_FILENAME = 'local-control.json' +export const LOCAL_CONTROL_RPC_PATH = '/v1/rpc' +export const LOCAL_CONTROL_AGENT_TOKEN_ENV = 'DEEPCHAT_CLI_AGENT_TOKEN' export const LOCAL_CONTROL_EFFECTS = [ 'read', @@ -63,6 +65,12 @@ export const LocalControlScopesSchema = z export const LocalControlPrincipalSchema = z.enum(['human', 'agent']) +export const LocalControlTokenSchema = z + .string() + .min(43) + .max(256) + .regex(/^[A-Za-z0-9_-]+$/) + export const LocalControlEndpointSchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('unix'), @@ -93,11 +101,7 @@ export const LocalControlDescriptorSchema = z appVersion: z.string().min(1).max(128), endpoint: LocalControlEndpointSchema, pid: z.number().int().positive().max(2_147_483_647), - token: z - .string() - .min(43) - .max(256) - .regex(/^[A-Za-z0-9_-]+$/), + token: LocalControlTokenSchema, startedAt: TimestampMsSchema }) .strict() diff --git a/src/shared/contracts/routes/cli.routes.ts b/src/shared/contracts/routes/cli.routes.ts index 51b2b1d59..42276845b 100644 --- a/src/shared/contracts/routes/cli.routes.ts +++ b/src/shared/contracts/routes/cli.routes.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { defineRouteContract } from '../common' +import { defineRouteContract } from '../contract' import { LOCAL_CONTROL_PROTOCOL_VERSION, LOCAL_CONTROL_SURFACE_VERSION, diff --git a/test/main/cli/args.test.ts b/test/main/cli/args.test.ts new file mode 100644 index 000000000..9b6fbea6a --- /dev/null +++ b/test/main/cli/args.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { CLI_OUTPUT_ENV, CLI_TIMEOUT_ENV, parseCliArguments } from '../../../src/cli/args' + +describe('CLI argument grammar', () => { + it('maps the two-token command prefix to a canonical route', () => { + const parsed = parseCliArguments(['system', 'status'], {}) + + expect(parsed).toMatchObject({ + domain: 'system', + verb: 'status', + outputMode: 'text', + timeoutMs: 30_000, + helpRequested: false + }) + expect(parsed.contract?.name).toBe('cli.status') + }) + + it('accepts global flags only after the domain and verb', () => { + expect(parseCliArguments(['system', 'doctor', '--jsonl', '--timeout=2500'], {})).toMatchObject({ + outputMode: 'jsonl', + timeoutMs: 2_500 + }) + + expect(() => parseCliArguments(['--json', 'system', 'doctor'], {})).toThrow( + 'deepchat ' + ) + expect(() => parseCliArguments(['system', '--json', 'doctor'], {})).toThrow( + 'deepchat ' + ) + }) + + it('uses validated environment defaults and explicit output override', () => { + expect( + parseCliArguments(['system', 'version', '--json'], { + [CLI_OUTPUT_ENV]: 'text', + [CLI_TIMEOUT_ENV]: '9000' + }) + ).toMatchObject({ outputMode: 'json', timeoutMs: 9_000 }) + + expect(() => parseCliArguments(['system', 'version'], { [CLI_OUTPUT_ENV]: 'xml' })).toThrow( + `${CLI_OUTPUT_ENV} must be` + ) + }) + + it('rejects ambiguous and unbounded options', () => { + expect(() => parseCliArguments(['system', 'status', '--json', '--jsonl'], {})).toThrow( + 'mutually exclusive' + ) + expect(() => parseCliArguments(['system', 'status', '--timeout', '1800001'], {})).toThrow( + 'must not exceed' + ) + expect(() => parseCliArguments(['system', 'status', 'extra'], {})).toThrow('Unknown option') + }) + + it('keeps help inside the two-token grammar', () => { + expect(parseCliArguments(['help', 'commands'], {})).toMatchObject({ + contract: null, + helpRequested: true + }) + expect(() => parseCliArguments(['--help'], {})).toThrow('deepchat ') + }) +}) diff --git a/test/main/cli/client.test.ts b/test/main/cli/client.test.ts new file mode 100644 index 000000000..1408da535 --- /dev/null +++ b/test/main/cli/client.test.ts @@ -0,0 +1,210 @@ +import { EventEmitter } from 'node:events' +import { mkdtemp, rm } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { DeepchatRouteName } from '@shared/contracts/routes' +import { + LOCAL_CONTROL_AGENT_TOKEN_ENV, + LocalControlRpcResponseSchema +} from '@shared/contracts/localControl' +import { createCliRoutes } from '@/cli/routes' +import { CliServer } from '@/cli/server' +import type { CliRouteCaller } from '@/routes/routeRegistry' +import { runCli } from '../../../src/cli/run' + +const servers: CliServer[] = [] +const temporaryDirectories: string[] = [] + +function captureOutput(): { stream: NodeJS.WriteStream; read(): string } { + let value = '' + return { + stream: { + write: (chunk: string | Uint8Array) => { + value += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8') + return true + } + } as NodeJS.WriteStream, + read: () => value + } +} + +async function createClientServer(options: { hang?: boolean } = {}): Promise<{ + userDataPath: string + dispatch: ReturnType +}> { + const userDataPath = await mkdtemp(path.join(os.tmpdir(), 'deepchat-cli-client-')) + temporaryDirectories.push(userDataPath) + let server: CliServer + const routes = createCliRoutes({ + appVersion: '9.8.7', + getStatus: () => server.getStatus(), + hasTrustedRenderer: () => true + }) + const dispatch = vi.fn( + async (method: string, input: unknown, caller: CliRouteCaller): Promise => { + if (options.hang) return await new Promise(() => undefined) + const route = routes.get(method as DeepchatRouteName) + if (!route) throw new Error(`Unknown test route: ${method}`) + return await route(input, { caller }) + } + ) + server = new CliServer({ + userDataPath, + appVersion: '9.8.7', + dispatch, + log: { warn: vi.fn(), error: vi.fn() } + }) + servers.push(server) + await server.start() + return { userDataPath, dispatch } +} + +function runWithCapturedOutput( + argv: readonly string[], + env: NodeJS.ProcessEnv +): { + result: Promise + stdout: ReturnType + stderr: ReturnType + signalHost: EventEmitter +} { + const stdout = captureOutput() + const stderr = captureOutput() + const signalHost = new EventEmitter() + return { + result: runCli(argv, { + env, + stdout: stdout.stream, + stderr: stderr.stream, + signalHost: signalHost as unknown as NodeJS.Process, + randomId: () => 'request-1', + forceExit: vi.fn() + }), + stdout, + stderr, + signalHost + } +} + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => server.stop())) + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })) + ) +}) + +describe('bundled CLI client', () => { + it('keeps usage failures machine-readable when a post-command mode is valid', async () => { + const invocation = runWithCapturedOutput(['system', 'status', '--json', '--unknown'], {}) + + await expect(invocation.result).resolves.toBe(2) + expect(LocalControlRpcResponseSchema.parse(JSON.parse(invocation.stdout.read()))).toMatchObject( + { + ok: false, + error: { code: 'invalid_request' } + } + ) + expect(invocation.stderr.read()).toBe('') + }) + + it('keeps unexpected boundary failures inside the machine error contract', async () => { + const stdout = captureOutput() + const stderr = captureOutput() + + await expect( + runCli(['system', 'status', '--json'], { + env: {}, + stdout: stdout.stream, + stderr: stderr.stream, + randomId: () => 'request-1', + loadDescriptor: async () => { + throw new Error('x'.repeat(5_000)) + } + }) + ).resolves.toBe(8) + + const response = LocalControlRpcResponseSchema.parse(JSON.parse(stdout.read())) + expect(response).toMatchObject({ ok: false, error: { code: 'internal_error' } }) + expect(response.ok ? '' : response.error.message).toHaveLength(4_096) + expect(stderr.read()).toBe('') + }) + + it('discovers the running app and renders human output', async () => { + const { userDataPath, dispatch } = await createClientServer() + const invocation = runWithCapturedOutput(['system', 'version'], { + DEEPCHAT_E2E_USER_DATA_DIR: userDataPath + }) + + await expect(invocation.result).resolves.toBe(0) + expect(invocation.stdout.read()).toContain('DeepChat 9.8.7') + expect(invocation.stdout.read()).toContain('Protocol 1, surface 1') + expect(invocation.stderr.read()).toBe('') + expect(dispatch).toHaveBeenCalledOnce() + }) + + it('emits exactly one canonical envelope in JSON mode', async () => { + const { userDataPath } = await createClientServer() + const invocation = runWithCapturedOutput(['system', 'status', '--json'], { + DEEPCHAT_E2E_USER_DATA_DIR: userDataPath + }) + + await expect(invocation.result).resolves.toBe(0) + const lines = invocation.stdout.read().trimEnd().split('\n') + expect(lines).toHaveLength(1) + expect(LocalControlRpcResponseSchema.parse(JSON.parse(lines[0]))).toMatchObject({ + id: 'request-1', + ok: true, + result: { running: true } + }) + expect(invocation.stderr.read()).toBe('') + }) + + it('never falls back to the descriptor when Agent token selection fails', async () => { + const { userDataPath, dispatch } = await createClientServer() + const invocation = runWithCapturedOutput(['system', 'status', '--json'], { + DEEPCHAT_E2E_USER_DATA_DIR: userDataPath, + [LOCAL_CONTROL_AGENT_TOKEN_ENV]: '' + }) + + await expect(invocation.result).resolves.toBe(4) + expect(LocalControlRpcResponseSchema.parse(JSON.parse(invocation.stdout.read()))).toMatchObject( + { + ok: false, + error: { code: 'authentication_failed' } + } + ) + expect(dispatch).not.toHaveBeenCalled() + }) + + it('aborts the socket and returns the stable timeout exit code', async () => { + const { userDataPath } = await createClientServer({ hang: true }) + const invocation = runWithCapturedOutput(['system', 'status', '--json', '--timeout', '10'], { + DEEPCHAT_E2E_USER_DATA_DIR: userDataPath + }) + + await expect(invocation.result).resolves.toBe(7) + expect(LocalControlRpcResponseSchema.parse(JSON.parse(invocation.stdout.read()))).toMatchObject( + { + ok: false, + error: { code: 'timeout' } + } + ) + }) + + it('cancels once on SIGINT and returns the stable cancellation code', async () => { + const { userDataPath } = await createClientServer({ hang: true }) + const invocation = runWithCapturedOutput(['system', 'status', '--json'], { + DEEPCHAT_E2E_USER_DATA_DIR: userDataPath + }) + invocation.signalHost.emit('SIGINT') + + await expect(invocation.result).resolves.toBe(7) + expect(LocalControlRpcResponseSchema.parse(JSON.parse(invocation.stdout.read()))).toMatchObject( + { + ok: false, + error: { code: 'cancelled' } + } + ) + }) +}) diff --git a/test/main/cli/discovery.test.ts b/test/main/cli/discovery.test.ts new file mode 100644 index 000000000..39f784756 --- /dev/null +++ b/test/main/cli/discovery.test.ts @@ -0,0 +1,94 @@ +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + LOCAL_CONTROL_AGENT_TOKEN_ENV, + LOCAL_CONTROL_DESCRIPTOR_FILENAME, + LOCAL_CONTROL_PROTOCOL_VERSION, + LOCAL_CONTROL_SURFACE_VERSION, + type LocalControlDescriptor +} from '@shared/contracts/localControl' +import { + loadLocalControlDescriptor, + resolveCliUserDataPath, + selectLocalControlToken +} from '../../../src/cli/discovery' + +const temporaryDirectories: string[] = [] + +async function createTemporaryDirectory(): Promise { + const directory = await mkdtemp(path.join(os.tmpdir(), 'deepchat-cli-discovery-')) + temporaryDirectories.push(directory) + return directory +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })) + ) +}) + +describe('CLI descriptor discovery', () => { + it('mirrors Electron default and explicit profile paths', () => { + expect( + resolveCliUserDataPath({ platform: 'darwin', homeDirectory: '/Users/test', env: {} }) + ).toBe('/Users/test/Library/Application Support/DeepChat') + expect( + resolveCliUserDataPath({ platform: 'linux', homeDirectory: '/home/test', env: {} }) + ).toBe('/home/test/.config/DeepChat') + expect( + resolveCliUserDataPath({ + platform: 'win32', + homeDirectory: 'C:\\Users\\test', + env: { APPDATA: 'D:\\Profiles' } + }) + ).toBe(path.join('D:\\Profiles', 'DeepChat')) + expect( + resolveCliUserDataPath({ + env: { DEEPCHAT_E2E_USER_DATA_DIR: ' ./profile ' }, + homeDirectory: '/unused' + }) + ).toBe(path.resolve('./profile')) + }) + + it('fails closed when an Agent token variable is present but invalid', () => { + const descriptor = { token: 'h'.repeat(43) } as LocalControlDescriptor + + expect(selectLocalControlToken(descriptor, {})).toBe('h'.repeat(43)) + expect( + selectLocalControlToken(descriptor, { [LOCAL_CONTROL_AGENT_TOKEN_ENV]: 'a'.repeat(43) }) + ).toBe('a'.repeat(43)) + expect(() => + selectLocalControlToken(descriptor, { [LOCAL_CONTROL_AGENT_TOKEN_ENV]: '' }) + ).toThrow('refusing human-token fallback') + }) + + it('reports incompatible descriptor versions before connecting', async () => { + const userDataPath = await createTemporaryDirectory() + const controlDirectory = path.join(userDataPath, 'local-control') + const descriptorPath = path.join(controlDirectory, LOCAL_CONTROL_DESCRIPTOR_FILENAME) + await mkdir(controlDirectory, { recursive: true }) + await writeFile( + descriptorPath, + JSON.stringify({ + protocolVersion: LOCAL_CONTROL_PROTOCOL_VERSION + 1, + surfaceVersion: LOCAL_CONTROL_SURFACE_VERSION, + appVersion: '2.0.0', + endpoint: { kind: 'unix', path: '/tmp/deepchat.sock' }, + pid: process.pid, + token: 't'.repeat(43), + startedAt: Date.now() + }), + { mode: 0o600 } + ) + if (process.platform !== 'win32') await chmod(descriptorPath, 0o600) + + await expect( + loadLocalControlDescriptor({ + env: { DEEPCHAT_E2E_USER_DATA_DIR: userDataPath }, + processAlive: () => true + }) + ).rejects.toMatchObject({ code: 'unsupported_version', exitCode: 3 }) + }) +}) diff --git a/test/main/cli/errors.test.ts b/test/main/cli/errors.test.ts new file mode 100644 index 000000000..aba40272d --- /dev/null +++ b/test/main/cli/errors.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { LocalControlErrorSchema, type LocalControlErrorCode } from '@shared/contracts/localControl' +import { CLI_EXIT_CODES, exitCodeForRemoteError } from '../../../src/cli/errors' + +const error = (code: LocalControlErrorCode) => + LocalControlErrorSchema.parse({ + code, + message: code, + retriable: false + }) + +describe('CLI exit codes', () => { + it('keeps the public numeric contract stable', () => { + expect(CLI_EXIT_CODES).toEqual({ + success: 0, + usage: 2, + unavailable: 3, + authorization: 4, + approval: 5, + domain: 6, + cancelled: 7, + internal: 8 + }) + }) + + it.each([ + ['invalid_request', 2], + ['unsupported_version', 3], + ['unavailable', 3], + ['authentication_failed', 4], + ['permission_denied', 4], + ['approval_denied', 5], + ['approval_timeout', 5], + ['not_found', 6], + ['conflict', 6], + ['rate_limited', 6], + ['cancelled', 7], + ['timeout', 7], + ['internal_error', 8] + ] as const)('maps %s to exit %i', (code, expected) => { + expect(exitCodeForRemoteError(error(code))).toBe(expected) + }) +}) diff --git a/test/main/cli/transport.test.ts b/test/main/cli/transport.test.ts new file mode 100644 index 000000000..c6d7fd4fb --- /dev/null +++ b/test/main/cli/transport.test.ts @@ -0,0 +1,107 @@ +import { randomUUID } from 'node:crypto' +import { rm } from 'node:fs/promises' +import { createServer, type RequestListener, type Server } from 'node:http' +import { afterEach, describe, expect, it } from 'vitest' +import { + LOCAL_CONTROL_PROTOCOL_VERSION, + LOCAL_CONTROL_SURFACE_VERSION, + createLocalControlSuccess, + type LocalControlDescriptor, + type LocalControlEndpoint +} from '@shared/contracts/localControl' +import { invokeLocalControlRpc } from '../../../src/cli/transport' + +const servers: Server[] = [] +const socketPaths: string[] = [] + +function createEndpoint(): LocalControlEndpoint { + if (process.platform === 'win32') { + return { kind: 'pipe', name: `\\\\.\\pipe\\deepchat-cli-test-${randomUUID()}` } + } + const socketPath = `/tmp/deepchat-cli-${randomUUID()}.sock` + socketPaths.push(socketPath) + return { kind: 'unix', path: socketPath } +} + +async function listen(listener: RequestListener): Promise { + const endpoint = createEndpoint() + const server = createServer(listener) + servers.push(server) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(endpoint.kind === 'unix' ? endpoint.path : endpoint.name, resolve) + }) + return { + protocolVersion: LOCAL_CONTROL_PROTOCOL_VERSION, + surfaceVersion: LOCAL_CONTROL_SURFACE_VERSION, + appVersion: '1.2.3', + endpoint, + pid: process.pid, + token: 't'.repeat(43), + startedAt: Date.now() + } +} + +async function invoke(descriptor: LocalControlDescriptor) { + return await invokeLocalControlRpc({ + descriptor, + token: descriptor.token, + id: 'request-1', + method: 'cli.version', + params: {}, + signal: new AbortController().signal + }) +} + +afterEach(async () => { + await Promise.all( + servers.splice(0).map( + (server) => + new Promise((resolve) => { + server.closeAllConnections() + server.close(() => resolve()) + }) + ) + ) + await Promise.all(socketPaths.splice(0).map((socketPath) => rm(socketPath, { force: true }))) +}) + +describe('CLI response transport', () => { + it('rejects a response envelope for another request', async () => { + const descriptor = await listen((_request, response) => { + response.setHeader('content-type', 'application/json; charset=utf-8') + response.end(JSON.stringify(createLocalControlSuccess('request-2', {}))) + }) + + await expect(invoke(descriptor)).rejects.toMatchObject({ + code: 'internal_error', + exitCode: 8 + }) + }) + + it('rejects an oversized declared response before buffering it', async () => { + const descriptor = await listen((_request, response) => { + response.setHeader('content-type', 'application/json; charset=utf-8') + response.setHeader('content-length', 16 * 1024 * 1024 + 1) + response.flushHeaders() + }) + + await expect(invoke(descriptor)).rejects.toMatchObject({ + code: 'internal_error', + exitCode: 8 + }) + }) + + it('requires HTTP status and the typed envelope to agree', async () => { + const descriptor = await listen((_request, response) => { + response.statusCode = 500 + response.setHeader('content-type', 'application/json; charset=utf-8') + response.end(JSON.stringify(createLocalControlSuccess('request-1', {}))) + }) + + await expect(invoke(descriptor)).rejects.toMatchObject({ + code: 'internal_error', + exitCode: 8 + }) + }) +}) diff --git a/test/main/scripts/buildCli.test.ts b/test/main/scripts/buildCli.test.ts new file mode 100644 index 000000000..d4d395e3d --- /dev/null +++ b/test/main/scripts/buildCli.test.ts @@ -0,0 +1,53 @@ +import { execFile } from 'node:child_process' +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { promisify } from 'node:util' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' +import { + POSIX_LAUNCHER, + WINDOWS_LAUNCHER, + buildCli +} from '../../../scripts/build-cli.mjs' + +const execFileAsync = promisify(execFile) + +describe('CLI bundle', () => { + it('builds a standalone Node entry and explicit bundled-runtime launchers', async () => { + const outputDirectory = await mkdtemp(path.join(os.tmpdir(), 'deepchat-cli-build-')) + try { + await buildCli({ outDir: outputDirectory, logLevel: 'silent' }) + const entryPath = path.join(outputDirectory, 'deepchat.mjs') + const source = await readFile(entryPath, 'utf8') + const result = await execFileAsync(process.execPath, [entryPath, 'help', 'commands']) + + expect(source.startsWith('#!/usr/bin/env node')).toBe(true) + expect(source).not.toMatch(/from\s+["']zod["']/) + expect(result.stdout).toContain('deepchat ') + expect((await stat(path.join(outputDirectory, 'deepchat'))).mode & 0o111).toBe(0o111) + expect(await readFile(path.join(outputDirectory, 'deepchat'), 'utf8')).toBe(POSIX_LAUNCHER) + expect(await readFile(path.join(outputDirectory, 'deepchat.cmd'), 'utf8')).toBe( + WINDOWS_LAUNCHER + ) + expect(POSIX_LAUNCHER).toContain('../runtime/node/bin/node') + expect(WINDOWS_LAUNCHER).toContain('..\\runtime\\node\\node.exe') + } finally { + await rm(outputDirectory, { recursive: true }) + } + }) + + it('packages only generated CLI resources outside app.asar', async () => { + const config = parse(await readFile(path.resolve('electron-builder.yml'), 'utf8')) as { + files: string[] + extraResources: Array<{ from: string; to: string; filter?: string[] }> + } + + expect(config.files).toContain('!out/cli/**') + expect(config.extraResources).toContainEqual({ + from: './out/cli/', + to: 'app.asar.unpacked/cli', + filter: ['deepchat', 'deepchat.cmd', 'deepchat.mjs'] + }) + }) +}) diff --git a/tsconfig.node.json b/tsconfig.node.json index c10fc6d18..c87a01691 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -2,6 +2,7 @@ "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", "include": [ "electron.vite.config.*", + "src/cli/**/*", "src/main/**/*", "src/preload/**/*", "src/shared/**/*" From d0192dd67a2a5339667c664b6e3d95a6b3aa2d9a Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 11:14:29 +0800 Subject: [PATCH 05/51] chore(acp): refresh bundled registry --- resources/acp-registry/registry.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/resources/acp-registry/registry.json b/resources/acp-registry/registry.json index 19e8430ed..ee0f00064 100644 --- a/resources/acp-registry/registry.json +++ b/resources/acp-registry/registry.json @@ -818,7 +818,7 @@ { "id": "kilo", "name": "Kilo", - "version": "7.4.17", + "version": "7.4.19", "description": "The open source coding agent", "repository": "https://github.com/Kilo-Org/kilocode", "website": "https://kilo.ai/", @@ -830,48 +830,48 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.17/kilo-darwin-arm64.zip", + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.19/kilo-darwin-arm64.zip", "cmd": "./kilo", "args": [ "acp" ], - "sha256": "b51f33f7c47fb77c6252fcacfc20ba7c90a979ecd757cc9aadb246338f8c266a" + "sha256": "370f213a1ff62575e471d5c1fd96ccadb41096224f560aaa3c51d868f132f59b" }, "darwin-x86_64": { - "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.17/kilo-darwin-x64.zip", + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.19/kilo-darwin-x64.zip", "cmd": "./kilo", "args": [ "acp" ], - "sha256": "4670655e95869ff25b398d5c24b0a9c890ebef18dc23af4c6faa586c97d75cc9" + "sha256": "46b32098a5e8cf2177fb586478ae85f3a0989eed6f11d03033c0d8776e0a856d" }, "linux-aarch64": { - "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.17/kilo-linux-arm64.tar.gz", + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.19/kilo-linux-arm64.tar.gz", "cmd": "./kilo", "args": [ "acp" ], - "sha256": "b0342209411e4e16a161541e36b7d01d6972e965abea1c13c2e0eec021acfdf7" + "sha256": "48eb310f95a2778d7a25a2fada06785f1082a66a518f8e454863f4d11836e2a0" }, "linux-x86_64": { - "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.17/kilo-linux-x64.tar.gz", + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.19/kilo-linux-x64.tar.gz", "cmd": "./kilo", "args": [ "acp" ], - "sha256": "8bc8b4918c0634c185e1c54be049b4110b9e37d59f3dc93c6411d63c23b11034" + "sha256": "324de57ff82c1ff55c11903280e97ff0b7abacf1aa5acd6d2331bdb191c78f7e" }, "windows-x86_64": { - "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.17/kilo-windows-x64.zip", + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.19/kilo-windows-x64.zip", "cmd": "./kilo.exe", "args": [ "acp" ], - "sha256": "4313a14a20ab3609702ab3c52e232f28aba52bbd7eb41853297ba947a452d4d2" + "sha256": "5db36446e18e1ccb592be9106c6da9342ab56c230d84d68ca268ced64873e96d" } }, "npx": { - "package": "@kilocode/cli@7.4.17", + "package": "@kilocode/cli@7.4.19", "args": [ "acp" ] From 26268104eb2865aacec4f530f5c8304546500cff Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 11:45:11 +0800 Subject: [PATCH 06/51] feat(cli): add owned artifact delivery --- docs/architecture/local-control-plane/spec.md | 5 +- .../architecture/local-control-plane/tasks.md | 2 +- src/cli/args.ts | 97 ++- src/cli/artifacts.ts | 252 +++++++ src/cli/format.ts | 22 +- src/cli/index.ts | 1 + src/cli/run.ts | 41 +- src/main/app/composition.ts | 13 +- src/main/cli/artifactRoutes.ts | 61 ++ src/main/cli/artifactSpool.ts | 697 ++++++++++++++++++ src/main/cli/index.ts | 2 + src/main/cli/server.ts | 172 ++++- src/main/cli/surface.ts | 32 +- src/shared/contracts/localControl.ts | 1 + src/shared/contracts/routes.ts | 9 + .../contracts/routes/artifacts.routes.ts | 61 ++ src/shared/contracts/routes/cli.routes.ts | 2 +- test/main/cli/args.test.ts | 24 + test/main/cli/artifactSpool.test.ts | 250 +++++++ test/main/cli/artifacts.test.ts | 220 ++++++ test/main/cli/server.test.ts | 40 + test/main/cli/surface.test.ts | 21 +- 22 files changed, 1992 insertions(+), 33 deletions(-) create mode 100644 src/cli/artifacts.ts create mode 100644 src/main/cli/artifactRoutes.ts create mode 100644 src/main/cli/artifactSpool.ts create mode 100644 src/shared/contracts/routes/artifacts.routes.ts create mode 100644 test/main/cli/artifactSpool.test.ts create mode 100644 test/main/cli/artifacts.test.ts diff --git a/docs/architecture/local-control-plane/spec.md b/docs/architecture/local-control-plane/spec.md index 844c5aad5..a3b541d4f 100644 --- a/docs/architecture/local-control-plane/spec.md +++ b/docs/architecture/local-control-plane/spec.md @@ -463,8 +463,9 @@ The spool is output-only and intentionally smaller than a general asset store: - per-artifact, per-request, per-connection, and aggregate byte/count limits; - streaming writes with hash/size accounting and atomic publication; - ownership checks on describe/read/delete and no path exposure; -- TTL cleanup, disconnect cleanup for non-detached output, startup cleanup after crashes, and shutdown - cleanup; +- TTL cleanup, request-failure cleanup for unpublished output, startup cleanup after crashes, and + shutdown cleanup; published artifacts survive their creating HTTP connection because download uses + a separate request; - bounded streaming download with backpressure. Input uploads use a separate private temporary-body utility and never become spool artifacts unless a diff --git a/docs/architecture/local-control-plane/tasks.md b/docs/architecture/local-control-plane/tasks.md index e70d800b9..2eb37d882 100644 --- a/docs/architecture/local-control-plane/tasks.md +++ b/docs/architecture/local-control-plane/tasks.md @@ -33,7 +33,7 @@ - [ ] Add image and video standalone generation surfaces. - [ ] Add formal standalone speech generation and typed audio output. - [ ] Add upload and owned-artifact transcription inputs. -- [ ] Implement output-only `ArtifactSpool` ownership, quotas, expiry, and cleanup. +- [x] Implement output-only `ArtifactSpool` ownership, quotas, expiry, and cleanup. - [ ] Add stream, media, speech, transcription, artifact, and quota tests. ## OCR diff --git a/src/cli/args.ts b/src/cli/args.ts index 4bb1bda5f..44b9f592a 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -4,6 +4,13 @@ import { cliStatusRoute, cliVersionRoute } from '@shared/contracts/routes/cli.routes' +import { + ArtifactIdSchema, + artifactsDeleteRoute, + artifactsDescribeRoute, + artifactsReadRoute +} from '@shared/contracts/routes/artifacts.routes' +import type { JsonValue } from '@shared/contracts/json' import { CliUsageError } from './errors' export const CLI_OUTPUT_ENV = 'DEEPCHAT_CLI_OUTPUT' @@ -17,6 +24,11 @@ export type CliRpcContract = | typeof cliVersionRoute | typeof cliCapabilitiesRoute | typeof cliDoctorRoute + | typeof artifactsDescribeRoute + | typeof artifactsReadRoute + | typeof artifactsDeleteRoute + +export type CliCommandOperation = 'rpc' | 'download' export type ParsedCliArguments = Readonly<{ domain: string @@ -25,13 +37,20 @@ export type ParsedCliArguments = Readonly<{ outputMode: CliOutputMode timeoutMs: number helpRequested: boolean + operation: CliCommandOperation + params: JsonValue + outputPath?: string + overwrite: boolean }> const COMMANDS = new Map([ ['system status', cliStatusRoute], ['system version', cliVersionRoute], ['system capabilities', cliCapabilitiesRoute], - ['system doctor', cliDoctorRoute] + ['system doctor', cliDoctorRoute], + ['artifact describe', artifactsDescribeRoute], + ['artifact get', artifactsReadRoute], + ['artifact delete', artifactsDeleteRoute] ]) function parseOutputMode(value: string | undefined): CliOutputMode { @@ -90,6 +109,26 @@ export function parseCliArguments( : DEFAULT_CLI_TIMEOUT_MS let timeoutSeen = false let helpRequested = false + let artifactId: string | undefined + let outputPath: string | undefined + let overwrite = false + + const readOptionValue = ( + argument: string, + index: number + ): { value: string; nextIndex: number } => { + const equalsIndex = argument.indexOf('=') + if (equalsIndex >= 0) { + const value = argument.slice(equalsIndex + 1) + if (!value) throw new CliUsageError(`Missing value for ${argument.slice(0, equalsIndex)}`) + return { value, nextIndex: index } + } + const value = argv[index + 1] + if (!value || value.startsWith('--')) { + throw new CliUsageError(`Missing value for ${argument}`) + } + return { value, nextIndex: index + 1 } + } for (let index = 2; index < argv.length; index += 1) { const argument = argv[index] @@ -122,23 +161,72 @@ export function parseCliArguments( timeoutSeen = true continue } + if (argument === '--id' || argument.startsWith('--id=')) { + if (artifactId !== undefined) throw new CliUsageError('--id may be specified only once') + const parsedOption = readOptionValue(argument, index) + const parsedId = ArtifactIdSchema.safeParse(parsedOption.value) + if (!parsedId.success) throw new CliUsageError('--id is not a valid artifact identifier') + artifactId = parsedId.data + index = parsedOption.nextIndex + continue + } + if (argument === '--out' || argument.startsWith('--out=')) { + if (outputPath !== undefined) throw new CliUsageError('--out may be specified only once') + const parsedOption = readOptionValue(argument, index) + outputPath = parsedOption.value + index = parsedOption.nextIndex + continue + } + if (argument === '--overwrite') { + if (overwrite) throw new CliUsageError('--overwrite may be specified only once') + overwrite = true + continue + } throw new CliUsageError(`Unknown option after ${domain} ${verb}: ${argument}`) } + const isArtifactCommand = domain === 'artifact' + if (!helpRequested && isArtifactCommand && !artifactId) { + throw new CliUsageError(`deepchat ${domain} ${verb} requires --id `) + } + if (!helpRequested && commandKey === 'artifact get' && !outputPath) { + throw new CliUsageError('deepchat artifact get requires --out ') + } + if (!isArtifactCommand && (artifactId !== undefined || outputPath !== undefined || overwrite)) { + throw new CliUsageError(`Artifact options are not valid for deepchat ${domain} ${verb}`) + } + if ( + isArtifactCommand && + commandKey !== 'artifact get' && + (outputPath !== undefined || overwrite) + ) { + throw new CliUsageError(`--out and --overwrite are only valid for deepchat artifact get`) + } + return { domain, verb, contract, outputMode, timeoutMs, - helpRequested: helpRequested || isHelpCommand + helpRequested: helpRequested || isHelpCommand, + operation: commandKey === 'artifact get' ? 'download' : 'rpc', + params: artifactId ? { id: artifactId } : {}, + ...(outputPath ? { outputPath } : {}), + overwrite } } export function formatCliHelp(command?: Pick): string { if (command && command.domain !== 'help') { + const commandOptions = + command.domain === 'artifact' + ? command.verb === 'get' + ? ' --id --out [--overwrite]' + : ' --id ' + : '' return [ - `Usage: deepchat ${command.domain} ${command.verb} [--json|--jsonl] [--timeout ]`, + `Usage: deepchat ${command.domain} ${command.verb}${commandOptions} [--json|--jsonl] [--timeout ]`, '', 'Global flags must follow the domain and verb.' ].join('\n') @@ -152,6 +240,9 @@ export function formatCliHelp(command?: Pick + +function protocolFailure(message: string): CliClientError { + return new CliClientError('internal_error', message, CLI_EXIT_CODES.internal) +} + +function transportFailure(message: string): CliClientError { + return new CliClientError('unavailable', message, CLI_EXIT_CODES.unavailable, true) +} + +function abortReason(signal: AbortSignal): Error { + return signal.reason instanceof Error + ? signal.reason + : new CliClientError('cancelled', 'CLI request was cancelled', CLI_EXIT_CODES.cancelled) +} + +function singularHeader(response: IncomingMessage, name: string): string | undefined { + const values = response.headersDistinct[name] + if (values && values.length !== 1) { + throw protocolFailure(`DeepChat returned multiple ${name} headers`) + } + const value = values?.[0] ?? response.headers[name] + if (Array.isArray(value)) throw protocolFailure(`DeepChat returned an invalid ${name} header`) + return value +} + +async function writeAll(handle: FileHandle, chunk: Buffer, position: number): Promise { + let offset = 0 + while (offset < chunk.length) { + const { bytesWritten } = await handle + .write(chunk, offset, chunk.length - offset, position + offset) + .catch((error) => { + throw new CliClientError( + 'conflict', + `Cannot write artifact output: ${(error as Error).message}`, + CLI_EXIT_CODES.domain + ) + }) + if (bytesWritten <= 0) throw new Error('Artifact output write made no progress') + offset += bytesWritten + } + return offset +} + +async function readRemoteFailure(response: IncomingMessage): Promise { + const chunks: Buffer[] = [] + let size = 0 + for await (const rawChunk of response) { + const chunk = Buffer.from(rawChunk) + size += chunk.length + if (size > MAX_ERROR_RESPONSE_BYTES) { + throw protocolFailure('DeepChat artifact error response exceeds the byte limit') + } + chunks.push(chunk) + } + + try { + const parsed = LocalControlRpcResponseSchema.parse( + JSON.parse(Buffer.concat(chunks, size).toString('utf8')) + ) + if (parsed.ok) + throw protocolFailure('DeepChat returned a success envelope with an error status') + return new CliClientError( + parsed.error.code, + parsed.error.message, + exitCodeForRemoteError(parsed.error), + parsed.error.retriable + ) + } catch (error) { + if (error instanceof CliClientError) return error + return protocolFailure('DeepChat returned an invalid artifact error response') + } +} + +async function receiveArtifact(input: ArtifactDownloadInput, handle: FileHandle): Promise { + if (input.signal.aborted) throw abortReason(input.signal) + + await new Promise((resolve, reject) => { + let settled = false + const finish = (callback: () => void) => { + if (settled) return + settled = true + callback() + } + const request = httpRequest({ + socketPath: + input.descriptor.endpoint.kind === 'unix' + ? input.descriptor.endpoint.path + : input.descriptor.endpoint.name, + path: `${LOCAL_CONTROL_ARTIFACT_PATH_PREFIX}${input.metadata.id}`, + method: 'GET', + agent: false, + signal: input.signal, + headers: { + authorization: `Bearer ${input.token}`, + connection: 'close', + 'user-agent': `DeepChat-CLI/${CLI_VERSION}` + } + }) + + request.once('response', (response) => { + void (async () => { + if ((response.statusCode ?? 0) < 200 || (response.statusCode ?? 0) >= 300) { + throw await readRemoteFailure(response) + } + + const contentLength = singularHeader(response, 'content-length') + const contentType = singularHeader(response, 'content-type') + const artifactId = singularHeader(response, 'x-deepchat-artifact-id') + const expectedHash = singularHeader(response, 'x-content-sha256') + if (!contentLength || !/^(0|[1-9][0-9]*)$/.test(contentLength)) { + throw protocolFailure('DeepChat returned an invalid artifact Content-Length') + } + if (Number(contentLength) !== input.metadata.size) { + throw protocolFailure('DeepChat artifact size changed after description') + } + if (contentType?.trim().toLowerCase() !== input.metadata.mimeType.toLowerCase()) { + throw protocolFailure('DeepChat artifact MIME type changed after description') + } + if (artifactId !== input.metadata.id || expectedHash !== input.metadata.sha256) { + throw protocolFailure('DeepChat artifact identity changed after description') + } + + const hash = createHash('sha256') + let bytesWritten = 0 + for await (const rawChunk of response) { + if (input.signal.aborted) throw abortReason(input.signal) + const chunk = Buffer.from(rawChunk) + if (bytesWritten + chunk.length > input.metadata.size) { + throw protocolFailure('DeepChat artifact exceeded its declared size') + } + hash.update(chunk) + bytesWritten += await writeAll(handle, chunk, bytesWritten) + } + if (bytesWritten !== input.metadata.size) { + throw protocolFailure('DeepChat artifact was truncated') + } + if (hash.digest('hex') !== input.metadata.sha256) { + throw protocolFailure('DeepChat artifact checksum did not match') + } + })().then( + () => finish(resolve), + (error: unknown) => { + response.destroy() + finish(() => + reject( + error instanceof CliClientError + ? error + : transportFailure( + error instanceof Error ? error.message : 'Artifact download failed' + ) + ) + ) + } + ) + }) + request.once('error', (error: NodeJS.ErrnoException) => { + if (input.signal.aborted) { + finish(() => reject(abortReason(input.signal))) + return + } + finish(() => reject(transportFailure(`Cannot download artifact: ${error.message}`))) + }) + request.end() + }) +} + +async function publishDownload( + tempPath: string, + outputPath: string, + overwrite: boolean +): Promise { + if (!overwrite) { + try { + await link(tempPath, outputPath) + return + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + throw new CliClientError( + 'conflict', + `Output already exists: ${outputPath}`, + CLI_EXIT_CODES.domain + ) + } + throw error + } + } + + await rename(tempPath, outputPath) +} + +export async function downloadArtifact(input: ArtifactDownloadInput): Promise { + const outputPath = path.resolve(input.outputPath) + if (outputPath.includes('\0')) { + throw new CliClientError('invalid_request', 'Output path contains NUL', CLI_EXIT_CODES.usage) + } + const tempPath = path.join(path.dirname(outputPath), `.deepchat-${randomUUID()}.tmp`) + const handle = await open(tempPath, 'wx', 0o600).catch((error) => { + throw new CliClientError( + 'conflict', + `Cannot create temporary output beside ${outputPath}: ${(error as Error).message}`, + CLI_EXIT_CODES.domain + ) + }) + let handleOpen = true + try { + await receiveArtifact(input, handle) + await handle.sync().catch((error) => { + throw new CliClientError( + 'conflict', + `Cannot flush artifact output: ${(error as Error).message}`, + CLI_EXIT_CODES.domain + ) + }) + await handle.close() + handleOpen = false + await publishDownload(tempPath, outputPath, input.overwrite).catch((error) => { + if (error instanceof CliClientError) throw error + throw new CliClientError( + 'conflict', + `Cannot publish artifact output: ${(error as Error).message}`, + CLI_EXIT_CODES.domain + ) + }) + return outputPath + } finally { + if (handleOpen) await handle.close().catch(() => undefined) + await unlink(tempPath).catch(() => undefined) + } +} diff --git a/src/cli/format.ts b/src/cli/format.ts index eaf48abfc..040ffd6a5 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -14,7 +14,11 @@ function formatDuration(milliseconds: number): string { .join(' ') } -export function formatHumanResult(contract: CliRpcContract, value: JsonValue): string { +export function formatHumanResult( + contract: CliRpcContract, + value: JsonValue, + context: { outputPath?: string } = {} +): string { switch (contract.name) { case 'cli.status': { const result = contract.output.parse(value) @@ -54,6 +58,22 @@ export function formatHumanResult(contract: CliRpcContract, value: JsonValue): s ) ].join('\n') } + case 'artifacts.describe': { + const { artifact } = contract.output.parse(value) + return [ + `${artifact.id} ${artifact.mimeType} ${artifact.size} bytes`, + `SHA-256: ${artifact.sha256}`, + `Expires: ${new Date(artifact.expiresAt).toISOString()}` + ].join('\n') + } + case 'artifacts.read': { + const { artifact } = contract.output.parse(value) + return `Saved ${artifact.size} bytes to ${context.outputPath ?? artifact.filename}` + } + case 'artifacts.delete': { + contract.output.parse(value) + return 'Artifact deleted' + } } } diff --git a/src/cli/index.ts b/src/cli/index.ts index 6cc83fcf5..6d7169a1c 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url' import { runCli } from './run' export { parseCliArguments, formatCliHelp } from './args' +export { downloadArtifact } from './artifacts' export { loadLocalControlDescriptor, resolveCliUserDataPath } from './discovery' export { CLI_EXIT_CODES } from './errors' export { runCli } from './run' diff --git a/src/cli/run.ts b/src/cli/run.ts index db79fff33..67a093fee 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -1,9 +1,11 @@ import { randomUUID } from 'node:crypto' import { + LOCAL_CONTROL_AGENT_TOKEN_ENV, createLocalControlFailure, type LocalControlDescriptor, type LocalControlRpcResponse } from '@shared/contracts/localControl' +import { artifactsDescribeRoute } from '@shared/contracts/routes/artifacts.routes' import { parseCliArguments, formatCliHelp, inferCliOutputMode, type CliOutputMode } from './args' import { loadLocalControlDescriptor, @@ -19,6 +21,7 @@ import { } from './errors' import { formatHumanResult, serializeMachineResponse } from './format' import { invokeLocalControlRpc, type CliRpcInvocation } from './transport' +import { downloadArtifact } from './artifacts' const SIGNAL_GRACE_MS = 1_000 @@ -139,12 +142,24 @@ export async function runCli( }) if (controller.signal.aborted) throw controller.signal.reason const token = selectLocalControlToken(descriptor, env) + if ( + parsed.operation === 'download' && + Object.prototype.hasOwnProperty.call(env, LOCAL_CONTROL_AGENT_TOKEN_ENV) + ) { + throw new CliClientError( + 'permission_denied', + 'Agent callers cannot download artifact bytes or use --out', + CLI_EXIT_CODES.authorization + ) + } + const invocationContract = + parsed.operation === 'download' ? artifactsDescribeRoute : parsed.contract const response = await (dependencies.invokeRpc ?? invokeLocalControlRpc)({ descriptor, token, id: requestId, - method: parsed.contract.name, - params: {}, + method: invocationContract.name, + params: parsed.params, signal: controller.signal }) @@ -165,8 +180,28 @@ export async function runCli( CLI_EXIT_CODES.internal ) } + if (parsed.operation === 'download') { + if (!parsed.outputPath || !('artifact' in result.data)) { + throw new CliClientError( + 'internal_error', + 'Artifact download command has no validated output target', + CLI_EXIT_CODES.internal + ) + } + await downloadArtifact({ + descriptor, + token, + metadata: result.data.artifact, + outputPath: parsed.outputPath, + overwrite: parsed.overwrite, + signal: controller.signal + }) + } if (parsed.outputMode === 'text') { - writeText(stdout, formatHumanResult(parsed.contract, response.result)) + writeText( + stdout, + formatHumanResult(parsed.contract, response.result, { outputPath: parsed.outputPath }) + ) } else { writeText(stdout, serializeMachineResponse(response)) } diff --git a/src/main/app/composition.ts b/src/main/app/composition.ts index 5afe283ba..632c10e6a 100644 --- a/src/main/app/composition.ts +++ b/src/main/app/composition.ts @@ -207,7 +207,7 @@ import { type RouteDispatcher } from '@/routes' import { createNodeScheduler } from '@/routes/scheduler' -import { CliServer, createCliRoutes } from '@/cli' +import { ArtifactSpool, CliServer, createArtifactRoutes, createCliRoutes } from '@/cli' import { AcpRegistryMigrationService } from '@/agent/acp/catalog/acpRegistryMigrationService' import { killTerminal } from '@/agent/acp/launch/acpInitHelper' import { rtkRuntimeService } from '@/agent/shared/process/rtkRuntimeService' @@ -357,6 +357,10 @@ export async function createMainProcessControl(dependencies: { dependencies.onWindowCreated, startupWorkloadCoordinator ) + const artifactSpool = new ArtifactSpool({ + directory: path.join(app.getPath('userData'), 'local-control', 'artifacts'), + log: logger + }) const cliServer = new CliServer({ userDataPath: app.getPath('userData'), appVersion: app.getVersion(), @@ -367,6 +371,7 @@ export async function createMainProcessControl(dependencies: { signal.throwIfAborted() return output }, + artifactSpool, log: logger }) const semanticNotificationScheduler = new TimeoutNotificationScheduler() @@ -1873,6 +1878,7 @@ export async function createMainProcessControl(dependencies: { async function destroy(): Promise { await runDestroyStep('cliServer.stop', () => cliServer.stop()) + await runDestroyStep('artifactSpool.close', () => artifactSpool.close()) await runDestroyStep('providerCatalog.unsubscribe', () => unsubscribeProviderDbCatalog()) await runDestroyStep('liveDelegationService.stop', () => liveDelegationService.stop()) await runDestroyStep('cronJobs.destroy', () => cronJobs.destroy()) @@ -2236,6 +2242,7 @@ export async function createMainProcessControl(dependencies: { hasTrustedRenderer: () => windowPresenter.getAllWindows().some((window) => !window.isDestroyed()) }) + const artifactRoutes = createArtifactRoutes(artifactSpool) routeDispatcher = createRouteDispatcher({ appDatabaseMaintenance: { assertRouteAllowed: (routeName) => assertRouteAllowedDuringDatabaseMaintenance(routeName) @@ -2270,7 +2277,8 @@ export async function createMainProcessControl(dependencies: { notificationRoutes, appSettingsRoutes, appRoutes, - cliRoutes + cliRoutes, + artifactRoutes ], settingsWindow: windowPresenter, startupWorkloadCoordinator @@ -2660,6 +2668,7 @@ export async function createMainProcessControl(dependencies: { memoryService.startBackgroundMaintenance() appLifecycleState = 'running' try { + await artifactSpool.initialize() await cliServer.start() } catch (error) { logger.error('[CLI] Failed to start local control server', error) diff --git a/src/main/cli/artifactRoutes.ts b/src/main/cli/artifactRoutes.ts new file mode 100644 index 000000000..f1afc2117 --- /dev/null +++ b/src/main/cli/artifactRoutes.ts @@ -0,0 +1,61 @@ +import { + artifactsDeleteRoute, + artifactsDescribeRoute, + artifactsReadRoute +} from '@shared/contracts/routes' +import { + createRouteMap, + type CliRouteCaller, + type DeepchatRouteMap, + type RouteCaller +} from '@/routes/routeRegistry' +import { ArtifactSpool } from './artifactSpool' +import { CliRequestError } from './errors' + +function requireCliCaller(caller: RouteCaller): CliRouteCaller { + if (caller.kind !== 'cli') { + throw new CliRequestError('permission_denied', 'Artifact routes require a CLI caller', { + httpStatus: 403 + }) + } + return caller +} + +export function createArtifactRoutes(artifactSpool: ArtifactSpool): DeepchatRouteMap { + return createRouteMap([ + [ + artifactsDescribeRoute.name, + async (rawInput, context) => { + const input = artifactsDescribeRoute.input.parse(rawInput) + const caller = requireCliCaller(context.caller) + return artifactsDescribeRoute.output.parse({ + artifact: await artifactSpool.describe(input.id, caller) + }) + } + ], + [ + artifactsReadRoute.name, + async (rawInput, context) => { + const input = artifactsReadRoute.input.parse(rawInput) + const caller = requireCliCaller(context.caller) + return artifactsReadRoute.output.parse({ + artifact: await artifactSpool.describe(input.id, caller) + }) + } + ], + [ + artifactsDeleteRoute.name, + async (rawInput, context) => { + const input = artifactsDeleteRoute.input.parse(rawInput) + const caller = requireCliCaller(context.caller) + if (caller.principal !== 'human') { + throw new CliRequestError('permission_denied', 'Agent callers cannot delete artifacts', { + httpStatus: 403 + }) + } + await artifactSpool.delete(input.id, caller) + return artifactsDeleteRoute.output.parse({ deleted: true }) + } + ] + ]) +} diff --git a/src/main/cli/artifactSpool.ts b/src/main/cli/artifactSpool.ts new file mode 100644 index 000000000..037534aa5 --- /dev/null +++ b/src/main/cli/artifactSpool.ts @@ -0,0 +1,697 @@ +import { createHash, randomBytes } from 'node:crypto' +import type { ReadStream } from 'node:fs' +import type { FileHandle } from 'node:fs/promises' +import { chmod, link, lstat, mkdir, open, readdir, unlink } from 'node:fs/promises' +import path from 'node:path' +import { + ArtifactMetadataSchema, + type ArtifactMetadata +} from '@shared/contracts/routes/artifacts.routes' +import type { CliRouteCaller } from '@/routes/routeRegistry' +import { CliRequestError } from './errors' + +const DEFAULT_ARTIFACT_TTL_MS = 60 * 60_000 +const MAX_ARTIFACT_TTL_MS = 7 * 24 * 60 * 60_000 +const DEFAULT_CLEANUP_INTERVAL_MS = 60_000 +const OWNED_FILE_PATTERN = /^(?:\.[A-Za-z0-9_-]{16,128}\.tmp|[A-Za-z0-9_-]{16,128}\.artifact)$/ + +export type ArtifactSpoolLimits = Readonly<{ + maxArtifactBytes: number + maxRequestBytes: number + maxConnectionBytes: number + maxOwnerBytes: number + maxTotalBytes: number + maxRequestCount: number + maxConnectionCount: number + maxOwnerCount: number + maxTotalCount: number +}> + +const DEFAULT_LIMITS: ArtifactSpoolLimits = { + maxArtifactBytes: 512 * 1024 * 1024, + maxRequestBytes: 768 * 1024 * 1024, + maxConnectionBytes: 1024 * 1024 * 1024, + maxOwnerBytes: 1024 * 1024 * 1024, + maxTotalBytes: 2 * 1024 * 1024 * 1024, + maxRequestCount: 16, + maxConnectionCount: 64, + maxOwnerCount: 128, + maxTotalCount: 256 +} + +type ArtifactOwner = Readonly<{ + principal: 'human' | 'agent' + conversationId?: string +}> + +type StoredArtifact = Readonly<{ + metadata: ArtifactMetadata + filePath: string + ownerKey: string + requestKey: string + connectionId: string +}> + +type QuotaReservation = { + bytes: number + count: number +} + +export type ArtifactWriteInput = Readonly<{ + caller: CliRouteCaller + requestId: string + mimeType: string + suggestedFilename?: string + data: Uint8Array | AsyncIterable + ttlMs?: number +}> + +export type OpenArtifact = Readonly<{ + metadata: ArtifactMetadata + stream: ReadStream +}> + +export type ArtifactSpoolOptions = Readonly<{ + directory: string + limits?: Partial + now?: () => number + createId?: () => string + cleanupIntervalMs?: number + log?: Pick +}> + +function ownerForCaller(caller: CliRouteCaller): ArtifactOwner { + return caller.principal === 'human' + ? { principal: 'human' } + : { principal: 'agent', conversationId: caller.conversationId } +} + +function ownerKey(owner: ArtifactOwner): string { + return owner.principal === 'human' ? 'human' : `agent:${owner.conversationId ?? ''}` +} + +function requestKey(owner: string, requestId: string): string { + return JSON.stringify([owner, requestId]) +} + +function normalizeMimeType(value: string): string { + return ArtifactMetadataSchema.shape.mimeType.parse(value.trim().toLowerCase()) +} + +function extensionForMimeType(mimeType: string): string { + switch (mimeType.split(';', 1)[0]) { + case 'image/png': + return '.png' + case 'image/jpeg': + return '.jpg' + case 'image/webp': + return '.webp' + case 'image/gif': + return '.gif' + case 'video/mp4': + return '.mp4' + case 'video/webm': + return '.webm' + case 'audio/mpeg': + return '.mp3' + case 'audio/wav': + return '.wav' + case 'audio/ogg': + return '.ogg' + case 'audio/aac': + return '.aac' + case 'audio/flac': + return '.flac' + default: + return '.bin' + } +} + +function normalizeFilename(value: string | undefined, mimeType: string): string { + const sanitized = Buffer.from(value ?? '', 'utf8') + .toString('utf8') + .normalize('NFC') + .replace(/[\\/]/g, '_') + .replace(/\p{Cc}/gu, '_') + .trim() + let normalized = '' + for (const character of sanitized) { + if (normalized.length + character.length > 255) break + normalized += character + } + return normalized && normalized !== '.' && normalized !== '..' + ? normalized + : `artifact${extensionForMimeType(mimeType)}` +} + +function positiveInteger(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive safe integer`) + } + return value +} + +async function writeAll(handle: FileHandle, bytes: Uint8Array, position: number): Promise { + let offset = 0 + while (offset < bytes.byteLength) { + const { bytesWritten } = await handle.write( + bytes, + offset, + bytes.byteLength - offset, + position + offset + ) + if (bytesWritten <= 0) throw new Error('Artifact output write made no progress') + offset += bytesWritten + } + return position + bytes.byteLength +} + +async function* artifactChunks( + data: Uint8Array | AsyncIterable +): AsyncGenerator { + if (data instanceof Uint8Array) { + yield data + return + } + for await (const chunk of data) { + if (!(chunk instanceof Uint8Array)) { + throw new CliRequestError('invalid_request', 'Artifact stream yielded a non-byte chunk') + } + yield chunk + } +} + +export class ArtifactSpool { + private readonly limits: ArtifactSpoolLimits + private readonly now: () => number + private readonly createId: () => string + private readonly cleanupIntervalMs: number + private readonly log: Pick + private readonly artifacts = new Map() + private readonly ownerReservations = new Map() + private readonly requestReservations = new Map() + private readonly connectionReservations = new Map() + private readonly allocatedIds = new Set() + private reservedBytes = 0 + private reservedCount = 0 + private activeWrites = 0 + private readonly activeReads = new Map() + private readonly openReadStreams = new Set() + private readonly pendingRemovalIds = new Set() + private readonly removalPromises = new Map>() + private readonly writeDrainListeners = new Set<() => void>() + private readonly readDrainListeners = new Set<() => void>() + private closing = false + private initializePromise: Promise | undefined + private closePromise: Promise | undefined + private cleanupTimer: NodeJS.Timeout | undefined + + constructor(private readonly options: ArtifactSpoolOptions) { + this.limits = { + ...DEFAULT_LIMITS, + ...options.limits + } + for (const [name, value] of Object.entries(this.limits)) positiveInteger(value, name) + this.now = options.now ?? Date.now + this.createId = options.createId ?? (() => randomBytes(24).toString('base64url')) + this.cleanupIntervalMs = positiveInteger( + options.cleanupIntervalMs ?? DEFAULT_CLEANUP_INTERVAL_MS, + 'cleanupIntervalMs' + ) + this.log = options.log ?? console + } + + async initialize(): Promise { + if (this.closing) { + throw new CliRequestError('unavailable', 'Artifact spool is closed', { httpStatus: 503 }) + } + if (this.initializePromise) return await this.initializePromise + this.initializePromise = this.initializeInternal().catch((error) => { + this.initializePromise = undefined + throw error + }) + return await this.initializePromise + } + + private async initializeInternal(): Promise { + await mkdir(this.options.directory, { recursive: true, mode: 0o700 }) + const directoryStat = await lstat(this.options.directory) + if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) { + throw new Error('Artifact spool path is not a regular directory') + } + if (typeof process.getuid === 'function' && directoryStat.uid !== process.getuid()) { + throw new Error('Artifact spool directory is owned by another user') + } + if (process.platform !== 'win32') { + await chmod(this.options.directory, 0o700) + const protectedStat = await lstat(this.options.directory) + if ((protectedStat.mode & 0o077) !== 0) { + throw new Error('Artifact spool directory permissions are not private') + } + } + + for (const entry of await readdir(this.options.directory, { withFileTypes: true })) { + if (!OWNED_FILE_PATTERN.test(entry.name)) continue + if (entry.isFile() || entry.isSymbolicLink()) { + await unlink(path.join(this.options.directory, entry.name)) + } + } + + this.cleanupTimer = setInterval(() => { + void this.cleanupExpired().catch((error) => { + this.log.warn('[CLI] Failed to clean expired artifacts', error) + }) + }, this.cleanupIntervalMs) + this.cleanupTimer.unref() + } + + async write(input: ArtifactWriteInput): Promise { + if (this.closing) { + throw new CliRequestError('unavailable', 'Artifact spool is closed', { httpStatus: 503 }) + } + this.activeWrites += 1 + try { + return await this.writeInternal(input) + } finally { + this.activeWrites = Math.max(0, this.activeWrites - 1) + if (this.activeWrites === 0) { + for (const listener of this.writeDrainListeners) listener() + this.writeDrainListeners.clear() + } + } + } + + private async writeInternal(input: ArtifactWriteInput): Promise { + await this.initialize() + const owner = ownerForCaller(input.caller) + const ownerQuotaKey = ownerKey(owner) + const requestQuotaKey = requestKey(ownerQuotaKey, input.requestId) + const connectionQuotaKey = input.caller.connectionId + const mimeType = normalizeMimeType(input.mimeType) + const requestId = ArtifactMetadataSchema.shape.requestId.parse(input.requestId) + const filename = normalizeFilename(input.suggestedFilename, mimeType) + const ttlMs = positiveInteger(input.ttlMs ?? DEFAULT_ARTIFACT_TTL_MS, 'ttlMs') + if (ttlMs > MAX_ARTIFACT_TTL_MS) { + throw new Error('Artifact expiry is outside the supported range') + } + this.reserveArtifact(ownerQuotaKey, requestQuotaKey, connectionQuotaKey) + + let id = '' + let tempPath = '' + let finalPath = '' + let published = false + let size = 0 + let reservedSize = 0 + try { + id = this.allocateId() + tempPath = path.join(this.options.directory, `.${id}.tmp`) + finalPath = path.join(this.options.directory, `${id}.artifact`) + let metadata: ArtifactMetadata + const handle = await open(tempPath, 'wx', 0o600) + try { + const hash = createHash('sha256') + let position = 0 + for await (const chunk of artifactChunks(input.data)) { + if (chunk.byteLength === 0) continue + if (chunk.byteLength > this.limits.maxArtifactBytes - size) { + throw new CliRequestError( + 'body_too_large', + 'Artifact exceeds the per-file byte limit', + { + httpStatus: 413 + } + ) + } + this.reserveBytes(ownerQuotaKey, requestQuotaKey, connectionQuotaKey, chunk.byteLength) + reservedSize += chunk.byteLength + position = await writeAll(handle, chunk, position) + size += chunk.byteLength + hash.update(chunk) + } + if (size === 0) { + throw new CliRequestError('invalid_request', 'Artifact output is empty') + } + await handle.sync() + const createdAt = this.now() + if (createdAt > Number.MAX_SAFE_INTEGER - ttlMs) { + throw new Error('Artifact expiry is outside the supported range') + } + metadata = ArtifactMetadataSchema.parse({ + id, + requestId, + owner: owner.principal, + mimeType, + size, + sha256: hash.digest('hex'), + filename, + createdAt, + expiresAt: createdAt + ttlMs + }) + } finally { + await handle.close() + } + if (process.platform !== 'win32') await chmod(tempPath, 0o600) + await link(tempPath, finalPath) + published = true + await unlink(tempPath) + tempPath = '' + this.artifacts.set(id, { + metadata, + filePath: finalPath, + ownerKey: ownerQuotaKey, + requestKey: requestQuotaKey, + connectionId: connectionQuotaKey + }) + return metadata + } catch (error) { + if (tempPath) await unlink(tempPath).catch(() => undefined) + if (published && finalPath) await unlink(finalPath).catch(() => undefined) + throw error + } finally { + if (id) this.allocatedIds.delete(id) + this.releaseReservation(ownerQuotaKey, requestQuotaKey, connectionQuotaKey, reservedSize) + } + } + + async describe(id: string, caller: CliRouteCaller): Promise { + await this.initialize() + const artifact = await this.getAuthorizedArtifact(id, caller) + return artifact.metadata + } + + async openRead(id: string, caller: CliRouteCaller): Promise { + await this.initialize() + const artifact = await this.getAuthorizedArtifact(id, caller) + const releaseRead = this.acquireRead(artifact) + try { + const fileStat = await lstat(artifact.filePath).catch(() => null) + if ( + !fileStat?.isFile() || + fileStat.isSymbolicLink() || + fileStat.size !== artifact.metadata.size + ) { + await this.removeStoredArtifact(artifact).catch((error) => { + this.log.warn('[CLI] Failed to remove invalid artifact', error) + }) + throw new CliRequestError('unavailable', 'Artifact data is unavailable', { + httpStatus: 410 + }) + } + const handle = await open(artifact.filePath, 'r').catch(async () => { + await this.removeStoredArtifact(artifact).catch((error) => { + this.log.warn('[CLI] Failed to remove unavailable artifact', error) + }) + throw new CliRequestError('unavailable', 'Artifact data is unavailable', { + httpStatus: 410 + }) + }) + const openedStat = await handle.stat().catch(() => null) + if ( + !openedStat?.isFile() || + openedStat.size !== artifact.metadata.size || + openedStat.dev !== fileStat.dev || + openedStat.ino !== fileStat.ino + ) { + await handle.close().catch(() => undefined) + await this.removeStoredArtifact(artifact).catch((error) => { + this.log.warn('[CLI] Failed to remove changed artifact', error) + }) + throw new CliRequestError('unavailable', 'Artifact data changed before it could be read', { + httpStatus: 410 + }) + } + let stream: ReadStream + try { + stream = handle.createReadStream({ autoClose: true }) + } catch (error) { + await handle.close().catch(() => undefined) + throw error + } + this.openReadStreams.add(stream) + stream.once('close', () => { + this.openReadStreams.delete(stream) + releaseRead() + }) + if (this.closing) stream.destroy() + return { metadata: artifact.metadata, stream } + } catch (error) { + releaseRead() + throw error + } + } + + async delete(id: string, caller: CliRouteCaller): Promise { + await this.initialize() + const artifact = await this.getAuthorizedArtifact(id, caller) + await this.removeStoredArtifact(artifact, 'reject') + } + + async cleanupExpired(): Promise { + if (this.closing) return + await this.initialize() + const now = this.now() + const expired = Array.from(this.artifacts.values()).filter( + (artifact) => artifact.metadata.expiresAt <= now + ) + await Promise.all(expired.map((artifact) => this.removeStoredArtifact(artifact))) + } + + async close(): Promise { + if (this.closePromise) return await this.closePromise + this.closing = true + this.closePromise = this.closeInternal() + return await this.closePromise + } + + private async closeInternal(): Promise { + if (this.initializePromise) await this.initializePromise.catch(() => undefined) + if (this.activeWrites > 0) { + await new Promise((resolve) => this.writeDrainListeners.add(resolve)) + } + for (const stream of this.openReadStreams) stream.destroy() + if (this.activeReads.size > 0) { + await new Promise((resolve) => this.readDrainListeners.add(resolve)) + } + if (this.cleanupTimer) clearInterval(this.cleanupTimer) + this.cleanupTimer = undefined + await Promise.all( + Array.from(this.artifacts.values()).map((artifact) => this.removeStoredArtifact(artifact)) + ) + this.artifacts.clear() + this.activeReads.clear() + this.openReadStreams.clear() + this.pendingRemovalIds.clear() + this.removalPromises.clear() + this.ownerReservations.clear() + this.requestReservations.clear() + this.connectionReservations.clear() + this.allocatedIds.clear() + this.reservedBytes = 0 + this.reservedCount = 0 + } + + private allocateId(): string { + for (let attempt = 0; attempt < 8; attempt += 1) { + const id = this.createId() + if (!/^[A-Za-z0-9_-]{16,128}$/.test(id)) { + throw new Error('Artifact ID generator returned an invalid identifier') + } + if (!this.artifacts.has(id) && !this.allocatedIds.has(id)) { + this.allocatedIds.add(id) + return id + } + } + throw new Error('Unable to allocate a unique artifact ID') + } + + private reserveArtifact(owner: string, request: string, connection: string): void { + const stored = Array.from(this.artifacts.values()) + if ( + this.quotaUsage(stored, 'ownerKey', owner, this.ownerReservations).count + 1 > + this.limits.maxOwnerCount || + this.quotaUsage(stored, 'requestKey', request, this.requestReservations).count + 1 > + this.limits.maxRequestCount || + this.quotaUsage(stored, 'connectionId', connection, this.connectionReservations).count + 1 > + this.limits.maxConnectionCount || + stored.length + this.reservedCount + 1 > this.limits.maxTotalCount + ) { + throw this.quotaError() + } + this.addReservation(this.ownerReservations, owner, 0, 1) + this.addReservation(this.requestReservations, request, 0, 1) + this.addReservation(this.connectionReservations, connection, 0, 1) + this.reservedCount += 1 + } + + private reserveBytes(owner: string, request: string, connection: string, bytes: number): void { + const stored = Array.from(this.artifacts.values()) + if ( + this.quotaUsage(stored, 'ownerKey', owner, this.ownerReservations).bytes + bytes > + this.limits.maxOwnerBytes || + this.quotaUsage(stored, 'requestKey', request, this.requestReservations).bytes + bytes > + this.limits.maxRequestBytes || + this.quotaUsage(stored, 'connectionId', connection, this.connectionReservations).bytes + + bytes > + this.limits.maxConnectionBytes || + stored.reduce((total, artifact) => total + artifact.metadata.size, 0) + + this.reservedBytes + + bytes > + this.limits.maxTotalBytes + ) { + throw this.quotaError() + } + this.addReservation(this.ownerReservations, owner, bytes, 0) + this.addReservation(this.requestReservations, request, bytes, 0) + this.addReservation(this.connectionReservations, connection, bytes, 0) + this.reservedBytes += bytes + } + + private quotaUsage( + artifacts: readonly StoredArtifact[], + key: 'ownerKey' | 'requestKey' | 'connectionId', + value: string, + reservations: ReadonlyMap + ): QuotaReservation { + const stored = artifacts + .filter((artifact) => artifact[key] === value) + .reduce( + (usage, artifact) => ({ + bytes: usage.bytes + artifact.metadata.size, + count: usage.count + 1 + }), + { bytes: 0, count: 0 } + ) + const reserved = reservations.get(value) + return { + bytes: stored.bytes + (reserved?.bytes ?? 0), + count: stored.count + (reserved?.count ?? 0) + } + } + + private addReservation( + reservations: Map, + key: string, + bytes: number, + count: number + ): void { + const reservation = reservations.get(key) ?? { bytes: 0, count: 0 } + reservation.bytes += bytes + reservation.count += count + reservations.set(key, reservation) + } + + private releaseReservation( + owner: string, + request: string, + connection: string, + bytes: number + ): void { + this.subtractReservation(this.ownerReservations, owner, bytes, 1) + this.subtractReservation(this.requestReservations, request, bytes, 1) + this.subtractReservation(this.connectionReservations, connection, bytes, 1) + this.reservedBytes = Math.max(0, this.reservedBytes - bytes) + this.reservedCount = Math.max(0, this.reservedCount - 1) + } + + private subtractReservation( + reservations: Map, + key: string, + bytes: number, + count: number + ): void { + const reservation = reservations.get(key) + if (!reservation) return + reservation.bytes = Math.max(0, reservation.bytes - bytes) + reservation.count = Math.max(0, reservation.count - count) + if (reservation.bytes === 0 && reservation.count === 0) reservations.delete(key) + } + + private quotaError(): CliRequestError { + return new CliRequestError('rate_limited', 'Artifact spool quota is exhausted', { + httpStatus: 429, + retriable: true + }) + } + + private async getAuthorizedArtifact(id: string, caller: CliRouteCaller): Promise { + const artifact = this.artifacts.get(id) + if (!artifact || this.removalPromises.has(id)) { + throw new CliRequestError('not_found', 'Artifact was not found', { httpStatus: 404 }) + } + if (artifact.metadata.expiresAt <= this.now()) { + await this.removeStoredArtifact(artifact) + throw new CliRequestError('not_found', 'Artifact has expired', { httpStatus: 404 }) + } + if (caller.principal === 'agent' && artifact.ownerKey !== ownerKey(ownerForCaller(caller))) { + throw new CliRequestError('permission_denied', 'Artifact belongs to another caller', { + httpStatus: 403 + }) + } + return artifact + } + + private acquireRead(artifact: StoredArtifact): () => void { + const id = artifact.metadata.id + if (this.artifacts.get(id) !== artifact || this.removalPromises.has(id)) { + throw new CliRequestError('not_found', 'Artifact was not found', { httpStatus: 404 }) + } + this.activeReads.set(id, (this.activeReads.get(id) ?? 0) + 1) + let released = false + return () => { + if (released) return + released = true + const remaining = Math.max(0, (this.activeReads.get(id) ?? 1) - 1) + if (remaining > 0) { + this.activeReads.set(id, remaining) + return + } + this.activeReads.delete(id) + if (this.pendingRemovalIds.delete(id)) { + const current = this.artifacts.get(id) + if (current) { + void this.removeStoredArtifact(current).catch((error) => { + this.log.warn('[CLI] Failed to remove released artifact', error) + }) + } + } + if (this.activeReads.size === 0) { + for (const listener of this.readDrainListeners) listener() + this.readDrainListeners.clear() + } + } + } + + private async removeStoredArtifact( + artifact: StoredArtifact, + activeReadBehavior: 'defer' | 'reject' = 'defer' + ): Promise { + if (this.artifacts.get(artifact.metadata.id) !== artifact) return + const id = artifact.metadata.id + const existingRemoval = this.removalPromises.get(id) + if (existingRemoval) return await existingRemoval + if ((this.activeReads.get(id) ?? 0) > 0) { + if (activeReadBehavior === 'reject') { + throw new CliRequestError('conflict', 'Artifact is currently being downloaded', { + httpStatus: 409, + retriable: true + }) + } + this.pendingRemovalIds.add(id) + return + } + + const removal = (async () => { + await unlink(artifact.filePath).catch((error) => { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + }) + if (this.artifacts.get(id) === artifact) this.artifacts.delete(id) + this.pendingRemovalIds.delete(id) + })() + this.removalPromises.set(id, removal) + try { + await removal + } finally { + if (this.removalPromises.get(id) === removal) this.removalPromises.delete(id) + } + } +} diff --git a/src/main/cli/index.ts b/src/main/cli/index.ts index 96a1231d5..4bc59e960 100644 --- a/src/main/cli/index.ts +++ b/src/main/cli/index.ts @@ -1,3 +1,5 @@ export { CliServer, type CliServerDependencies } from './server' +export { ArtifactSpool, type ArtifactSpoolOptions } from './artifactSpool' +export { createArtifactRoutes } from './artifactRoutes' export { createCliRoutes, type CliRuntimeStatus } from './routes' export { CLI_SURFACE_V1, getCliSurfaceEntry, listCliSurfaceCapabilities } from './surface' diff --git a/src/main/cli/server.ts b/src/main/cli/server.ts index a951db664..ea4ef4860 100644 --- a/src/main/cli/server.ts +++ b/src/main/cli/server.ts @@ -2,10 +2,13 @@ import { createHash, randomUUID, timingSafeEqual } from 'node:crypto' import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' import type { Socket } from 'node:net' import path from 'node:path' +import { pipeline } from 'node:stream/promises' import { z } from 'zod' +import { ArtifactIdSchema, artifactsReadRoute } from '@shared/contracts/routes' import { JsonValueSchema, TimestampMsSchema, type JsonValue } from '@shared/contracts/common' import { LOCAL_CONTROL_DESCRIPTOR_FILENAME, + LOCAL_CONTROL_ARTIFACT_PATH_PREFIX, LOCAL_CONTROL_PROTOCOL_VERSION, LOCAL_CONTROL_RPC_PATH, LOCAL_CONTROL_SCOPES, @@ -30,7 +33,9 @@ import { } from './descriptor' import { CliRequestError } from './errors' import { CLI_SURFACE_V1, getCliSurfaceEntry } from './surface' +import type { CliSurfaceEntry } from './surface' import type { CliRuntimeStatus } from './routes' +import type { ArtifactSpool } from './artifactSpool' const MAX_HEADER_BYTES = 8 * 1024 const MAX_CONNECTIONS = 64 @@ -59,6 +64,7 @@ export type CliServerDependencies = Readonly<{ signal: AbortSignal ): Promise resolveAgentToken?(token: string): AgentCliToken | null + artifactSpool?: ArtifactSpool now?: () => number platform?: NodeJS.Platform pid?: number @@ -107,6 +113,16 @@ function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value) } +function artifactContentDisposition(filename: string): string { + const ascii = filename.replace(/[^\x20-\x7e]|["\\]/g, '_') + const wellFormedFilename = Buffer.from(filename, 'utf8').toString('utf8') + const encoded = encodeURIComponent(wellFormedFilename).replace( + /[!'()*]/g, + (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}` + ) + return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}` +} + function requestAbortError(signal: AbortSignal): CliRequestError { return signal.reason instanceof CliRequestError ? signal.reason @@ -382,7 +398,10 @@ export class CliServer { response.setHeader('X-Content-Type-Options', 'nosniff') const connectionId = this.connectionIds.get(request.socket) ?? randomUUID() - if (request.method !== 'POST' || request.url !== LOCAL_CONTROL_RPC_PATH) { + const isRpcRequest = request.method === 'POST' && request.url === LOCAL_CONTROL_RPC_PATH + const isArtifactRequest = + request.method === 'GET' && request.url?.startsWith(LOCAL_CONTROL_ARTIFACT_PATH_PREFIX) + if (!isRpcRequest && !isArtifactRequest) { this.sendFailure( response, 404, @@ -417,6 +436,10 @@ export class CliServer { ) return } + if (isArtifactRequest) { + await this.handleArtifactDownload(request, response, caller) + return + } if (!requestContentTypeIsJson(request)) { this.sendFailure( response, @@ -500,16 +523,7 @@ export class CliServer { httpStatus: 413 }) } - if (!entry.callers.includes(caller.principal)) { - throw new CliRequestError('permission_denied', 'Caller is not allowed for method', { - httpStatus: 403 - }) - } - if (!entry.scopes.every((scope) => caller.scopes.includes(scope))) { - throw new CliRequestError('permission_denied', 'Required scope is missing', { - httpStatus: 403 - }) - } + this.assertSurfaceAccess(entry, caller) const parsedInput = entry.contract.input.safeParse(rpcRequest.params) if (!parsedInput.success) { @@ -572,6 +586,142 @@ export class CliServer { } } + private assertSurfaceAccess(entry: CliSurfaceEntry, caller: CliRouteCaller): void { + if (!entry.callers.includes(caller.principal)) { + throw new CliRequestError('permission_denied', 'Caller is not allowed for method', { + httpStatus: 403 + }) + } + if (!entry.scopes.every((scope) => caller.scopes.includes(scope))) { + throw new CliRequestError('permission_denied', 'Required scope is missing', { + httpStatus: 403 + }) + } + } + + private async handleArtifactDownload( + request: IncomingMessage, + response: ServerResponse, + caller: CliRouteCaller + ): Promise { + const rawId = request.url?.slice(LOCAL_CONTROL_ARTIFACT_PATH_PREFIX.length) ?? '' + const parsedId = ArtifactIdSchema.safeParse(rawId) + const entry = getCliSurfaceEntry(artifactsReadRoute.name) + if (!parsedId.success || !entry || entry.transport !== 'download') { + this.sendFailure( + response, + 404, + UNKNOWN_REQUEST_ID, + new CliRequestError('not_found', 'Artifact endpoint was not found', { httpStatus: 404 }) + ) + return + } + const contentLength = request.headers['content-length'] + if ( + request.headers['transfer-encoding'] !== undefined || + (contentLength !== undefined && contentLength !== '0') + ) { + this.sendFailure( + response, + 400, + UNKNOWN_REQUEST_ID, + new CliRequestError('invalid_request', 'Artifact downloads do not accept a request body') + ) + return + } + + const connectionPending = this.pendingByConnection.get(caller.connectionId) ?? 0 + if ( + this.pendingRequests >= MAX_PENDING_REQUESTS || + connectionPending >= MAX_PENDING_PER_CONNECTION + ) { + this.sendFailure( + response, + 429, + UNKNOWN_REQUEST_ID, + new CliRequestError('rate_limited', 'Too many pending local-control requests', { + httpStatus: 429, + retriable: true + }) + ) + return + } + + this.pendingRequests += 1 + this.pendingByConnection.set(caller.connectionId, connectionPending + 1) + const controller = new AbortController() + this.requestControllers.add(controller) + const abort = () => { + abortRequest(controller, new CliRequestError('cancelled', 'Request was cancelled')) + } + const abortOnIncompleteResponse = () => { + if (!response.writableEnded) abort() + } + request.once('aborted', abort) + response.once('close', abortOnIncompleteResponse) + const timeout = setTimeout(() => { + abortRequest( + controller, + new CliRequestError('timeout', 'Request timed out', { + httpStatus: 504, + retriable: true + }) + ) + }, entry.limits.timeoutMs) + timeout.unref() + + try { + this.assertSurfaceAccess(entry, caller) + if (!this.dependencies.artifactSpool) { + throw new CliRequestError('unavailable', 'Artifact service is unavailable', { + httpStatus: 503, + retriable: true + }) + } + const artifact = await this.dependencies.artifactSpool.openRead(parsedId.data, caller) + if (controller.signal.aborted) { + artifact.stream.destroy() + throw requestAbortError(controller.signal) + } + response.statusCode = 200 + response.setHeader('Content-Type', artifact.metadata.mimeType) + response.setHeader('Content-Length', artifact.metadata.size) + response.setHeader( + 'Content-Disposition', + artifactContentDisposition(artifact.metadata.filename) + ) + response.setHeader('X-DeepChat-Artifact-Id', artifact.metadata.id) + response.setHeader('X-Content-SHA256', artifact.metadata.sha256) + await pipeline(artifact.stream, response, { signal: controller.signal }) + } catch (error) { + const failure = controller.signal.aborted ? requestAbortError(controller.signal) : error + if (failure instanceof CliRequestError && !response.headersSent) { + this.sendFailure(response, failure.httpStatus, UNKNOWN_REQUEST_ID, failure) + return + } + if (!response.headersSent) { + this.log.warn('[CLI] Artifact download failed', failure) + this.sendFailure( + response, + 500, + UNKNOWN_REQUEST_ID, + new CliRequestError('internal_error', 'Artifact download failed', { httpStatus: 500 }) + ) + return + } + if (!response.destroyed) response.destroy() + } finally { + clearTimeout(timeout) + request.off('aborted', abort) + response.off('close', abortOnIncompleteResponse) + this.requestControllers.delete(controller) + this.pendingRequests = Math.max(0, this.pendingRequests - 1) + const remaining = (this.pendingByConnection.get(caller.connectionId) ?? 1) - 1 + if (remaining > 0) this.pendingByConnection.set(caller.connectionId, remaining) + else this.pendingByConnection.delete(caller.connectionId) + } + } + private sendFailure( response: ServerResponse, status: number, diff --git a/src/main/cli/surface.ts b/src/main/cli/surface.ts index 24cc6cfeb..bc911b926 100644 --- a/src/main/cli/surface.ts +++ b/src/main/cli/surface.ts @@ -1,5 +1,8 @@ import type { RouteContract } from '@shared/contracts/contract' import { + artifactsDeleteRoute, + artifactsDescribeRoute, + artifactsReadRoute, cliCapabilitiesRoute, cliDoctorRoute, cliStatusRoute, @@ -12,7 +15,7 @@ import type { LocalControlScope } from '@shared/contracts/localControl' -export type LocalControlTransport = 'rpc' | 'stream' | 'upload' +export type LocalControlTransport = 'rpc' | 'stream' | 'upload' | 'download' export type LocalControlApprovalMode = 'never' | 'policy' export type CliRouteLimits = Readonly<{ @@ -46,6 +49,33 @@ const diagnosticEntry = (contract: RouteContract): CliSurfaceEntry => ({ }) const CLI_SURFACE_V1_ENTRIES = [ + { + contract: artifactsDescribeRoute, + effect: 'read', + callers: ['human', 'agent'], + scopes: ['artifacts:read'], + transport: 'rpc', + approval: 'never', + limits: DIAGNOSTIC_LIMITS + }, + { + contract: artifactsReadRoute, + effect: 'read', + callers: ['human'], + scopes: ['artifacts:read'], + transport: 'download', + approval: 'never', + limits: { maxBodyBytes: 1, timeoutMs: 5 * 60_000 } + }, + { + contract: artifactsDeleteRoute, + effect: 'local-maintenance', + callers: ['human'], + scopes: ['artifacts:manage'], + transport: 'rpc', + approval: 'never', + limits: DIAGNOSTIC_LIMITS + }, diagnosticEntry(cliStatusRoute), diagnosticEntry(cliVersionRoute), diagnosticEntry(cliCapabilitiesRoute), diff --git a/src/shared/contracts/localControl.ts b/src/shared/contracts/localControl.ts index 9fdcbb37a..d1f3dbb73 100644 --- a/src/shared/contracts/localControl.ts +++ b/src/shared/contracts/localControl.ts @@ -5,6 +5,7 @@ export const LOCAL_CONTROL_PROTOCOL_VERSION = 1 as const export const LOCAL_CONTROL_SURFACE_VERSION = 1 as const export const LOCAL_CONTROL_DESCRIPTOR_FILENAME = 'local-control.json' export const LOCAL_CONTROL_RPC_PATH = '/v1/rpc' +export const LOCAL_CONTROL_ARTIFACT_PATH_PREFIX = '/v1/artifacts/' export const LOCAL_CONTROL_AGENT_TOKEN_ENV = 'DEEPCHAT_CLI_AGENT_TOKEN' export const LOCAL_CONTROL_EFFECTS = [ diff --git a/src/shared/contracts/routes.ts b/src/shared/contracts/routes.ts index 3163a2e54..bc2de6b35 100644 --- a/src/shared/contracts/routes.ts +++ b/src/shared/contracts/routes.ts @@ -1,5 +1,10 @@ import type { z } from 'zod' import type { RouteContract } from './common' +import { + artifactsDeleteRoute, + artifactsDescribeRoute, + artifactsReadRoute +} from './routes/artifacts.routes' import { acpTerminalInputRoute, acpTerminalKillRoute } from './routes/acp-terminal.routes' import { browserAttachCurrentWindowRoute, @@ -560,6 +565,7 @@ import { } from './routes/orchestration.routes' export * from './routes/browser.routes' +export * from './routes/artifacts.routes' export * from './routes/computerUse.routes' export * from './routes/acp-terminal.routes' export * from './routes/chat.routes' @@ -940,6 +946,9 @@ const DEEPCHAT_ROUTE_CATALOG_PART_4 = { } satisfies Record const DEEPCHAT_ROUTE_CATALOG_PART_5 = { + [artifactsDescribeRoute.name]: artifactsDescribeRoute, + [artifactsReadRoute.name]: artifactsReadRoute, + [artifactsDeleteRoute.name]: artifactsDeleteRoute, [cliStatusRoute.name]: cliStatusRoute, [cliVersionRoute.name]: cliVersionRoute, [cliCapabilitiesRoute.name]: cliCapabilitiesRoute, diff --git a/src/shared/contracts/routes/artifacts.routes.ts b/src/shared/contracts/routes/artifacts.routes.ts new file mode 100644 index 000000000..41eb7cda3 --- /dev/null +++ b/src/shared/contracts/routes/artifacts.routes.ts @@ -0,0 +1,61 @@ +import { z } from 'zod' +import { defineRouteContract } from '../contract' +import { TimestampMsSchema } from '../json' + +const MAX_DATE_TIMESTAMP_MS = 8_640_000_000_000_000 + +export const ArtifactIdSchema = z + .string() + .min(16) + .max(128) + .regex(/^[A-Za-z0-9_-]+$/) + +export const ArtifactMetadataSchema = z + .object({ + id: ArtifactIdSchema, + requestId: z + .string() + .min(1) + .max(128) + .regex(/^[A-Za-z0-9._:-]+$/), + owner: z.enum(['human', 'agent']), + mimeType: z + .string() + .min(3) + .max(255) + .regex( + /^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+(?:;\s*[A-Za-z0-9!#$&^_.+-]+=[A-Za-z0-9!#$&^_.+-]+)*$/ + ), + size: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + sha256: z + .string() + .length(64) + .regex(/^[a-f0-9]+$/), + filename: z.string().min(1).max(255), + createdAt: TimestampMsSchema.max(MAX_DATE_TIMESTAMP_MS), + expiresAt: TimestampMsSchema.max(MAX_DATE_TIMESTAMP_MS) + }) + .strict() + .refine((artifact) => artifact.expiresAt > artifact.createdAt, { + message: 'Artifact expiry must be later than creation' + }) + +export const artifactsDescribeRoute = defineRouteContract({ + name: 'artifacts.describe', + input: z.object({ id: ArtifactIdSchema }).strict(), + output: z.object({ artifact: ArtifactMetadataSchema }).strict() +}) + +export const artifactsReadRoute = defineRouteContract({ + name: 'artifacts.read', + input: z.object({ id: ArtifactIdSchema }).strict(), + output: z.object({ artifact: ArtifactMetadataSchema }).strict() +}) + +export const artifactsDeleteRoute = defineRouteContract({ + name: 'artifacts.delete', + input: z.object({ id: ArtifactIdSchema }).strict(), + output: z.object({ deleted: z.literal(true) }).strict() +}) + +export type ArtifactMetadata = z.infer diff --git a/src/shared/contracts/routes/cli.routes.ts b/src/shared/contracts/routes/cli.routes.ts index 42276845b..c54031797 100644 --- a/src/shared/contracts/routes/cli.routes.ts +++ b/src/shared/contracts/routes/cli.routes.ts @@ -9,7 +9,7 @@ import { LocalControlScopeSchema } from '../localControl' -export const LocalControlTransportSchema = z.enum(['rpc', 'stream', 'upload']) +export const LocalControlTransportSchema = z.enum(['rpc', 'stream', 'upload', 'download']) export const LocalControlApprovalModeSchema = z.enum(['never', 'policy']) export const LocalControlCapabilitySchema = z diff --git a/test/main/cli/args.test.ts b/test/main/cli/args.test.ts index 9b6fbea6a..1e13aaf52 100644 --- a/test/main/cli/args.test.ts +++ b/test/main/cli/args.test.ts @@ -59,4 +59,28 @@ describe('CLI argument grammar', () => { }) expect(() => parseCliArguments(['--help'], {})).toThrow('deepchat ') }) + + it('parses artifact ownership commands without accepting output flags on metadata operations', () => { + const id = 'artifact_identifier_123' + + expect( + parseCliArguments(['artifact', 'get', '--id', id, '--out', './image.png', '--overwrite'], {}) + ).toMatchObject({ + operation: 'download', + params: { id }, + outputPath: './image.png', + overwrite: true + }) + expect(parseCliArguments(['artifact', 'describe', `--id=${id}`], {})).toMatchObject({ + operation: 'rpc', + params: { id } + }) + expect(() => + parseCliArguments(['artifact', 'delete', '--id', id, '--out', './invalid'], {}) + ).toThrow('only valid for deepchat artifact get') + expect(() => parseCliArguments(['artifact', 'get', '--id', id], {})).toThrow('requires --out') + expect(() => + parseCliArguments(['artifact', 'get', '--id', id, '--out', '--overwrite'], {}) + ).toThrow('Missing value for --out') + }) }) diff --git a/test/main/cli/artifactSpool.test.ts b/test/main/cli/artifactSpool.test.ts new file mode 100644 index 000000000..a778d3ce8 --- /dev/null +++ b/test/main/cli/artifactSpool.test.ts @@ -0,0 +1,250 @@ +import { mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { ArtifactSpool } from '@/cli/artifactSpool' +import type { AgentCliRouteCaller, HumanCliRouteCaller } from '@/routes/routeRegistry' + +const spools: ArtifactSpool[] = [] +const temporaryDirectories: string[] = [] + +const humanCaller: HumanCliRouteCaller = { + kind: 'cli', + principal: 'human', + connectionId: 'human-connection', + scopes: ['artifacts:read', 'artifacts:manage'] +} + +const agentCaller = (conversationId: string): AgentCliRouteCaller => ({ + kind: 'cli', + principal: 'agent', + connectionId: `agent-${conversationId}`, + conversationId, + expiresAt: Date.now() + 60_000, + scopes: ['artifacts:read'] +}) + +async function createSpool( + options: Omit[0], 'directory'> = {} +): Promise<{ spool: ArtifactSpool; directory: string }> { + const root = await mkdtemp(path.join(os.tmpdir(), 'deepchat-artifact-spool-')) + temporaryDirectories.push(root) + const directory = path.join(root, 'artifacts') + const spool = new ArtifactSpool({ directory, ...options }) + spools.push(spool) + return { spool, directory } +} + +async function collect(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = [] + for await (const chunk of stream) chunks.push(Buffer.from(chunk)) + return Buffer.concat(chunks) +} + +afterEach(async () => { + await Promise.allSettled(spools.splice(0).map((spool) => spool.close())) + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })) + ) +}) + +describe('ArtifactSpool', () => { + it('publishes private metadata and bytes without exposing a path', async () => { + const { spool, directory } = await createSpool() + const metadata = await spool.write({ + caller: humanCaller, + requestId: 'request-1', + mimeType: 'audio/ogg; codecs=opus', + suggestedFilename: '../voice.ogg', + data: Buffer.from('generated-audio') + }) + + expect(metadata).toMatchObject({ + owner: 'human', + mimeType: 'audio/ogg; codecs=opus', + filename: '.._voice.ogg', + size: 15 + }) + expect(metadata).not.toHaveProperty('path') + if (process.platform !== 'win32') { + expect((await stat(directory)).mode & 0o777).toBe(0o700) + } + + const opened = await spool.openRead(metadata.id, humanCaller) + expect(await collect(opened.stream)).toEqual(Buffer.from('generated-audio')) + expect(await readFile(path.join(directory, `${metadata.id}.artifact`), 'utf8')).toBe( + 'generated-audio' + ) + }) + + it('streams writes while hashing and accounting the complete result', async () => { + const { spool } = await createSpool() + async function* chunks(): AsyncGenerator { + yield Buffer.from('generated-') + yield new Uint8Array() + yield Buffer.from('video') + } + + const metadata = await spool.write({ + caller: humanCaller, + requestId: 'request-stream', + mimeType: 'video/mp4', + data: chunks() + }) + + expect(metadata).toMatchObject({ size: 15, filename: 'artifact.mp4' }) + const opened = await spool.openRead(metadata.id, humanCaller) + expect(await collect(opened.stream)).toEqual(Buffer.from('generated-video')) + }) + + it('isolates Agent artifacts by conversation while allowing human recovery', async () => { + const { spool } = await createSpool() + const owner = agentCaller('conversation-a') + const metadata = await spool.write({ + caller: owner, + requestId: 'request-1', + mimeType: 'image/png', + data: Buffer.from('png') + }) + + await expect(spool.describe(metadata.id, owner)).resolves.toEqual(metadata) + await expect(spool.describe(metadata.id, agentCaller('conversation-b'))).rejects.toMatchObject({ + code: 'permission_denied' + }) + await expect(spool.describe(metadata.id, humanCaller)).resolves.toEqual(metadata) + }) + + it('accounts in-flight writes before enforcing aggregate quotas', async () => { + const { spool } = await createSpool({ + limits: { + maxArtifactBytes: 8, + maxOwnerBytes: 10, + maxTotalBytes: 10, + maxOwnerCount: 2, + maxTotalCount: 2 + } + }) + + const writes = await Promise.allSettled([ + spool.write({ + caller: humanCaller, + requestId: 'request-1', + mimeType: 'application/octet-stream', + data: Buffer.alloc(6, 1) + }), + spool.write({ + caller: humanCaller, + requestId: 'request-2', + mimeType: 'application/octet-stream', + data: Buffer.alloc(6, 2) + }) + ]) + + expect(writes.filter((result) => result.status === 'fulfilled')).toHaveLength(1) + expect(writes.filter((result) => result.status === 'rejected')).toHaveLength(1) + expect(writes.find((result) => result.status === 'rejected')).toMatchObject({ + reason: { code: 'rate_limited' } + }) + }) + + it('enforces request and connection quotas independently', async () => { + const { spool } = await createSpool({ + limits: { + maxArtifactBytes: 8, + maxRequestBytes: 8, + maxConnectionBytes: 12, + maxOwnerBytes: 32, + maxTotalBytes: 32, + maxRequestCount: 2, + maxConnectionCount: 4, + maxOwnerCount: 8, + maxTotalCount: 8 + } + }) + await spool.write({ + caller: humanCaller, + requestId: 'request-1', + mimeType: 'application/octet-stream', + data: Buffer.alloc(6) + }) + + await expect( + spool.write({ + caller: humanCaller, + requestId: 'request-1', + mimeType: 'application/octet-stream', + data: Buffer.alloc(3) + }) + ).rejects.toMatchObject({ code: 'rate_limited' }) + await spool.write({ + caller: humanCaller, + requestId: 'request-2', + mimeType: 'application/octet-stream', + data: Buffer.alloc(6) + }) + await expect( + spool.write({ + caller: humanCaller, + requestId: 'request-3', + mimeType: 'application/octet-stream', + data: Buffer.alloc(1) + }) + ).rejects.toMatchObject({ code: 'rate_limited' }) + }) + + it('expires artifacts and removes their files', async () => { + let now = 1_000 + const { spool, directory } = await createSpool({ now: () => now }) + const metadata = await spool.write({ + caller: humanCaller, + requestId: 'request-1', + mimeType: 'text/plain', + data: Buffer.from('temporary'), + ttlMs: 10 + }) + + now = 1_011 + await expect(spool.describe(metadata.id, humanCaller)).rejects.toMatchObject({ + code: 'not_found' + }) + expect(await readdir(directory)).toEqual([]) + }) + + it('keeps active downloads stable and rejects concurrent deletion', async () => { + const { spool } = await createSpool() + const metadata = await spool.write({ + caller: humanCaller, + requestId: 'request-1', + mimeType: 'application/octet-stream', + data: Buffer.alloc(64 * 1024, 1) + }) + const opened = await spool.openRead(metadata.id, humanCaller) + + await expect(spool.delete(metadata.id, humanCaller)).rejects.toMatchObject({ + code: 'conflict', + retriable: true + }) + expect(await collect(opened.stream)).toHaveLength(metadata.size) + await expect(spool.delete(metadata.id, humanCaller)).resolves.toBeUndefined() + await expect(spool.describe(metadata.id, humanCaller)).rejects.toMatchObject({ + code: 'not_found' + }) + }) + + it('cleans only spool-owned crash remnants during initialization', async () => { + const { spool, directory } = await createSpool() + await writeFile(path.join(path.dirname(directory), 'keep.txt'), 'outside') + await spool.initialize() + await writeFile(path.join(directory, '.abcdefghijklmnop.tmp'), 'partial') + await writeFile(path.join(directory, 'abcdefghijklmnop.artifact'), 'stale') + await writeFile(path.join(directory, 'keep.txt'), 'foreign') + await spool.close() + + const replacement = new ArtifactSpool({ directory }) + spools.push(replacement) + await replacement.initialize() + + expect(await readdir(directory)).toEqual(['keep.txt']) + expect(await readFile(path.join(path.dirname(directory), 'keep.txt'), 'utf8')).toBe('outside') + }) +}) diff --git a/test/main/cli/artifacts.test.ts b/test/main/cli/artifacts.test.ts new file mode 100644 index 000000000..13692576a --- /dev/null +++ b/test/main/cli/artifacts.test.ts @@ -0,0 +1,220 @@ +import { EventEmitter } from 'node:events' +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { DeepchatRouteName } from '@shared/contracts/routes' +import { + LOCAL_CONTROL_AGENT_TOKEN_ENV, + LocalControlRpcResponseSchema +} from '@shared/contracts/localControl' +import { ArtifactSpool } from '@/cli/artifactSpool' +import { createArtifactRoutes } from '@/cli/artifactRoutes' +import { CliServer } from '@/cli/server' +import type { CliRouteCaller, HumanCliRouteCaller } from '@/routes/routeRegistry' +import { runCli } from '../../../src/cli/run' + +const servers: CliServer[] = [] +const spools: ArtifactSpool[] = [] +const temporaryDirectories: string[] = [] + +const originCaller: HumanCliRouteCaller = { + kind: 'cli', + principal: 'human', + connectionId: 'origin', + scopes: ['artifacts:read', 'artifacts:manage'] +} + +function captureOutput(): { stream: NodeJS.WriteStream; read(): string } { + let value = '' + return { + stream: { + write: (chunk: string | Uint8Array) => { + value += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8') + return true + } + } as NodeJS.WriteStream, + read: () => value + } +} + +async function createHarness(): Promise<{ + userDataPath: string + spool: ArtifactSpool +}> { + const userDataPath = await mkdtemp(path.join(os.tmpdir(), 'deepchat-cli-artifacts-')) + temporaryDirectories.push(userDataPath) + const spool = new ArtifactSpool({ + directory: path.join(userDataPath, 'local-control', 'artifacts') + }) + spools.push(spool) + await spool.initialize() + const routes = createArtifactRoutes(spool) + const server = new CliServer({ + userDataPath, + appVersion: '1.2.3', + artifactSpool: spool, + dispatch: async (method: string, input: unknown, caller: CliRouteCaller) => { + const route = routes.get(method as DeepchatRouteName) + if (!route) throw new Error(`Unknown artifact test route: ${method}`) + return await route(input, { caller }) + }, + log: { warn: vi.fn(), error: vi.fn() } + }) + servers.push(server) + await server.start() + return { userDataPath, spool } +} + +function invoke(argv: readonly string[], env: NodeJS.ProcessEnv) { + const stdout = captureOutput() + const stderr = captureOutput() + return { + result: runCli(argv, { + env, + stdout: stdout.stream, + stderr: stderr.stream, + signalHost: new EventEmitter() as unknown as NodeJS.Process, + randomId: () => 'request-1', + forceExit: vi.fn() + }), + stdout, + stderr + } +} + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => server.stop())) + await Promise.allSettled(spools.splice(0).map((spool) => spool.close())) + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })) + ) +}) + +describe('artifact CLI', () => { + it('downloads verified bytes and emits the canonical machine envelope', async () => { + const { userDataPath, spool } = await createHarness() + const artifact = await spool.write({ + caller: originCaller, + requestId: 'generation-1', + mimeType: 'image/png', + suggestedFilename: 'generated.png', + data: Buffer.from('generated-image') + }) + const outputPath = path.join(userDataPath, 'result.png') + const invocation = invoke( + ['artifact', 'get', '--id', artifact.id, '--out', outputPath, '--json'], + { DEEPCHAT_E2E_USER_DATA_DIR: userDataPath } + ) + + await expect(invocation.result).resolves.toBe(0) + expect(await readFile(outputPath, 'utf8')).toBe('generated-image') + expect(LocalControlRpcResponseSchema.parse(JSON.parse(invocation.stdout.read()))).toMatchObject( + { + ok: true, + result: { artifact: { id: artifact.id, sha256: artifact.sha256 } } + } + ) + expect(invocation.stderr.read()).toBe('') + }) + + it('preserves an existing file unless overwrite is explicit', async () => { + const { userDataPath, spool } = await createHarness() + const artifact = await spool.write({ + caller: originCaller, + requestId: 'generation-1', + mimeType: 'application/octet-stream', + data: Buffer.from('replacement') + }) + const outputPath = path.join(userDataPath, 'existing.bin') + await writeFile(outputPath, 'original') + + const refused = invoke(['artifact', 'get', '--id', artifact.id, '--out', outputPath], { + DEEPCHAT_E2E_USER_DATA_DIR: userDataPath + }) + await expect(refused.result).resolves.toBe(6) + expect(await readFile(outputPath, 'utf8')).toBe('original') + + const replaced = invoke( + ['artifact', 'get', '--id', artifact.id, '--out', outputPath, '--overwrite'], + { DEEPCHAT_E2E_USER_DATA_DIR: userDataPath } + ) + await expect(replaced.result).resolves.toBe(0) + expect(await readFile(outputPath, 'utf8')).toBe('replacement') + }) + + it('rejects changed bytes and removes partial output', async () => { + const { userDataPath, spool } = await createHarness() + const artifact = await spool.write({ + caller: originCaller, + requestId: 'generation-1', + mimeType: 'application/octet-stream', + data: Buffer.from('expected') + }) + await writeFile( + path.join(userDataPath, 'local-control', 'artifacts', `${artifact.id}.artifact`), + 'tampered' + ) + const outputPath = path.join(userDataPath, 'changed.bin') + const invocation = invoke(['artifact', 'get', '--id', artifact.id, '--out', outputPath], { + DEEPCHAT_E2E_USER_DATA_DIR: userDataPath + }) + + await expect(invocation.result).resolves.toBe(8) + await expect(readFile(outputPath)).rejects.toMatchObject({ code: 'ENOENT' }) + expect((await readdir(userDataPath)).filter((entry) => entry.startsWith('.deepchat-'))).toEqual( + [] + ) + }) + + it('rejects Agent byte downloads before route dispatch', async () => { + const { userDataPath, spool } = await createHarness() + const artifact = await spool.write({ + caller: originCaller, + requestId: 'generation-1', + mimeType: 'image/png', + data: Buffer.from('private') + }) + const outputPath = path.join(userDataPath, 'agent-output.png') + const invocation = invoke( + ['artifact', 'get', '--id', artifact.id, '--out', outputPath, '--json'], + { + DEEPCHAT_E2E_USER_DATA_DIR: userDataPath, + [LOCAL_CONTROL_AGENT_TOKEN_ENV]: 'a'.repeat(43) + } + ) + + await expect(invocation.result).resolves.toBe(4) + expect(LocalControlRpcResponseSchema.parse(JSON.parse(invocation.stdout.read()))).toMatchObject( + { + ok: false, + error: { code: 'permission_denied' } + } + ) + await expect(readFile(outputPath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('describes and deletes artifacts through typed RPC routes', async () => { + const { userDataPath, spool } = await createHarness() + const artifact = await spool.write({ + caller: originCaller, + requestId: 'generation-1', + mimeType: 'text/plain', + data: Buffer.from('result') + }) + const env = { DEEPCHAT_E2E_USER_DATA_DIR: userDataPath } + + const described = invoke(['artifact', 'describe', '--id', artifact.id, '--json'], env) + await expect(described.result).resolves.toBe(0) + expect(LocalControlRpcResponseSchema.parse(JSON.parse(described.stdout.read()))).toMatchObject({ + ok: true, + result: { artifact: { id: artifact.id } } + }) + + const deleted = invoke(['artifact', 'delete', '--id', artifact.id], env) + await expect(deleted.result).resolves.toBe(0) + await expect(spool.describe(artifact.id, originCaller)).rejects.toMatchObject({ + code: 'not_found' + }) + }) +}) diff --git a/test/main/cli/server.test.ts b/test/main/cli/server.test.ts index 53b6d6010..f1215421a 100644 --- a/test/main/cli/server.test.ts +++ b/test/main/cli/server.test.ts @@ -253,6 +253,46 @@ describe('CLI local transport', () => { expect(dispatch).not.toHaveBeenCalled() }) + it('rejects request bodies on artifact download endpoints', async () => { + const { descriptor } = await createTestServer() + const body = await new Promise((resolve, reject) => { + const request = httpRequest( + { + socketPath: + descriptor.endpoint.kind === 'unix' + ? descriptor.endpoint.path + : descriptor.endpoint.name, + path: '/v1/artifacts/abcdefghijklmnop', + method: 'GET', + headers: { + authorization: `Bearer ${descriptor.token}`, + 'content-length': 1 + } + }, + (response) => { + const chunks: Buffer[] = [] + response.on('data', (chunk: Buffer) => chunks.push(chunk)) + response.once('error', reject) + response.once('end', () => { + try { + resolve( + LocalControlRpcResponseSchema.parse( + JSON.parse(Buffer.concat(chunks).toString('utf8')) + ) + ) + } catch (error) { + reject(error) + } + }) + } + ) + request.once('error', reject) + request.end('x') + }) + + expect(body).toMatchObject({ ok: false, error: { code: 'invalid_request' } }) + }) + it('applies agent expiry and scopes independently of the bearer token', async () => { let scopes: readonly LocalControlScope[] = ['models:read'] let expiresAt = Date.now() - 1 diff --git a/test/main/cli/surface.test.ts b/test/main/cli/surface.test.ts index 1033890a0..dc9838e92 100644 --- a/test/main/cli/surface.test.ts +++ b/test/main/cli/surface.test.ts @@ -6,7 +6,15 @@ describe('CLI surface V1', () => { it('contains only explicit canonical route contracts', () => { const methods = Array.from(CLI_SURFACE_V1.keys()).sort() - expect(methods).toEqual(['cli.capabilities', 'cli.doctor', 'cli.status', 'cli.version']) + expect(methods).toEqual([ + 'artifacts.delete', + 'artifacts.describe', + 'artifacts.read', + 'cli.capabilities', + 'cli.doctor', + 'cli.status', + 'cli.version' + ]) for (const [method, entry] of CLI_SURFACE_V1) { expect(entry.contract).toBe( DEEPCHAT_ROUTE_CATALOG[method as keyof typeof DEEPCHAT_ROUTE_CATALOG] @@ -22,19 +30,16 @@ describe('CLI surface V1', () => { it('publishes stable sorted capability metadata', () => { expect(listCliSurfaceCapabilities()).toEqual([ + expect.objectContaining({ method: 'artifacts.delete', effect: 'local-maintenance' }), + expect.objectContaining({ method: 'artifacts.describe', effect: 'read' }), + expect.objectContaining({ method: 'artifacts.read', effect: 'read', transport: 'download' }), expect.objectContaining({ method: 'cli.capabilities', effect: 'read' }), expect.objectContaining({ method: 'cli.doctor', effect: 'read' }), expect.objectContaining({ method: 'cli.status', effect: 'read' }), expect.objectContaining({ method: 'cli.version', effect: 'read' }) ]) expect( - listCliSurfaceCapabilities().every( - (capability) => - capability.callers.join(',') === 'human,agent' && - capability.scopes.join(',') === 'system:read' && - capability.transport === 'rpc' && - capability.approval === 'never' - ) + listCliSurfaceCapabilities().every((capability) => capability.approval === 'never') ).toBe(true) }) }) From 2505dd5179ecdf9c077ee729579789cd6c7ef6cb Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 11:47:29 +0800 Subject: [PATCH 07/51] feat(provider): add standalone speech --- src/main/provider/index.ts | 89 ++++++++++++++++++++++ src/shared/types/provider.ts | 15 ++++ test/main/provider/providerRuntime.test.ts | 47 ++++++++++++ 3 files changed, 151 insertions(+) diff --git a/src/main/provider/index.ts b/src/main/provider/index.ts index 4926989ec..6b1305328 100644 --- a/src/main/provider/index.ts +++ b/src/main/provider/index.ts @@ -13,6 +13,7 @@ import type { KeyStatus, LLM_EMBEDDING_ATTRS, StandaloneImageGenerationResult, + StandaloneSpeechGenerationResult, StandaloneVideoGenerationResult, ModelScopeMcpSyncOptions, ModelScopeMcpSyncResult, @@ -29,6 +30,7 @@ import { normalizeVideoGenerationOptions, type VideoGenerationOptions } from '@shared/videoGenerationSettings' +import { normalizeTtsSettings, type TtsSettings } from '@shared/ttsSettings' import { ProviderChange, ProviderBatchUpdate } from '@shared/provider-operations' import { isProviderDbBackedProvider } from '@shared/providerDbCatalog' import type { @@ -762,6 +764,93 @@ export class ProviderRuntime } } + async generateSpeechStandalone( + providerId: string, + text: string, + modelId: string, + speechOptions?: TtsSettings, + options?: { signal?: AbortSignal } + ): Promise { + const normalizedText = text.trim() + if (!normalizedText) { + throw new Error('Speech generation text is required') + } + + const signal = options?.signal + if (signal?.aborted) { + throw createAbortError() + } + + await this.executeWithRateLimit(providerId, { signal }) + + const provider = this.getProviderInstance(providerId) + const modelConfig = this.providerSettings.getModelConfig(modelId, providerId) + const mergedSpeechOptions = normalizeTtsSettings({ + ...modelConfig.tts, + ...speechOptions + }) + const resolvedModelConfig: ModelConfig = { + ...modelConfig, + type: ModelType.TTS, + apiEndpoint: ApiEndpointType.AudioSpeech, + tts: mergedSpeechOptions + } + const stream = provider.coreStream( + [{ role: 'user', content: normalizedText }], + modelId, + resolvedModelConfig, + modelConfig.temperature ?? 0.7, + modelConfig.maxTokens ?? 1024, + [], + { signal } + ) + let audio: StandaloneSpeechGenerationResult['audio'] | undefined + const abort = createAbortPromise(signal, () => { + closeAsyncIterator(stream) + }) + + const collect = async () => { + for await (const event of stream) { + if (signal?.aborted) { + throw createAbortError() + } + + if ( + event.type === 'image_data' && + event.image_data.mimeType.trim().toLowerCase().startsWith('audio/') + ) { + if (audio) { + throw new Error('Speech generation returned multiple audio outputs') + } + audio = { + data: event.image_data.data, + mimeType: event.image_data.mimeType + } + } + if (event.type === 'error') { + throw new Error(event.error_message) + } + } + } + + try { + await (abort.promise ? Promise.race([collect(), abort.promise]) : collect()) + } finally { + abort.cleanup() + } + + if (!audio) { + throw new Error('Speech generation completed without audio output') + } + + return { + providerId, + modelId, + ...(mergedSpeechOptions ? { options: mergedSpeechOptions } : {}), + audio + } + } + // 配置相关方法 setMaxConcurrentStreams(max: number): void { this.config.maxConcurrentStreams = max diff --git a/src/shared/types/provider.ts b/src/shared/types/provider.ts index f3240c629..9a0b0acc6 100644 --- a/src/shared/types/provider.ts +++ b/src/shared/types/provider.ts @@ -143,6 +143,13 @@ export type StandaloneVideoGenerationResult = { videos: Array<{ data: string; mimeType: string }> } +export type StandaloneSpeechGenerationResult = { + providerId: string + modelId: string + options?: TtsSettings + audio: { data: string; mimeType: string } +} + export interface KeyStatus { remainNum?: number /** Remaining quota */ @@ -358,6 +365,14 @@ export interface ProviderRuntimePort { videoOptions?: VideoGenerationOptions, options?: { signal?: AbortSignal } ): Promise + + generateSpeechStandalone( + providerId: string, + text: string, + modelId: string, + speechOptions?: TtsSettings, + options?: { signal?: AbortSignal } + ): Promise } export type ProviderExecutionPort = Pick< diff --git a/test/main/provider/providerRuntime.test.ts b/test/main/provider/providerRuntime.test.ts index 592e0299a..cfeb48cd2 100644 --- a/test/main/provider/providerRuntime.test.ts +++ b/test/main/provider/providerRuntime.test.ts @@ -591,6 +591,53 @@ describe('ProviderRuntime Integration Tests', () => { ) }, 15000) + it('should generate typed audio through the standalone speech runtime', async () => { + mockProviderSettings.getModelConfig = vi.fn().mockReturnValue({ + maxTokens: 4096, + contextLength: 4096, + temperature: 0.4, + vision: false, + functionCall: false, + reasoning: false, + type: ModelType.TTS, + tts: { voice: 'alloy', speed: 1 } + }) + mockRunAiSdkCoreStream.mockImplementationOnce(async function* () { + yield { + type: 'image_data', + image_data: { data: 'data:audio/mpeg;base64,AQID', mimeType: 'audio/mpeg' } + } + yield { type: 'stop', stop_reason: 'complete' } + }) + + const response = await providerRuntime.generateSpeechStandalone( + 'mock-openai-api', + ' Read this aloud. ', + 'gpt-4o-mini-tts', + { speed: 1.25, responseFormat: 'mp3' } + ) + + expect(response).toEqual({ + providerId: 'mock-openai-api', + modelId: 'gpt-4o-mini-tts', + options: { voice: 'alloy', speed: 1.25, responseFormat: 'mp3' }, + audio: { data: 'data:audio/mpeg;base64,AQID', mimeType: 'audio/mpeg' } + }) + expect(mockRunAiSdkCoreStream).toHaveBeenCalledWith( + expect.any(Object), + [{ role: 'user', content: 'Read this aloud.' }], + 'gpt-4o-mini-tts', + expect.objectContaining({ + apiEndpoint: ApiEndpointType.AudioSpeech, + type: ModelType.TTS, + tts: { voice: 'alloy', speed: 1.25, responseFormat: 'mp3' } + }), + 0.4, + 4096, + [] + ) + }, 15000) + it('should summarize titles', async () => { const messages = [ { role: 'user' as const, content: 'Hello, I want to learn about artificial intelligence' }, From 9992535c3f4de4b07367b5f21a8364432c39d256 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 12:11:29 +0800 Subject: [PATCH 08/51] feat(cli): stream raw model calls --- .../architecture/local-control-plane/tasks.md | 4 +- src/cli/args.ts | 152 ++++++- src/cli/format.ts | 15 + src/cli/index.ts | 3 +- src/cli/run.ts | 80 +++- src/cli/stdin.ts | 59 +++ src/cli/transport.ts | 213 +++++++++- src/main/app/composition.ts | 24 +- src/main/cli/computeService.ts | 374 ++++++++++++++++++ src/main/cli/index.ts | 1 + src/main/cli/server.ts | 227 +++++++++-- src/main/cli/surface.ts | 29 +- src/shared/contracts/localControl.ts | 11 + src/shared/contracts/routes.ts | 4 + src/shared/contracts/routes/models.routes.ts | 99 +++++ .../contracts/routes/providers.routes.ts | 38 ++ test/main/cli/args.test.ts | 65 +++ test/main/cli/client.test.ts | 107 ++++- test/main/cli/computeService.test.ts | 205 ++++++++++ test/main/cli/server.test.ts | 60 +++ test/main/cli/stdin.test.ts | 29 ++ test/main/cli/surface.test.ts | 12 +- test/main/cli/transport.test.ts | 84 +++- 23 files changed, 1826 insertions(+), 69 deletions(-) create mode 100644 src/cli/stdin.ts create mode 100644 src/main/cli/computeService.ts create mode 100644 test/main/cli/computeService.test.ts create mode 100644 test/main/cli/stdin.test.ts diff --git a/docs/architecture/local-control-plane/tasks.md b/docs/architecture/local-control-plane/tasks.md index 2eb37d882..59041c56b 100644 --- a/docs/architecture/local-control-plane/tasks.md +++ b/docs/architecture/local-control-plane/tasks.md @@ -29,9 +29,9 @@ ## Compute and Artifacts -- [ ] Add raw `models.invoke` over `coreStream` with no Agent/session/tool side effects. +- [x] Add raw `models.invoke` over `coreStream` with no Agent/session/tool side effects. - [ ] Add image and video standalone generation surfaces. -- [ ] Add formal standalone speech generation and typed audio output. +- [x] Add formal standalone speech generation and typed audio output. - [ ] Add upload and owned-artifact transcription inputs. - [x] Implement output-only `ArtifactSpool` ownership, quotas, expiry, and cleanup. - [ ] Add stream, media, speech, transcription, artifact, and quota tests. diff --git a/src/cli/args.ts b/src/cli/args.ts index 44b9f592a..7752753ea 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -10,13 +10,17 @@ import { artifactsDescribeRoute, artifactsReadRoute } from '@shared/contracts/routes/artifacts.routes' +import { modelsInvokeRoute } from '@shared/contracts/routes/models.routes' +import { providersListPublicRoute } from '@shared/contracts/routes/providers.routes' import type { JsonValue } from '@shared/contracts/json' +import { LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS } from '@shared/contracts/localControl' import { CliUsageError } from './errors' export const CLI_OUTPUT_ENV = 'DEEPCHAT_CLI_OUTPUT' export const CLI_TIMEOUT_ENV = 'DEEPCHAT_CLI_TIMEOUT_MS' export const DEFAULT_CLI_TIMEOUT_MS = 30_000 -export const MAX_CLI_TIMEOUT_MS = 30 * 60_000 +export const MAX_CLI_TIMEOUT_MS = LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS +export const DEFAULT_MODEL_INVOKE_TIMEOUT_MS = MAX_CLI_TIMEOUT_MS export type CliOutputMode = 'text' | 'json' | 'jsonl' export type CliRpcContract = @@ -27,8 +31,10 @@ export type CliRpcContract = | typeof artifactsDescribeRoute | typeof artifactsReadRoute | typeof artifactsDeleteRoute + | typeof modelsInvokeRoute + | typeof providersListPublicRoute -export type CliCommandOperation = 'rpc' | 'download' +export type CliCommandOperation = 'rpc' | 'stream' | 'download' export type ParsedCliArguments = Readonly<{ domain: string @@ -41,6 +47,7 @@ export type ParsedCliArguments = Readonly<{ params: JsonValue outputPath?: string overwrite: boolean + readStdin: boolean }> const COMMANDS = new Map([ @@ -50,7 +57,9 @@ const COMMANDS = new Map([ ['system doctor', cliDoctorRoute], ['artifact describe', artifactsDescribeRoute], ['artifact get', artifactsReadRoute], - ['artifact delete', artifactsDeleteRoute] + ['artifact delete', artifactsDeleteRoute], + ['model invoke', modelsInvokeRoute], + ['provider list', providersListPublicRoute] ]) function parseOutputMode(value: string | undefined): CliOutputMode { @@ -106,12 +115,23 @@ export function parseCliArguments( let explicitOutputMode: CliOutputMode | undefined let timeoutMs = env[CLI_TIMEOUT_ENV] ? parseTimeout(env[CLI_TIMEOUT_ENV], CLI_TIMEOUT_ENV) - : DEFAULT_CLI_TIMEOUT_MS + : commandKey === 'model invoke' + ? DEFAULT_MODEL_INVOKE_TIMEOUT_MS + : DEFAULT_CLI_TIMEOUT_MS let timeoutSeen = false let helpRequested = false let artifactId: string | undefined let outputPath: string | undefined let overwrite = false + let providerId: string | undefined + let modelId: string | undefined + let prompt: string | undefined + let systemPrompt: string | undefined + let temperature: number | undefined + let maxTokens: number | undefined + let readStdin = false + let enabledOnly = false + const domainOptions = new Set() const readOptionValue = ( argument: string, @@ -162,6 +182,7 @@ export function parseCliArguments( continue } if (argument === '--id' || argument.startsWith('--id=')) { + domainOptions.add('id') if (artifactId !== undefined) throw new CliUsageError('--id may be specified only once') const parsedOption = readOptionValue(argument, index) const parsedId = ArtifactIdSchema.safeParse(parsedOption.value) @@ -171,6 +192,7 @@ export function parseCliArguments( continue } if (argument === '--out' || argument.startsWith('--out=')) { + domainOptions.add('out') if (outputPath !== undefined) throw new CliUsageError('--out may be specified only once') const parsedOption = readOptionValue(argument, index) outputPath = parsedOption.value @@ -178,10 +200,80 @@ export function parseCliArguments( continue } if (argument === '--overwrite') { + domainOptions.add('overwrite') if (overwrite) throw new CliUsageError('--overwrite may be specified only once') overwrite = true continue } + if (argument === '--provider' || argument.startsWith('--provider=')) { + domainOptions.add('provider') + if (providerId !== undefined) throw new CliUsageError('--provider may be specified only once') + const parsedOption = readOptionValue(argument, index) + providerId = parsedOption.value + index = parsedOption.nextIndex + continue + } + if (argument === '--model' || argument.startsWith('--model=')) { + domainOptions.add('model') + if (modelId !== undefined) throw new CliUsageError('--model may be specified only once') + const parsedOption = readOptionValue(argument, index) + modelId = parsedOption.value + index = parsedOption.nextIndex + continue + } + if (argument === '--prompt' || argument.startsWith('--prompt=')) { + domainOptions.add('prompt') + if (prompt !== undefined) throw new CliUsageError('--prompt may be specified only once') + const parsedOption = readOptionValue(argument, index) + prompt = parsedOption.value + index = parsedOption.nextIndex + continue + } + if (argument === '--system' || argument.startsWith('--system=')) { + domainOptions.add('system') + if (systemPrompt !== undefined) throw new CliUsageError('--system may be specified only once') + const parsedOption = readOptionValue(argument, index) + systemPrompt = parsedOption.value + index = parsedOption.nextIndex + continue + } + if (argument === '--temperature' || argument.startsWith('--temperature=')) { + domainOptions.add('temperature') + if (temperature !== undefined) { + throw new CliUsageError('--temperature may be specified only once') + } + const parsedOption = readOptionValue(argument, index) + temperature = Number(parsedOption.value) + if (!Number.isFinite(temperature) || temperature < 0 || temperature > 2) { + throw new CliUsageError('--temperature must be a number between 0 and 2') + } + index = parsedOption.nextIndex + continue + } + if (argument === '--max-tokens' || argument.startsWith('--max-tokens=')) { + domainOptions.add('max-tokens') + if (maxTokens !== undefined) + throw new CliUsageError('--max-tokens may be specified only once') + const parsedOption = readOptionValue(argument, index) + maxTokens = Number(parsedOption.value) + if (!Number.isSafeInteger(maxTokens) || maxTokens < 1 || maxTokens > 1_000_000) { + throw new CliUsageError('--max-tokens must be an integer between 1 and 1000000') + } + index = parsedOption.nextIndex + continue + } + if (argument === '--stdin') { + domainOptions.add('stdin') + if (readStdin) throw new CliUsageError('--stdin may be specified only once') + readStdin = true + continue + } + if (argument === '--enabled-only') { + domainOptions.add('enabled-only') + if (enabledOnly) throw new CliUsageError('--enabled-only may be specified only once') + enabledOnly = true + continue + } throw new CliUsageError(`Unknown option after ${domain} ${verb}: ${argument}`) } @@ -203,6 +295,43 @@ export function parseCliArguments( throw new CliUsageError(`--out and --overwrite are only valid for deepchat artifact get`) } + const isModelInvoke = commandKey === 'model invoke' + const isProviderList = commandKey === 'provider list' + const allowedDomainOptions = isArtifactCommand + ? new Set(['id', 'out', 'overwrite']) + : isModelInvoke + ? new Set(['provider', 'model', 'prompt', 'system', 'temperature', 'max-tokens', 'stdin']) + : isProviderList + ? new Set(['enabled-only']) + : new Set() + const invalidDomainOption = Array.from(domainOptions).find( + (option) => !allowedDomainOptions.has(option) + ) + if (invalidDomainOption) { + throw new CliUsageError(`--${invalidDomainOption} is not valid for deepchat ${domain} ${verb}`) + } + if (!helpRequested && isModelInvoke && (!providerId || !modelId)) { + throw new CliUsageError('deepchat model invoke requires --provider and --model') + } + if (!helpRequested && isModelInvoke && (prompt !== undefined) === readStdin) { + throw new CliUsageError('deepchat model invoke requires exactly one of --prompt or --stdin') + } + + let params: JsonValue = artifactId ? { id: artifactId } : {} + if (isProviderList) params = { enabledOnly } + if (isModelInvoke && providerId && modelId) { + params = { + providerId, + modelId, + messages: [ + ...(systemPrompt ? [{ role: 'system', content: systemPrompt }] : []), + ...(prompt !== undefined ? [{ role: 'user', content: prompt }] : []) + ], + ...(temperature !== undefined ? { temperature } : {}), + ...(maxTokens !== undefined ? { maxTokens } : {}) + } + } + return { domain, verb, @@ -210,10 +339,11 @@ export function parseCliArguments( outputMode, timeoutMs, helpRequested: helpRequested || isHelpCommand, - operation: commandKey === 'artifact get' ? 'download' : 'rpc', - params: artifactId ? { id: artifactId } : {}, + operation: commandKey === 'artifact get' ? 'download' : isModelInvoke ? 'stream' : 'rpc', + params, ...(outputPath ? { outputPath } : {}), - overwrite + overwrite, + readStdin } } @@ -224,7 +354,11 @@ export function formatCliHelp(command?: Pick --out [--overwrite]' : ' --id ' - : '' + : command.domain === 'model' + ? ' --provider --model (--prompt |--stdin)' + : command.domain === 'provider' + ? ' [--enabled-only]' + : '' return [ `Usage: deepchat ${command.domain} ${command.verb}${commandOptions} [--json|--jsonl] [--timeout ]`, '', @@ -243,6 +377,8 @@ export function formatCliHelp(command?: Pick [ + `${provider.id} ${provider.enabled ? 'enabled' : 'disabled'} ${provider.name}`, + ...provider.models.map( + (model) => + ` ${model.id} ${model.enabled ? 'enabled' : 'disabled'} ${model.type ?? 'chat'}` + ) + ]) + .join('\n') + } + case 'models.invoke': { + return contract.output.parse(value).text + } } } diff --git a/src/cli/index.ts b/src/cli/index.ts index 6d7169a1c..1dae0def9 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -5,10 +5,11 @@ import { runCli } from './run' export { parseCliArguments, formatCliHelp } from './args' export { downloadArtifact } from './artifacts' +export { readBoundedUtf8Stdin } from './stdin' export { loadLocalControlDescriptor, resolveCliUserDataPath } from './discovery' export { CLI_EXIT_CODES } from './errors' export { runCli } from './run' -export { CLI_VERSION, invokeLocalControlRpc } from './transport' +export { CLI_VERSION, invokeLocalControlRpc, invokeLocalControlStream } from './transport' function ignoreBrokenPipe(stream: NodeJS.WriteStream): void { stream.on('error', (error: NodeJS.ErrnoException) => { diff --git a/src/cli/run.ts b/src/cli/run.ts index 67a093fee..deadb1ea7 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -6,6 +6,7 @@ import { type LocalControlRpcResponse } from '@shared/contracts/localControl' import { artifactsDescribeRoute } from '@shared/contracts/routes/artifacts.routes' +import { ModelInvokeEventSchema } from '@shared/contracts/routes/models.routes' import { parseCliArguments, formatCliHelp, inferCliOutputMode, type CliOutputMode } from './args' import { loadLocalControlDescriptor, @@ -20,8 +21,14 @@ import { type CliExitCode } from './errors' import { formatHumanResult, serializeMachineResponse } from './format' -import { invokeLocalControlRpc, type CliRpcInvocation } from './transport' +import { + invokeLocalControlRpc, + invokeLocalControlStream, + type CliRpcInvocation, + type CliStreamEventHandler +} from './transport' import { downloadArtifact } from './artifacts' +import { readBoundedUtf8Stdin } from './stdin' const SIGNAL_GRACE_MS = 1_000 @@ -33,10 +40,15 @@ export type CliRunDependencies = Readonly<{ discovery?: Omit stdout?: WritableOutput stderr?: WritableOutput + stdin?: NodeJS.ReadableStream signalHost?: SignalHost randomId?: () => string loadDescriptor?: (options: CliDiscoveryOptions) => Promise invokeRpc?: (invocation: CliRpcInvocation) => Promise + invokeStream?: ( + invocation: CliRpcInvocation, + onEvent: CliStreamEventHandler + ) => Promise forceExit?: (code: number) => void }> @@ -74,6 +86,7 @@ export async function runCli( const env = dependencies.env ?? process.env const stdout = dependencies.stdout ?? process.stdout const stderr = dependencies.stderr ?? process.stderr + const stdin = dependencies.stdin ?? process.stdin const signalHost = dependencies.signalHost ?? process const requestId = (dependencies.randomId ?? randomUUID)() @@ -136,6 +149,27 @@ export async function runCli( }, parsed.timeoutMs) try { + let params = parsed.params + if (parsed.readStdin) { + const prompt = await readBoundedUtf8Stdin(stdin, controller.signal) + if (!params || typeof params !== 'object' || Array.isArray(params)) { + throw new CliClientError( + 'internal_error', + 'CLI command has invalid input parameters', + CLI_EXIT_CODES.internal + ) + } + const messages = Array.isArray(params.messages) ? params.messages : [] + params = { ...params, messages: [...messages, { role: 'user', content: prompt }] } + } + const validatedInput = parsed.contract.input.safeParse(params) + if (!validatedInput.success) { + throw new CliClientError( + 'invalid_request', + 'CLI input does not match the command contract', + CLI_EXIT_CODES.usage + ) + } const descriptor = await (dependencies.loadDescriptor ?? loadLocalControlDescriptor)({ ...dependencies.discovery, env @@ -154,16 +188,50 @@ export async function runCli( } const invocationContract = parsed.operation === 'download' ? artifactsDescribeRoute : parsed.contract - const response = await (dependencies.invokeRpc ?? invokeLocalControlRpc)({ + const invocation: CliRpcInvocation = { descriptor, token, id: requestId, method: invocationContract.name, - params: parsed.params, + params: validatedInput.data, signal: controller.signal - }) + } + let streamedText = false + let streamedTextEndsWithNewline = false + const onStreamEvent: CliStreamEventHandler = async (event) => { + if (event.event !== parsed.contract?.name) { + throw new CliClientError( + 'internal_error', + 'DeepChat emitted an event for another method', + CLI_EXIT_CODES.internal + ) + } + if (parsed.outputMode === 'jsonl') { + writeText(stdout, JSON.stringify(event)) + return + } + if (parsed.outputMode !== 'text' || parsed.contract.name !== 'models.invoke') return + const parsedEvent = ModelInvokeEventSchema.safeParse(event.data) + if (!parsedEvent.success) { + throw new CliClientError( + 'internal_error', + 'DeepChat emitted an invalid model event', + CLI_EXIT_CODES.internal + ) + } + if (parsedEvent.data.type === 'text_delta' && parsedEvent.data.text) { + stdout.write(parsedEvent.data.text) + streamedText = true + streamedTextEndsWithNewline = parsedEvent.data.text.endsWith('\n') + } + } + const response = + parsed.operation === 'stream' + ? await (dependencies.invokeStream ?? invokeLocalControlStream)(invocation, onStreamEvent) + : await (dependencies.invokeRpc ?? invokeLocalControlRpc)(invocation) if (!response.ok) { + if (streamedText && !streamedTextEndsWithNewline) stdout.write('\n') if (parsed.outputMode === 'text') { writeText(stderr, `${response.error.code}: ${response.error.message}`) } else { @@ -197,7 +265,9 @@ export async function runCli( signal: controller.signal }) } - if (parsed.outputMode === 'text') { + if (parsed.operation === 'stream' && parsed.outputMode === 'text') { + if (!streamedText || !streamedTextEndsWithNewline) stdout.write('\n') + } else if (parsed.outputMode === 'text') { writeText( stdout, formatHumanResult(parsed.contract, response.result, { outputPath: parsed.outputPath }) diff --git a/src/cli/stdin.ts b/src/cli/stdin.ts new file mode 100644 index 000000000..11f5ccc77 --- /dev/null +++ b/src/cli/stdin.ts @@ -0,0 +1,59 @@ +import { CliClientError, CLI_EXIT_CODES } from './errors' + +export const MAX_CLI_STDIN_BYTES = 4 * 1024 * 1024 + +function abortReason(signal: AbortSignal): Error { + return signal.reason instanceof Error + ? signal.reason + : new CliClientError('cancelled', 'CLI input was cancelled', CLI_EXIT_CODES.cancelled) +} + +export async function readBoundedUtf8Stdin( + stream: NodeJS.ReadableStream, + signal: AbortSignal, + maxBytes = MAX_CLI_STDIN_BYTES +): Promise { + if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) { + throw new Error('Invalid CLI stdin byte limit') + } + if (signal.aborted) throw abortReason(signal) + + const chunks: Buffer[] = [] + let size = 0 + const destroy = (error: Error) => { + const destroyable = stream as NodeJS.ReadableStream & { destroy?: (error?: Error) => void } + destroyable.destroy?.(error) + } + const onAbort = () => destroy(abortReason(signal)) + signal.addEventListener('abort', onAbort, { once: true }) + try { + for await (const rawChunk of stream as AsyncIterable) { + if (signal.aborted) throw abortReason(signal) + const chunk = Buffer.from(rawChunk) + size += chunk.length + if (size > maxBytes) { + throw new CliClientError( + 'body_too_large', + 'Standard input exceeds the CLI byte limit', + CLI_EXIT_CODES.usage + ) + } + chunks.push(chunk) + } + } catch (error) { + if (signal.aborted) throw abortReason(signal) + throw error + } finally { + signal.removeEventListener('abort', onAbort) + } + + try { + return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks, size)) + } catch { + throw new CliClientError( + 'invalid_request', + 'Standard input is not valid UTF-8', + CLI_EXIT_CODES.usage + ) + } +} diff --git a/src/cli/transport.ts b/src/cli/transport.ts index 4a41024c2..22f6ed95d 100644 --- a/src/cli/transport.ts +++ b/src/cli/transport.ts @@ -1,16 +1,20 @@ import { request as httpRequest, type IncomingHttpHeaders } from 'node:http' import { LOCAL_CONTROL_RPC_PATH, + LOCAL_CONTROL_STREAM_PATH, + LOCAL_CONTROL_MAX_JSON_RESPONSE_BYTES, + LOCAL_CONTROL_MAX_STREAM_RECORD_BYTES, + LocalControlEventEnvelopeSchema, LocalControlRpcRequestSchema, LocalControlRpcResponseSchema, + LocalControlStreamRecordSchema, type LocalControlDescriptor, + type LocalControlEventEnvelope, type LocalControlRpcResponse } from '@shared/contracts/localControl' import type { JsonValue } from '@shared/contracts/json' import { CLI_EXIT_CODES, CliClientError } from './errors' -const MAX_RESPONSE_BYTES = 16 * 1024 * 1024 - export const CLI_VERSION = typeof __DEEPCHAT_CLI_VERSION__ === 'string' ? __DEEPCHAT_CLI_VERSION__ : 'development' @@ -23,6 +27,8 @@ export type CliRpcInvocation = Readonly<{ signal: AbortSignal }> +export type CliStreamEventHandler = (event: LocalControlEventEnvelope) => void | Promise + function transportFailure(message: string, retriable = true): CliClientError { return new CliClientError('unavailable', message, CLI_EXIT_CODES.unavailable, retriable) } @@ -44,7 +50,7 @@ function declaredResponseLength( throw protocolFailure('DeepChat returned an invalid Content-Length') } const length = Number(raw) - if (!Number.isSafeInteger(length) || length > MAX_RESPONSE_BYTES) { + if (!Number.isSafeInteger(length) || length > LOCAL_CONTROL_MAX_JSON_RESPONSE_BYTES) { throw protocolFailure('DeepChat response exceeds the CLI byte limit') } return length @@ -56,11 +62,8 @@ function abortReason(signal: AbortSignal): Error { : new CliClientError('cancelled', 'CLI request was cancelled', CLI_EXIT_CODES.cancelled) } -export async function invokeLocalControlRpc( - invocation: CliRpcInvocation -): Promise { - if (invocation.signal.aborted) throw abortReason(invocation.signal) - const body = Buffer.from( +function createInvocationBody(invocation: CliRpcInvocation): Buffer { + return Buffer.from( JSON.stringify( LocalControlRpcRequestSchema.parse({ protocolVersion: invocation.descriptor.protocolVersion, @@ -72,6 +75,13 @@ export async function invokeLocalControlRpc( ), 'utf8' ) +} + +export async function invokeLocalControlRpc( + invocation: CliRpcInvocation +): Promise { + if (invocation.signal.aborted) throw abortReason(invocation.signal) + const body = createInvocationBody(invocation) return await new Promise((resolve, reject) => { let settled = false @@ -138,7 +148,7 @@ export async function invokeLocalControlRpc( if (settled) return const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk) size += chunk.length - if (size > MAX_RESPONSE_BYTES) { + if (size > LOCAL_CONTROL_MAX_JSON_RESPONSE_BYTES) { response.destroy() finish(() => reject(protocolFailure('DeepChat response exceeds the CLI byte limit'))) return @@ -190,3 +200,188 @@ export async function invokeLocalControlRpc( request.end(body) }) } + +export async function invokeLocalControlStream( + invocation: CliRpcInvocation, + onEvent: CliStreamEventHandler +): Promise { + if (invocation.signal.aborted) throw abortReason(invocation.signal) + const body = createInvocationBody(invocation) + + return await new Promise((resolve, reject) => { + let settled = false + let responseReceived = false + const finish = (callback: () => void) => { + if (settled) return + settled = true + callback() + } + const request = httpRequest({ + socketPath: + invocation.descriptor.endpoint.kind === 'unix' + ? invocation.descriptor.endpoint.path + : invocation.descriptor.endpoint.name, + path: LOCAL_CONTROL_STREAM_PATH, + method: 'POST', + agent: false, + signal: invocation.signal, + headers: { + authorization: `Bearer ${invocation.token}`, + 'content-type': 'application/json', + 'content-length': body.length, + connection: 'close', + 'user-agent': `DeepChat-CLI/${CLI_VERSION}` + } + }) + + request.once('response', (response) => { + responseReceived = true + void (async () => { + const contentTypes = response.headersDistinct['content-type'] + const contentType = contentTypes?.[0] ?? response.headers['content-type'] + const [mediaType, ...parameters] = + typeof contentType === 'string' + ? contentType.split(';').map((part) => part.trim().toLowerCase()) + : [] + if (contentTypes && contentTypes.length !== 1) { + throw protocolFailure('DeepChat returned multiple Content-Type headers') + } + if (response.headers['content-encoding'] !== undefined) { + throw protocolFailure('Compressed local responses are not supported') + } + + const isHttpSuccess = (response.statusCode ?? 0) >= 200 && (response.statusCode ?? 0) < 300 + if (!isHttpSuccess) { + if ( + mediaType !== 'application/json' || + !parameters.every((parameter) => parameter === 'charset=utf-8') + ) { + throw protocolFailure('DeepChat returned a non-JSON error response') + } + const expectedLength = declaredResponseLength( + response.headers, + response.headersDistinct['content-length'] + ) + const chunks: Buffer[] = [] + let size = 0 + for await (const rawChunk of response) { + const chunk = Buffer.from(rawChunk) + size += chunk.length + if (size > LOCAL_CONTROL_MAX_JSON_RESPONSE_BYTES) { + throw protocolFailure('DeepChat response exceeds the CLI byte limit') + } + chunks.push(chunk) + } + if (expectedLength !== null && expectedLength !== size) { + throw protocolFailure('DeepChat response length did not match') + } + const parsed = LocalControlRpcResponseSchema.parse( + JSON.parse(Buffer.concat(chunks, size).toString('utf8')) + ) + if (parsed.ok) { + throw protocolFailure('DeepChat HTTP status and response envelope disagree') + } + if (parsed.id !== invocation.id && parsed.id !== 'unknown') { + throw protocolFailure('DeepChat response ID did not match the request') + } + return parsed + } + + if ( + mediaType !== 'application/x-ndjson' || + !parameters.every((parameter) => parameter === 'charset=utf-8') + ) { + throw protocolFailure('DeepChat returned a non-NDJSON stream') + } + + let pendingChunks: Buffer[] = [] + let pendingLength = 0 + let expectedSequence = 0 + let terminal: LocalControlRpcResponse | undefined + const consumeLine = async (line: Buffer): Promise => { + if (line.length === 0) throw protocolFailure('DeepChat returned an empty stream record') + let parsed + try { + parsed = LocalControlStreamRecordSchema.parse(JSON.parse(line.toString('utf8'))) + } catch { + throw protocolFailure('DeepChat returned an invalid stream record') + } + if ('ok' in parsed) { + if (terminal) throw protocolFailure('DeepChat returned multiple terminal records') + if (parsed.id !== invocation.id) { + throw protocolFailure('DeepChat stream result ID did not match the request') + } + terminal = parsed + return + } + if (terminal) + throw protocolFailure('DeepChat returned an event after the terminal record') + const event = LocalControlEventEnvelopeSchema.parse(parsed) + if (event.requestId !== invocation.id || event.sequence !== expectedSequence) { + throw protocolFailure('DeepChat stream event identity or order did not match') + } + expectedSequence += 1 + await onEvent(event) + } + + for await (const rawChunk of response) { + const chunk = Buffer.from(rawChunk) + let offset = 0 + while (offset < chunk.length) { + const newlineIndex = chunk.indexOf(0x0a, offset) + const end = newlineIndex >= 0 ? newlineIndex : chunk.length + if (end > offset) { + const segment = chunk.subarray(offset, end) + pendingChunks.push(segment) + pendingLength += segment.length + } + if (pendingLength > LOCAL_CONTROL_MAX_STREAM_RECORD_BYTES) { + throw protocolFailure('DeepChat stream record exceeds the CLI byte limit') + } + if (newlineIndex < 0) break + const line = + pendingChunks.length === 1 + ? pendingChunks[0] + : Buffer.concat(pendingChunks, pendingLength) + await consumeLine(line) + pendingChunks = [] + pendingLength = 0 + offset = newlineIndex + 1 + } + } + if (pendingLength !== 0) { + throw protocolFailure('DeepChat stream ended with an incomplete record') + } + if (!terminal) throw protocolFailure('DeepChat stream ended without a terminal record') + return terminal + })().then( + (result) => finish(() => resolve(result)), + (error: unknown) => { + response.destroy() + finish(() => + reject( + invocation.signal.aborted + ? abortReason(invocation.signal) + : error instanceof CliClientError + ? error + : protocolFailure('DeepChat stream transport failed') + ) + ) + } + ) + }) + request.once('error', (error: NodeJS.ErrnoException) => { + if (responseReceived) return + if (invocation.signal.aborted) { + finish(() => reject(abortReason(invocation.signal))) + return + } + const message = + error.code === 'ENOENT' || error.code === 'ECONNREFUSED' || error.code === 'EPIPE' + ? 'DeepChat local control server is unavailable' + : `Cannot connect to DeepChat: ${error.message}` + finish(() => reject(transportFailure(message))) + }) + request.end(body) + }) +} diff --git a/src/main/app/composition.ts b/src/main/app/composition.ts index 632c10e6a..0e41c6fdd 100644 --- a/src/main/app/composition.ts +++ b/src/main/app/composition.ts @@ -207,7 +207,14 @@ import { type RouteDispatcher } from '@/routes' import { createNodeScheduler } from '@/routes/scheduler' -import { ArtifactSpool, CliServer, createArtifactRoutes, createCliRoutes } from '@/cli' +import { + ArtifactSpool, + CliComputeService, + CliServer, + createArtifactRoutes, + createCliComputeRoutes, + createCliRoutes +} from '@/cli' import { AcpRegistryMigrationService } from '@/agent/acp/catalog/acpRegistryMigrationService' import { killTerminal } from '@/agent/acp/launch/acpInitHelper' import { rtkRuntimeService } from '@/agent/shared/process/rtkRuntimeService' @@ -337,6 +344,7 @@ export async function createMainProcessControl(dependencies: { let acpAsLlmProviderSessionControl: AcpAsLlmProviderSessionControlPort let acpAsLlmProviderPermission: AcpAsLlmProviderPermissionPort let routeDispatcher: RouteDispatcher | undefined + let cliComputeService: CliComputeService let hasInitialized = false let databaseMaintenanceState: 'running' | 'maintenance' | 'failed' = 'running' let appLifecycleState: 'starting' | 'running' | 'stopping' | 'stopped' = 'starting' @@ -371,6 +379,11 @@ export async function createMainProcessControl(dependencies: { signal.throwIfAborted() return output }, + dispatchStream: async (method, input, caller, signal, emit) => { + if (!cliComputeService) throw new Error('CLI compute service is not ready') + assertRouteAllowedDuringDatabaseMaintenance(method) + return await cliComputeService.dispatchStream(method, input, caller, signal, emit) + }, artifactSpool, log: logger }) @@ -622,6 +635,11 @@ export async function createMainProcessControl(dependencies: { acpSessionPersistence, publishDeepchatEvent ) + cliComputeService = new CliComputeService({ + providerSettings, + providerRuntime, + log: logger + }) const agentDefaults = new DeepChatDefaults({ settings: dependencies.settingsStore, publishSettingChanged: (key, value) => @@ -2243,6 +2261,7 @@ export async function createMainProcessControl(dependencies: { windowPresenter.getAllWindows().some((window) => !window.isDestroyed()) }) const artifactRoutes = createArtifactRoutes(artifactSpool) + const cliComputeRoutes = createCliComputeRoutes(cliComputeService) routeDispatcher = createRouteDispatcher({ appDatabaseMaintenance: { assertRouteAllowed: (routeName) => assertRouteAllowedDuringDatabaseMaintenance(routeName) @@ -2278,7 +2297,8 @@ export async function createMainProcessControl(dependencies: { appSettingsRoutes, appRoutes, cliRoutes, - artifactRoutes + artifactRoutes, + cliComputeRoutes ], settingsWindow: windowPresenter, startupWorkloadCoordinator diff --git a/src/main/cli/computeService.ts b/src/main/cli/computeService.ts new file mode 100644 index 000000000..ed4a24f28 --- /dev/null +++ b/src/main/cli/computeService.ts @@ -0,0 +1,374 @@ +import { + MODEL_INVOKE_MAX_OUTPUT_CHARACTERS, + ModelInvokeEventSchema, + PublicProviderSchema, + modelsInvokeRoute, + providersListPublicRoute, + type ModelInvokeEvent, + type ModelInvokeInput, + type ModelInvokeOutput, + type PublicProvider +} from '@shared/contracts/routes' +import type { JsonValue } from '@shared/contracts/json' +import { ModelType } from '@shared/model' +import type { LLMCoreStreamEvent, ProviderRoundStopReason } from '@shared/types/core/llm-events' +import type { ProviderSettingsPort } from '@/provider/settings' +import type { ProviderRuntime } from '@/provider' +import { + createRouteMap, + type CliRouteCaller, + type DeepchatRouteMap, + type RouteCaller +} from '@/routes/routeRegistry' +import { CliRequestError } from './errors' + +const MAX_STREAM_DELTA_CHARACTERS = 1024 * 1024 +const MAX_MODEL_STREAM_EVENTS = 10_000 +const MAX_PUBLIC_PROVIDERS = 1_000 +const MAX_PUBLIC_MODELS = 10_000 + +type ComputeEmitter = (event: string, data: JsonValue) => Promise + +type ComputeProviderSettings = Pick< + ProviderSettingsPort, + | 'getProviders' + | 'getProviderById' + | 'getProviderModels' + | 'getCustomModels' + | 'getBatchModelStatus' + | 'getModelStatus' + | 'isKnownModel' + | 'getModelConfig' +> + +type ComputeProviderRuntime = Pick + +export type CliComputeServiceOptions = Readonly<{ + providerSettings: ComputeProviderSettings + providerRuntime: ComputeProviderRuntime + now?: () => number + log?: Pick +}> + +function requireCliCaller(caller: RouteCaller): CliRouteCaller { + if (caller.kind !== 'cli') { + throw new CliRequestError('permission_denied', 'Compute routes require a CLI caller', { + httpStatus: 403 + }) + } + return caller +} + +function splitStreamDelta(value: string): string[] { + if (value.length === 0) return [] + if (value.length <= MAX_STREAM_DELTA_CHARACTERS) return [value] + const chunks: string[] = [] + let offset = 0 + while (offset < value.length) { + let end = Math.min(value.length, offset + MAX_STREAM_DELTA_CHARACTERS) + const lastCodeUnit = value.charCodeAt(end - 1) + if (end < value.length && lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff) end -= 1 + chunks.push(value.slice(offset, end)) + offset = end + } + return chunks +} + +function toUsage(event: Extract): ModelInvokeEvent { + return ModelInvokeEventSchema.parse({ + type: 'usage', + usage: { + promptTokens: event.usage.prompt_tokens, + completionTokens: event.usage.completion_tokens, + totalTokens: event.usage.total_tokens, + ...(event.usage.cached_tokens !== undefined + ? { cachedTokens: event.usage.cached_tokens } + : {}), + ...(event.usage.cache_write_tokens !== undefined + ? { cacheWriteTokens: event.usage.cache_write_tokens } + : {}) + } + }) +} + +export class CliComputeService { + private readonly now: () => number + private readonly log: Pick + + constructor(private readonly options: CliComputeServiceOptions) { + this.now = options.now ?? Date.now + this.log = options.log ?? console + } + + listPublicProviders(enabledOnly = false): PublicProvider[] { + const providers = this.options.providerSettings + .getProviders() + .filter((provider) => !enabledOnly || provider.enable) + if (providers.length > MAX_PUBLIC_PROVIDERS) { + throw new CliRequestError('result_too_large', 'Provider list exceeds the public limit', { + httpStatus: 413 + }) + } + + let totalModels = 0 + return providers.map((provider) => { + const modelsById = new Map( + [ + ...this.options.providerSettings.getProviderModels(provider.id), + ...this.options.providerSettings.getCustomModels(provider.id) + ].map((model) => [model.id, model] as const) + ) + totalModels += modelsById.size + if (totalModels > MAX_PUBLIC_MODELS) { + throw new CliRequestError('result_too_large', 'Model list exceeds the public limit', { + httpStatus: 413 + }) + } + const status = this.options.providerSettings.getBatchModelStatus( + provider.id, + Array.from(modelsById.keys()) + ) + const models = Array.from(modelsById.values()) + .map((model) => ({ + id: model.id, + name: model.name, + group: model.group, + enabled: status[model.id] ?? false, + custom: model.isCustom === true, + vision: model.vision === true, + functionCall: model.functionCall === true, + reasoning: model.reasoning === true, + enableSearch: model.enableSearch === true, + ...(model.type ? { type: model.type } : {}), + ...(model.contextLength !== undefined ? { contextLength: model.contextLength } : {}), + ...(model.maxTokens !== undefined ? { maxTokens: model.maxTokens } : {}) + })) + .filter((model) => !enabledOnly || model.enabled) + + return PublicProviderSchema.parse({ + id: provider.id, + name: provider.name || provider.id, + apiType: provider.apiType, + enabled: provider.enable, + custom: provider.custom === true, + models + }) + }) + } + + async dispatchStream( + method: string, + rawInput: unknown, + _caller: CliRouteCaller, + signal: AbortSignal, + emit: ComputeEmitter + ): Promise { + if (method !== modelsInvokeRoute.name) { + throw new CliRequestError('not_found', 'Streaming method is not implemented', { + httpStatus: 404 + }) + } + return await this.invokeModel(modelsInvokeRoute.input.parse(rawInput), signal, emit) + } + + private async invokeModel( + input: ModelInvokeInput, + signal: AbortSignal, + emit: ComputeEmitter + ): Promise { + const startedAt = this.now() + let providerError: unknown + let emittedEvents = 0 + const emitEvent = async (event: ModelInvokeEvent): Promise => { + if (emittedEvents >= MAX_MODEL_STREAM_EVENTS) { + throw new CliRequestError('result_too_large', 'Model stream exceeds the event limit', { + httpStatus: 413 + }) + } + emittedEvents += 1 + await emit(modelsInvokeRoute.name, event) + } + try { + signal.throwIfAborted() + const provider = this.options.providerSettings.getProviderById(input.providerId) + if (!provider?.enable) { + throw new CliRequestError('not_found', 'Provider is not available', { httpStatus: 404 }) + } + if (!this.options.providerSettings.isKnownModel(input.providerId, input.modelId)) { + throw new CliRequestError('not_found', 'Model is not available', { httpStatus: 404 }) + } + if (!this.options.providerSettings.getModelStatus(input.providerId, input.modelId)) { + throw new CliRequestError('conflict', 'Model is disabled', { httpStatus: 409 }) + } + + const modelConfig = this.options.providerSettings.getModelConfig( + input.modelId, + input.providerId + ) + if (modelConfig.type !== ModelType.Chat) { + throw new CliRequestError('conflict', 'Model is not configured for raw text invocation', { + httpStatus: 409 + }) + } + + let queuedEmission = Promise.resolve() + let queuedEmissionError: unknown + await this.options.providerRuntime.executeWithRateLimit(input.providerId, { + signal, + onQueued: (snapshot) => { + const event = ModelInvokeEventSchema.parse({ + type: 'rate_limit', + providerId: snapshot.providerId, + qpsLimit: snapshot.qpsLimit, + currentQps: snapshot.currentQps, + queueLength: snapshot.queueLength, + estimatedWaitTimeMs: snapshot.estimatedWaitTime + }) + queuedEmission = emitEvent(event).catch((error) => { + queuedEmissionError = error + }) + } + }) + await queuedEmission + if (queuedEmissionError) throw queuedEmissionError + + const stream = this.options.providerRuntime.streamChat( + input.providerId, + input.messages, + input.modelId, + modelConfig, + input.temperature ?? modelConfig.temperature ?? 0.7, + input.maxTokens ?? modelConfig.maxTokens, + [], + { signal } + ) + const textChunks: string[] = [] + const reasoningChunks: string[] = [] + let outputCharacters = 0 + let usage: ModelInvokeOutput['usage'] + let finishReason: ProviderRoundStopReason | undefined + let firstTokenAt: number | undefined + + for await (const event of stream) { + signal.throwIfAborted() + switch (event.type) { + case 'text': + case 'reasoning': { + const delta = event.type === 'text' ? event.content : event.reasoning_content + outputCharacters += delta.length + if (outputCharacters > MODEL_INVOKE_MAX_OUTPUT_CHARACTERS) { + throw new CliRequestError('result_too_large', 'Model output exceeds the text limit', { + httpStatus: 413 + }) + } + if (firstTokenAt === undefined && delta.length > 0) firstTokenAt = this.now() + if (event.type === 'text') textChunks.push(delta) + else reasoningChunks.push(delta) + for (const chunk of splitStreamDelta(delta)) { + await emitEvent( + ModelInvokeEventSchema.parse({ + type: event.type === 'text' ? 'text_delta' : 'reasoning_delta', + text: chunk + }) + ) + } + break + } + case 'usage': { + const usageEvent = toUsage(event) + usage = usageEvent.type === 'usage' ? usageEvent.usage : undefined + await emitEvent(usageEvent) + break + } + case 'rate_limit': + await emitEvent( + ModelInvokeEventSchema.parse({ + type: 'rate_limit', + providerId: event.rate_limit.providerId, + qpsLimit: event.rate_limit.qpsLimit, + currentQps: event.rate_limit.currentQps, + queueLength: event.rate_limit.queueLength, + ...(event.rate_limit.estimatedWaitTime !== undefined + ? { estimatedWaitTimeMs: event.rate_limit.estimatedWaitTime } + : {}) + }) + ) + break + case 'stop': + finishReason = event.stop_reason + await emitEvent( + ModelInvokeEventSchema.parse({ type: 'stop', reason: event.stop_reason }) + ) + break + case 'error': + providerError = event + throw new Error('Provider returned an error event') + case 'tool_call_start': + case 'tool_call_chunk': + case 'tool_call_end': + case 'permission': + case 'plan': + case 'image_data': + throw new CliRequestError( + 'conflict', + 'Raw model invocation returned an unsupported event', + { httpStatus: 409 } + ) + } + } + + if (!finishReason) { + throw new Error('Provider stream ended without a stop event') + } + if (finishReason === 'error') { + throw new Error('Provider stream stopped with an error') + } + const text = textChunks.join('') + const reasoning = reasoningChunks.join('') + return modelsInvokeRoute.output.parse({ + providerId: input.providerId, + modelId: input.modelId, + text, + ...(reasoning ? { reasoning } : {}), + ...(usage ? { usage } : {}), + finishReason, + durationMs: Math.max(0, this.now() - startedAt), + ttftMs: firstTokenAt === undefined ? null : Math.max(0, firstTokenAt - startedAt) + }) + } catch (error) { + if (error instanceof CliRequestError) throw error + if (signal.aborted || (error instanceof Error && error.name === 'AbortError')) { + throw new CliRequestError('cancelled', 'Model invocation was cancelled', { + retriable: true + }) + } + this.log.warn('[CLI] Model invocation failed', { + providerId: input.providerId, + modelId: input.modelId, + failure: + providerError && typeof providerError === 'object' && 'failure' in providerError + ? providerError.failure + : { name: error instanceof Error ? error.name : typeof error } + }) + throw new CliRequestError('unavailable', 'Model provider request failed', { + httpStatus: 503, + retriable: true + }) + } + } +} + +export function createCliComputeRoutes(service: CliComputeService): DeepchatRouteMap { + return createRouteMap([ + [ + providersListPublicRoute.name, + async (rawInput, context) => { + requireCliCaller(context.caller) + const input = providersListPublicRoute.input.parse(rawInput) + return providersListPublicRoute.output.parse({ + providers: service.listPublicProviders(input.enabledOnly ?? false) + }) + } + ] + ]) +} diff --git a/src/main/cli/index.ts b/src/main/cli/index.ts index 4bc59e960..e84c90eb2 100644 --- a/src/main/cli/index.ts +++ b/src/main/cli/index.ts @@ -1,5 +1,6 @@ export { CliServer, type CliServerDependencies } from './server' export { ArtifactSpool, type ArtifactSpoolOptions } from './artifactSpool' export { createArtifactRoutes } from './artifactRoutes' +export { CliComputeService, createCliComputeRoutes } from './computeService' export { createCliRoutes, type CliRuntimeStatus } from './routes' export { CLI_SURFACE_V1, getCliSurfaceEntry, listCliSurfaceCapabilities } from './surface' diff --git a/src/main/cli/server.ts b/src/main/cli/server.ts index ea4ef4860..e3efe9d57 100644 --- a/src/main/cli/server.ts +++ b/src/main/cli/server.ts @@ -12,13 +12,18 @@ import { LOCAL_CONTROL_PROTOCOL_VERSION, LOCAL_CONTROL_RPC_PATH, LOCAL_CONTROL_SCOPES, + LOCAL_CONTROL_STREAM_PATH, LOCAL_CONTROL_SURFACE_VERSION, + LOCAL_CONTROL_MAX_JSON_RESPONSE_BYTES, + LOCAL_CONTROL_MAX_STREAM_RECORD_BYTES, + LocalControlEventEnvelopeSchema, LocalControlScopesSchema, LocalControlTokenSchema, LocalControlRpcRequestSchema, createLocalControlFailure, createLocalControlSuccess, - type LocalControlDescriptor + type LocalControlDescriptor, + type LocalControlStreamRecord } from '@shared/contracts/localControl' import type { CliRouteCaller } from '@/routes/routeRegistry' import { parseBoundedJsonBody, readBoundedRequestBody } from './body' @@ -41,6 +46,7 @@ const MAX_HEADER_BYTES = 8 * 1024 const MAX_CONNECTIONS = 64 const MAX_PENDING_REQUESTS = 64 const MAX_PENDING_PER_CONNECTION = 8 +const MAX_IN_MEMORY_BODY_BYTES = 256 * 1024 const SHUTDOWN_GRACE_MS = 2_000 const UNKNOWN_REQUEST_ID = 'unknown' @@ -54,6 +60,8 @@ const AgentCliTokenSchema = z export type AgentCliToken = z.infer +export type CliStreamEmitter = (event: string, data: JsonValue) => Promise + export type CliServerDependencies = Readonly<{ userDataPath: string appVersion: string @@ -63,6 +71,13 @@ export type CliServerDependencies = Readonly<{ caller: CliRouteCaller, signal: AbortSignal ): Promise + dispatchStream?( + method: string, + input: unknown, + caller: CliRouteCaller, + signal: AbortSignal, + emit: CliStreamEmitter + ): Promise resolveAgentToken?(token: string): AgentCliToken | null artifactSpool?: ArtifactSpool now?: () => number @@ -96,10 +111,10 @@ function requestContentTypeIsJson(request: IncomingMessage): boolean { return parameters.every((parameter) => parameter === 'charset=utf-8') } -function getMaxRpcBodyBytes(): number { +function getMaxBodyBytes(transport: 'rpc' | 'stream'): number { let maxBytes = 1 for (const entry of CLI_SURFACE_V1.values()) { - if (entry.transport === 'rpc') maxBytes = Math.max(maxBytes, entry.limits.maxBodyBytes) + if (entry.transport === transport) maxBytes = Math.max(maxBytes, entry.limits.maxBodyBytes) } return maxBytes } @@ -399,9 +414,10 @@ export class CliServer { const connectionId = this.connectionIds.get(request.socket) ?? randomUUID() const isRpcRequest = request.method === 'POST' && request.url === LOCAL_CONTROL_RPC_PATH + const isStreamRequest = request.method === 'POST' && request.url === LOCAL_CONTROL_STREAM_PATH const isArtifactRequest = request.method === 'GET' && request.url?.startsWith(LOCAL_CONTROL_ARTIFACT_PATH_PREFIX) - if (!isRpcRequest && !isArtifactRequest) { + if (!isRpcRequest && !isStreamRequest && !isArtifactRequest) { this.sendFailure( response, 404, @@ -440,6 +456,7 @@ export class CliServer { await this.handleArtifactDownload(request, response, caller) return } + const requestTransport = isStreamRequest ? 'stream' : 'rpc' if (!requestContentTypeIsJson(request)) { this.sendFailure( response, @@ -485,8 +502,8 @@ export class CliServer { let routeMethod = 'unknown' try { const body = await readBoundedRequestBody(request, { - maxBytes: getMaxRpcBodyBytes(), - memoryThresholdBytes: getMaxRpcBodyBytes(), + maxBytes: getMaxBodyBytes(requestTransport), + memoryThresholdBytes: Math.min(getMaxBodyBytes(requestTransport), MAX_IN_MEMORY_BODY_BYTES), tempDirectory: this.layout?.tempDirectory ?? this.dependencies.userDataPath, requireContentLength: true }) @@ -513,7 +530,7 @@ export class CliServer { requestId = rpcRequest.id routeMethod = rpcRequest.method const entry = getCliSurfaceEntry(rpcRequest.method) - if (!entry || entry.transport !== 'rpc') { + if (!entry || entry.transport !== requestTransport) { throw new CliRequestError('not_found', 'Method is not exposed by CLI surface V1', { httpStatus: 404 }) @@ -534,13 +551,25 @@ export class CliServer { abortRequest( controller, new CliRequestError('timeout', 'Request timed out', { - httpStatus: 504 + httpStatus: 504, + retriable: true }) ) }, entry.limits.timeoutMs) timeout.unref() let rawOutput: unknown try { + if (requestTransport === 'stream') { + await this.dispatchStreamResponse( + response, + entry, + input, + caller, + requestId, + controller.signal + ) + return + } rawOutput = await runAbortable(controller.signal, async () => this.dependencies.dispatch(entry.contract.name, input, caller, controller.signal) ) @@ -550,17 +579,7 @@ export class CliServer { if (controller.signal.aborted) { throw requestAbortError(controller.signal) } - const parsedOutput = entry.contract.output.safeParse(rawOutput) - const parsedResult = parsedOutput.success - ? JsonValueSchema.safeParse(parsedOutput.data) - : { success: false as const } - if (!parsedOutput.success || !parsedResult.success) { - this.log.error('[CLI] Route returned invalid output', { method: routeMethod }) - throw new CliRequestError('internal_error', 'Route returned an invalid result', { - httpStatus: 500 - }) - } - const result = parsedResult.data as JsonValue + const result = this.parseRouteOutput(entry, rawOutput, routeMethod) this.sendJson(response, 200, createLocalControlSuccess(requestId, result)) } catch (error) { if (error instanceof CliRequestError) { @@ -599,6 +618,142 @@ export class CliServer { } } + private parseRouteOutput( + entry: CliSurfaceEntry, + rawOutput: unknown, + routeMethod: string + ): JsonValue { + const parsedOutput = entry.contract.output.safeParse(rawOutput) + const parsedResult = parsedOutput.success + ? JsonValueSchema.safeParse(parsedOutput.data) + : { success: false as const } + if (!parsedOutput.success || !parsedResult.success) { + this.log.error('[CLI] Route returned invalid output', { method: routeMethod }) + throw new CliRequestError('internal_error', 'Route returned an invalid result', { + httpStatus: 500 + }) + } + return parsedResult.data as JsonValue + } + + private async dispatchStreamResponse( + response: ServerResponse, + entry: CliSurfaceEntry, + input: unknown, + caller: CliRouteCaller, + requestId: string, + signal: AbortSignal + ): Promise { + const dispatchStream = this.dependencies.dispatchStream + if (!dispatchStream) { + throw new CliRequestError('unavailable', 'Streaming service is unavailable', { + httpStatus: 503, + retriable: true + }) + } + + response.statusCode = 200 + response.shouldKeepAlive = false + response.setHeader('Connection', 'close') + response.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8') + response.flushHeaders() + let sequence = 0 + const emit: CliStreamEmitter = async (event, data) => { + if (signal.aborted) throw requestAbortError(signal) + const parsed = LocalControlEventEnvelopeSchema.safeParse({ + protocolVersion: LOCAL_CONTROL_PROTOCOL_VERSION, + surfaceVersion: LOCAL_CONTROL_SURFACE_VERSION, + sequence, + timestamp: this.now(), + requestId, + event, + data + }) + if (!parsed.success) { + throw new CliRequestError('internal_error', 'Stream emitted an invalid event', { + httpStatus: 500 + }) + } + sequence += 1 + await this.writeStreamRecord(response, parsed.data, signal) + } + + try { + const rawOutput = await runAbortable(signal, async () => + dispatchStream(entry.contract.name, input, caller, signal, emit) + ) + if (signal.aborted) throw requestAbortError(signal) + const result = this.parseRouteOutput(entry, rawOutput, entry.contract.name) + await this.writeStreamRecord(response, createLocalControlSuccess(requestId, result), signal) + } catch (error) { + const failure = signal.aborted + ? requestAbortError(signal) + : error instanceof CliRequestError + ? error + : new CliRequestError('internal_error', 'Streaming operation failed', { + httpStatus: 500 + }) + if (!(error instanceof CliRequestError) && !signal.aborted) { + this.log.warn('[CLI] Stream dispatch failed', { method: entry.contract.name }, error) + } + if (!response.destroyed && !response.writableEnded) { + await this.writeStreamRecord(response, this.createFailureRecord(requestId, failure)).catch( + () => { + if (!response.destroyed) response.destroy() + } + ) + } + } finally { + if (!response.destroyed && !response.writableEnded) response.end() + } + } + + private async writeStreamRecord( + response: ServerResponse, + record: LocalControlStreamRecord, + signal?: AbortSignal + ): Promise { + if (signal?.aborted) throw requestAbortError(signal) + const serialized = Buffer.from(`${JSON.stringify(record)}\n`, 'utf8') + if (serialized.length > LOCAL_CONTROL_MAX_STREAM_RECORD_BYTES) { + throw new CliRequestError('internal_error', 'Stream record exceeds its byte limit', { + httpStatus: 500 + }) + } + if (response.destroyed || response.writableEnded) { + throw new CliRequestError('cancelled', 'Stream connection is closed') + } + + await new Promise((resolve, reject) => { + let settled = false + const finish = (error?: Error) => { + if (settled) return + settled = true + signal?.removeEventListener('abort', onAbort) + response.off('error', onError) + response.off('close', onClose) + if (error) reject(error) + else resolve() + } + const onAbort = () => finish(requestAbortError(signal!)) + const onError = (error: Error) => finish(error) + const onClose = () => finish(new CliRequestError('cancelled', 'Stream connection is closed')) + signal?.addEventListener('abort', onAbort, { once: true }) + response.once('error', onError) + response.once('close', onClose) + response.write(serialized, (error) => finish(error ?? undefined)) + }) + } + + private createFailureRecord(requestId: string, error: CliRequestError) { + return createLocalControlFailure(requestId, { + code: error.code, + message: (error.message || 'Local-control request failed').slice(0, 4096), + retriable: error.retriable, + ...(error.options.details ? { details: error.options.details } : {}) + }) + } + private async handleArtifactDownload( request: IncomingMessage, response: ServerResponse, @@ -728,23 +883,31 @@ export class CliServer { requestId: string, error: CliRequestError ): void { - this.sendJson( - response, - status, - createLocalControlFailure(requestId, { - code: error.code, - message: error.message, - retriable: error.retriable, - ...(error.options.details ? { details: error.options.details } : {}) - }) - ) + this.sendJson(response, status, this.createFailureRecord(requestId, error)) } private sendJson(response: ServerResponse, status: number, body: JsonValue): void { if (response.destroyed || response.writableEnded) return - const serialized = Buffer.from(JSON.stringify(body), 'utf8') - response.statusCode = status - if (status >= 400) { + let responseStatus = status + let serialized = Buffer.from(JSON.stringify(body), 'utf8') + if (serialized.length > LOCAL_CONTROL_MAX_JSON_RESPONSE_BYTES) { + responseStatus = 500 + serialized = Buffer.from( + JSON.stringify( + createLocalControlFailure( + isRecord(body) ? toSafeRequestId(body.id) : UNKNOWN_REQUEST_ID, + { + code: 'result_too_large', + message: 'Local-control response exceeds its byte limit', + retriable: false + } + ) + ), + 'utf8' + ) + } + response.statusCode = responseStatus + if (responseStatus >= 400) { response.shouldKeepAlive = false response.setHeader('Connection', 'close') } diff --git a/src/main/cli/surface.ts b/src/main/cli/surface.ts index bc911b926..432e53a15 100644 --- a/src/main/cli/surface.ts +++ b/src/main/cli/surface.ts @@ -7,12 +7,15 @@ import { cliDoctorRoute, cliStatusRoute, cliVersionRoute, + modelsInvokeRoute, + providersListPublicRoute, type CliCapability } from '@shared/contracts/routes' -import type { - LocalControlEffect, - LocalControlPrincipal, - LocalControlScope +import { + LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS, + type LocalControlEffect, + type LocalControlPrincipal, + type LocalControlScope } from '@shared/contracts/localControl' export type LocalControlTransport = 'rpc' | 'stream' | 'upload' | 'download' @@ -49,6 +52,24 @@ const diagnosticEntry = (contract: RouteContract): CliSurfaceEntry => ({ }) const CLI_SURFACE_V1_ENTRIES = [ + { + contract: modelsInvokeRoute, + effect: 'compute', + callers: ['human', 'agent'], + scopes: ['models:invoke'], + transport: 'stream', + approval: 'never', + limits: { maxBodyBytes: 5 * 1024 * 1024, timeoutMs: LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS } + }, + { + contract: providersListPublicRoute, + effect: 'read', + callers: ['human', 'agent'], + scopes: ['providers:read'], + transport: 'rpc', + approval: 'never', + limits: DIAGNOSTIC_LIMITS + }, { contract: artifactsDescribeRoute, effect: 'read', diff --git a/src/shared/contracts/localControl.ts b/src/shared/contracts/localControl.ts index d1f3dbb73..47244f904 100644 --- a/src/shared/contracts/localControl.ts +++ b/src/shared/contracts/localControl.ts @@ -3,8 +3,12 @@ import { JsonValueSchema, TimestampMsSchema, type JsonValue } from './json' export const LOCAL_CONTROL_PROTOCOL_VERSION = 1 as const export const LOCAL_CONTROL_SURFACE_VERSION = 1 as const +export const LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS = 30 * 60_000 +export const LOCAL_CONTROL_MAX_JSON_RESPONSE_BYTES = 16 * 1024 * 1024 +export const LOCAL_CONTROL_MAX_STREAM_RECORD_BYTES = 20 * 1024 * 1024 export const LOCAL_CONTROL_DESCRIPTOR_FILENAME = 'local-control.json' export const LOCAL_CONTROL_RPC_PATH = '/v1/rpc' +export const LOCAL_CONTROL_STREAM_PATH = '/v1/stream' export const LOCAL_CONTROL_ARTIFACT_PATH_PREFIX = '/v1/artifacts/' export const LOCAL_CONTROL_AGENT_TOKEN_ENV = 'DEEPCHAT_CLI_AGENT_TOKEN' @@ -140,6 +144,7 @@ export const LOCAL_CONTROL_ERROR_CODES = [ 'conflict', 'rate_limited', 'body_too_large', + 'result_too_large', 'unavailable', 'cancelled', 'timeout', @@ -191,6 +196,11 @@ export const LocalControlEventEnvelopeSchema = z }) .strict() +export const LocalControlStreamRecordSchema = z.union([ + LocalControlEventEnvelopeSchema, + LocalControlRpcResponseSchema +]) + export type LocalControlEffect = z.infer export type LocalControlScope = z.infer export type LocalControlPrincipal = z.infer @@ -201,6 +211,7 @@ export type LocalControlErrorCode = z.infer export type LocalControlError = z.infer export type LocalControlRpcResponse = z.infer export type LocalControlEventEnvelope = z.infer +export type LocalControlStreamRecord = z.infer export function createLocalControlSuccess(id: string, result: JsonValue): LocalControlRpcResponse { return LocalControlRpcResponseSchema.parse({ diff --git a/src/shared/contracts/routes.ts b/src/shared/contracts/routes.ts index bc2de6b35..235b57e2c 100644 --- a/src/shared/contracts/routes.ts +++ b/src/shared/contracts/routes.ts @@ -268,6 +268,7 @@ import { modelsGetProviderConfigsRoute, modelsHasUserConfigRoute, modelsImportConfigsRoute, + modelsInvokeRoute, modelsListRuntimeRoute, modelsRemoveCustomRoute, modelsResetConfigRoute, @@ -358,6 +359,7 @@ import { providersListModelsRoute, providersListOllamaModelsRoute, providersListOllamaRunningModelsRoute, + providersListPublicRoute, providersListRoute, providersListSummariesRoute, providersPullOllamaModelRoute, @@ -905,6 +907,7 @@ const DEEPCHAT_ROUTE_CATALOG_PART_4 = { [sessionsUpdateGenerationSettingsRoute.name]: sessionsUpdateGenerationSettingsRoute, [providersListRoute.name]: providersListRoute, [providersListSummariesRoute.name]: providersListSummariesRoute, + [providersListPublicRoute.name]: providersListPublicRoute, [providersListDefaultsRoute.name]: providersListDefaultsRoute, [providersSetByIdRoute.name]: providersSetByIdRoute, [providersUpdateRoute.name]: providersUpdateRoute, @@ -928,6 +931,7 @@ const DEEPCHAT_ROUTE_CATALOG_PART_4 = { [providersImportScanRoute.name]: providersImportScanRoute, [providersImportApplyRoute.name]: providersImportApplyRoute, [modelsGetProviderCatalogRoute.name]: modelsGetProviderCatalogRoute, + [modelsInvokeRoute.name]: modelsInvokeRoute, [modelsListRuntimeRoute.name]: modelsListRuntimeRoute, [modelsSetBatchStatusRoute.name]: modelsSetBatchStatusRoute, [modelsSetStatusRoute.name]: modelsSetStatusRoute, diff --git a/src/shared/contracts/routes/models.routes.ts b/src/shared/contracts/routes/models.routes.ts index ccab8328d..b04d1c459 100644 --- a/src/shared/contracts/routes/models.routes.ts +++ b/src/shared/contracts/routes/models.routes.ts @@ -9,6 +9,105 @@ import { } from '../domainSchemas' import { CapabilitySnapshotQuerySchema } from '../../types/model-capabilities' +export const MODEL_INVOKE_MAX_TOTAL_INPUT_CHARACTERS = 4 * 1024 * 1024 +export const MODEL_INVOKE_MAX_OUTPUT_CHARACTERS = 3 * 1024 * 1024 + +export const ModelInvokeUsageSchema = z + .object({ + promptTokens: z.number().int().nonnegative(), + completionTokens: z.number().int().nonnegative(), + totalTokens: z.number().int().nonnegative(), + cachedTokens: z.number().int().nonnegative().optional(), + cacheWriteTokens: z.number().int().nonnegative().optional() + }) + .strict() + +export const ModelInvokeEventSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('text_delta'), text: z.string().max(1024 * 1024) }).strict(), + z.object({ type: z.literal('reasoning_delta'), text: z.string().max(1024 * 1024) }).strict(), + z.object({ type: z.literal('usage'), usage: ModelInvokeUsageSchema }).strict(), + z + .object({ + type: z.literal('rate_limit'), + providerId: EntityIdSchema.max(128), + qpsLimit: z.number().nonnegative(), + currentQps: z.number().nonnegative(), + queueLength: z.number().int().nonnegative(), + estimatedWaitTimeMs: z.number().nonnegative().optional() + }) + .strict(), + z + .object({ + type: z.literal('stop'), + reason: z.enum(['tool_use', 'max_tokens', 'max_turn_requests', 'error', 'complete']) + }) + .strict() +]) + +const ModelInvokeMessageSchema = z + .object({ + role: z.enum(['system', 'user', 'assistant']), + content: z + .string() + .min(1) + .max(1024 * 1024) + }) + .strict() + +export const modelsInvokeRoute = defineRouteContract({ + name: 'models.invoke', + input: z + .object({ + providerId: EntityIdSchema.max(128), + modelId: z.string().min(1).max(256), + messages: z.array(ModelInvokeMessageSchema).min(1).max(128), + temperature: z.number().min(0).max(2).optional(), + maxTokens: z.number().int().min(1).max(1_000_000).optional() + }) + .strict() + .superRefine((input, context) => { + const totalCharacters = input.messages.reduce( + (total, message) => total + message.content.length, + 0 + ) + if (totalCharacters > MODEL_INVOKE_MAX_TOTAL_INPUT_CHARACTERS) { + context.addIssue({ + code: 'custom', + message: 'Model invocation input exceeds the total character limit', + path: ['messages'] + }) + } + }), + output: z + .object({ + providerId: EntityIdSchema.max(128), + modelId: z.string().min(1).max(256), + text: z.string().max(MODEL_INVOKE_MAX_OUTPUT_CHARACTERS), + reasoning: z.string().max(MODEL_INVOKE_MAX_OUTPUT_CHARACTERS).optional(), + usage: ModelInvokeUsageSchema.optional(), + finishReason: z.enum(['tool_use', 'max_tokens', 'max_turn_requests', 'error', 'complete']), + durationMs: z.number().int().nonnegative(), + ttftMs: z.number().int().nonnegative().nullable() + }) + .strict() + .superRefine((output, context) => { + if ( + output.text.length + (output.reasoning?.length ?? 0) > + MODEL_INVOKE_MAX_OUTPUT_CHARACTERS + ) { + context.addIssue({ + code: 'custom', + message: 'Model invocation output exceeds the total character limit', + path: ['text'] + }) + } + }) +}) + +export type ModelInvokeInput = z.infer +export type ModelInvokeOutput = z.infer +export type ModelInvokeEvent = z.infer + export const modelsGetProviderCatalogRoute = defineRouteContract({ name: 'models.getProviderCatalog', input: z.object({ diff --git a/src/shared/contracts/routes/providers.routes.ts b/src/shared/contracts/routes/providers.routes.ts index a5aed0d09..37cebe937 100644 --- a/src/shared/contracts/routes/providers.routes.ts +++ b/src/shared/contracts/routes/providers.routes.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { EntityIdSchema, ProviderModelSummarySchema, defineRouteContract } from '../common' +import { ModelType } from '../../model' import { AcpDebugActionSchema, AcpDebugRunResultSchema, @@ -13,6 +14,43 @@ import { } from '../domainSchemas' import { PROVIDER_IMPORT_CUSTOM_API_TYPES, PROVIDER_IMPORT_SOURCE_IDS } from '../../providerImport' +export const PublicProviderModelSchema = z + .object({ + id: z.string().min(1).max(256), + name: z.string().max(256), + group: z.string().max(128), + enabled: z.boolean(), + custom: z.boolean(), + vision: z.boolean(), + functionCall: z.boolean(), + reasoning: z.boolean(), + enableSearch: z.boolean(), + type: z.enum(ModelType).optional(), + contextLength: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(), + maxTokens: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional() + }) + .strict() + +export const PublicProviderSchema = z + .object({ + id: EntityIdSchema.max(128), + name: z.string().min(1).max(256), + apiType: z.string().min(1).max(128), + enabled: z.boolean(), + custom: z.boolean(), + models: z.array(PublicProviderModelSchema).max(10_000) + }) + .strict() + +export const providersListPublicRoute = defineRouteContract({ + name: 'providers.listPublic', + input: z.object({ enabledOnly: z.boolean().optional() }).strict().default({}), + output: z.object({ providers: z.array(PublicProviderSchema).max(1_000) }).strict() +}) + +export type PublicProvider = z.infer +export type PublicProviderModel = z.infer + const ProviderImportSourceIdSchema = z.enum(PROVIDER_IMPORT_SOURCE_IDS) const ProviderImportCustomApiTypeSchema = z.enum(PROVIDER_IMPORT_CUSTOM_API_TYPES) const ProviderImportTargetKindSchema = z.enum(['builtin', 'custom', 'unsupported']) diff --git a/test/main/cli/args.test.ts b/test/main/cli/args.test.ts index 1e13aaf52..918f80856 100644 --- a/test/main/cli/args.test.ts +++ b/test/main/cli/args.test.ts @@ -83,4 +83,69 @@ describe('CLI argument grammar', () => { parseCliArguments(['artifact', 'get', '--id', id, '--out', '--overwrite'], {}) ).toThrow('Missing value for --out') }) + + it('parses raw model input without allowing an ambiguous prompt source', () => { + expect( + parseCliArguments( + [ + 'model', + 'invoke', + '--provider', + 'provider-1', + '--model', + 'model-1', + '--system', + 'Be concise', + '--stdin', + '--temperature=0.2', + '--max-tokens', + '256' + ], + {} + ) + ).toMatchObject({ + operation: 'stream', + readStdin: true, + timeoutMs: 1_800_000, + params: { + providerId: 'provider-1', + modelId: 'model-1', + messages: [{ role: 'system', content: 'Be concise' }], + temperature: 0.2, + maxTokens: 256 + } + }) + + expect(() => + parseCliArguments( + [ + 'model', + 'invoke', + '--provider', + 'provider-1', + '--model', + 'model-1', + '--prompt', + 'hello', + '--stdin' + ], + {} + ) + ).toThrow('exactly one of --prompt or --stdin') + }) + + it('keeps model flags after the two-token capability signature', () => { + expect(() => + parseCliArguments( + ['--json', 'model', 'invoke', '--provider', 'provider-1', '--model', 'model-1'], + {} + ) + ).toThrow('deepchat ') + expect(() => parseCliArguments(['provider', 'list', '--provider', 'provider-1'], {})).toThrow( + 'not valid for deepchat provider list' + ) + expect(parseCliArguments(['provider', 'list', '--enabled-only'], {})).toMatchObject({ + params: { enabledOnly: true } + }) + }) }) diff --git a/test/main/cli/client.test.ts b/test/main/cli/client.test.ts index 1408da535..ef86100d0 100644 --- a/test/main/cli/client.test.ts +++ b/test/main/cli/client.test.ts @@ -4,6 +4,7 @@ import os from 'node:os' import path from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { DeepchatRouteName } from '@shared/contracts/routes' +import type { JsonValue } from '@shared/contracts/json' import { LOCAL_CONTROL_AGENT_TOKEN_ENV, LocalControlRpcResponseSchema @@ -29,8 +30,15 @@ function captureOutput(): { stream: NodeJS.WriteStream; read(): string } { } } -async function createClientServer(options: { hang?: boolean } = {}): Promise<{ +async function createClientServer( + options: { + hang?: boolean + hangStream?: boolean + stream?: Readonly<{ events: readonly JsonValue[]; result: unknown }> + } = {} +): Promise<{ userDataPath: string + server: CliServer dispatch: ReturnType }> { const userDataPath = await mkdtemp(path.join(os.tmpdir(), 'deepchat-cli-client-')) @@ -53,11 +61,26 @@ async function createClientServer(options: { hang?: boolean } = {}): Promise<{ userDataPath, appVersion: '9.8.7', dispatch, + ...(options.stream || options.hangStream + ? { + dispatchStream: async ( + method: string, + _input: unknown, + _caller: CliRouteCaller, + _signal: AbortSignal, + emit: (event: string, data: JsonValue) => Promise + ) => { + if (options.hangStream) return await new Promise(() => undefined) + for (const event of options.stream?.events ?? []) await emit(method, event) + return options.stream?.result + } + } + : {}), log: { warn: vi.fn(), error: vi.fn() } }) servers.push(server) await server.start() - return { userDataPath, dispatch } + return { userDataPath, server, dispatch } } function runWithCapturedOutput( @@ -207,4 +230,84 @@ describe('bundled CLI client', () => { } ) }) + + it('renders streamed model text once and preserves JSONL event records', async () => { + const stream = { + events: [ + { type: 'text_delta', text: 'Hel' }, + { type: 'text_delta', text: 'lo' }, + { type: 'stop', reason: 'complete' } + ], + result: { + providerId: 'provider-1', + modelId: 'model-1', + text: 'Hello', + finishReason: 'complete', + durationMs: 10, + ttftMs: 1 + } + } as const + const { userDataPath } = await createClientServer({ stream }) + const environment = { DEEPCHAT_E2E_USER_DATA_DIR: userDataPath } + + const textInvocation = runWithCapturedOutput( + ['model', 'invoke', '--provider', 'provider-1', '--model', 'model-1', '--prompt', 'hello'], + environment + ) + await expect(textInvocation.result).resolves.toBe(0) + expect(textInvocation.stdout.read()).toBe('Hello\n') + + const jsonlInvocation = runWithCapturedOutput( + [ + 'model', + 'invoke', + '--provider', + 'provider-1', + '--model', + 'model-1', + '--prompt', + 'hello', + '--jsonl' + ], + environment + ) + await expect(jsonlInvocation.result).resolves.toBe(0) + const records = jsonlInvocation.stdout + .read() + .trimEnd() + .split('\n') + .map((line) => JSON.parse(line) as Record) + expect(records).toHaveLength(4) + expect(records.slice(0, 3).map((record) => record.sequence)).toEqual([0, 1, 2]) + expect(records[3]).toMatchObject({ ok: true, result: { text: 'Hello' } }) + }) + + it('cancels a stream promptly even when its provider ignores the signal', async () => { + const { userDataPath, server } = await createClientServer({ hangStream: true }) + const invocation = runWithCapturedOutput( + [ + 'model', + 'invoke', + '--provider', + 'provider-1', + '--model', + 'model-1', + '--prompt', + 'hello', + '--json', + '--timeout', + '10' + ], + { DEEPCHAT_E2E_USER_DATA_DIR: userDataPath } + ) + + await expect(invocation.result).resolves.toBe(7) + expect(LocalControlRpcResponseSchema.parse(JSON.parse(invocation.stdout.read()))).toMatchObject( + { + ok: false, + error: { code: 'timeout' } + } + ) + await vi.waitFor(() => expect(server.getStatus().pendingRequests).toBe(0)) + }) }) diff --git a/test/main/cli/computeService.test.ts b/test/main/cli/computeService.test.ts new file mode 100644 index 000000000..30ad85ef5 --- /dev/null +++ b/test/main/cli/computeService.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it, vi } from 'vitest' +import { modelsInvokeRoute, type ModelInvokeEvent } from '@shared/contracts/routes' +import { ModelType } from '@shared/model' +import type { LLMCoreStreamEvent } from '@shared/types/core/llm-events' +import type { MODEL_META, ModelConfig } from '@shared/types/provider' +import { CliComputeService, type CliComputeServiceOptions } from '@/cli/computeService' +import type { CliRouteCaller } from '@/routes/routeRegistry' + +const provider = { + id: 'provider-1', + name: 'Provider One', + apiType: 'openai-compatible', + apiKey: 'secret-key', + baseUrl: 'https://private.example', + enable: true, + custom: true +} + +const model: MODEL_META = { + id: 'model-1', + name: 'Model One', + group: 'default', + providerId: provider.id, + type: ModelType.Chat +} + +const modelConfig: ModelConfig = { + maxTokens: 4_096, + contextLength: 32_768, + temperature: 0.7, + vision: false, + functionCall: true, + reasoning: true, + type: ModelType.Chat +} + +const caller: CliRouteCaller = { + kind: 'cli', + principal: 'human', + connectionId: 'connection-1', + scopes: ['models:invoke'] +} + +async function* streamEvents( + events: readonly LLMCoreStreamEvent[] +): AsyncGenerator { + for (const event of events) yield event +} + +function createService(events: readonly LLMCoreStreamEvent[]) { + const providerSettings: CliComputeServiceOptions['providerSettings'] = { + getProviders: vi.fn(() => [provider]), + getProviderById: vi.fn(() => provider), + getProviderModels: vi.fn(() => [model]), + getCustomModels: vi.fn(() => []), + getBatchModelStatus: vi.fn(() => ({ [model.id]: true })), + getModelStatus: vi.fn(() => true), + isKnownModel: vi.fn(() => true), + getModelConfig: vi.fn(() => modelConfig) + } + const providerRuntime: CliComputeServiceOptions['providerRuntime'] = { + executeWithRateLimit: vi.fn(async () => undefined), + streamChat: vi.fn(() => streamEvents(events)) + } + const log = { warn: vi.fn() } + return { + service: new CliComputeService({ providerSettings, providerRuntime, log, now: () => 100 }), + providerSettings, + providerRuntime, + log + } +} + +describe('CLI compute service', () => { + it('returns an explicitly redacted provider and model view', () => { + const { service } = createService([]) + + const result = service.listPublicProviders() + + expect(result).toEqual([ + { + id: 'provider-1', + name: 'Provider One', + apiType: 'openai-compatible', + enabled: true, + custom: true, + models: [ + { + id: 'model-1', + name: 'Model One', + group: 'default', + enabled: true, + custom: false, + vision: false, + functionCall: false, + reasoning: false, + enableSearch: false, + type: ModelType.Chat + } + ] + } + ]) + expect(JSON.stringify(result)).not.toContain('secret-key') + expect(JSON.stringify(result)).not.toContain('private.example') + }) + + it('streams only typed raw-model events and never enables tools', async () => { + const { service, providerRuntime } = createService([ + { type: 'text', content: 'Hel' }, + { type: 'text', content: '' }, + { type: 'reasoning', reasoning_content: 'Think' }, + { type: 'text', content: 'lo' }, + { + type: 'usage', + usage: { prompt_tokens: 2, completion_tokens: 3, total_tokens: 5 } + }, + { type: 'stop', stop_reason: 'complete' } + ]) + const emitted: ModelInvokeEvent[] = [] + const signal = new AbortController().signal + + const result = await service.dispatchStream( + modelsInvokeRoute.name, + { + providerId: provider.id, + modelId: model.id, + messages: [{ role: 'user', content: 'hello' }] + }, + caller, + signal, + async (event, data) => { + expect(event).toBe(modelsInvokeRoute.name) + emitted.push(data as ModelInvokeEvent) + } + ) + + expect(result).toMatchObject({ + providerId: provider.id, + modelId: model.id, + text: 'Hello', + reasoning: 'Think', + usage: { totalTokens: 5 }, + finishReason: 'complete' + }) + expect(emitted.map((event) => event.type)).toEqual([ + 'text_delta', + 'reasoning_delta', + 'text_delta', + 'usage', + 'stop' + ]) + const streamCall = vi.mocked(providerRuntime.streamChat).mock.calls[0] + expect(streamCall?.[6]).toEqual([]) + expect(streamCall?.[7]).toEqual({ signal }) + }) + + it('does not expose provider error details through the local protocol', async () => { + const { service, log } = createService([ + { type: 'error', error_message: 'secret upstream response' } + ]) + + await expect( + service.dispatchStream( + modelsInvokeRoute.name, + { + providerId: provider.id, + modelId: model.id, + messages: [{ role: 'user', content: 'hello' }] + }, + caller, + new AbortController().signal, + async () => undefined + ) + ).rejects.toMatchObject({ + code: 'unavailable', + message: 'Model provider request failed', + retriable: true + }) + expect(log.warn).toHaveBeenCalledOnce() + expect(JSON.stringify(log.warn.mock.calls)).not.toContain('secret upstream response') + }) + + it('rejects tool events instead of turning raw invocation into an Agent run', async () => { + const { service } = createService([ + { type: 'tool_call_start', tool_call_id: 'call-1', tool_call_name: 'dangerous_tool' } + ]) + + await expect( + service.dispatchStream( + modelsInvokeRoute.name, + { + providerId: provider.id, + modelId: model.id, + messages: [{ role: 'user', content: 'hello' }] + }, + caller, + new AbortController().signal, + async () => undefined + ) + ).rejects.toMatchObject({ + code: 'conflict', + message: 'Raw model invocation returned an unsupported event' + }) + }) +}) diff --git a/test/main/cli/server.test.ts b/test/main/cli/server.test.ts index f1215421a..197b02662 100644 --- a/test/main/cli/server.test.ts +++ b/test/main/cli/server.test.ts @@ -4,6 +4,7 @@ import os from 'node:os' import path from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { DeepchatRouteName } from '@shared/contracts/routes' +import type { JsonValue } from '@shared/contracts/json' import { LOCAL_CONTROL_PROTOCOL_VERSION, LOCAL_CONTROL_SCOPES, @@ -17,6 +18,7 @@ import { import { createCliRoutes } from '@/cli/routes' import { CliServer, type AgentCliToken } from '@/cli/server' import type { CliRouteCaller } from '@/routes/routeRegistry' +import { invokeLocalControlStream } from '../../../src/cli/transport' type RpcResult = Readonly<{ status: number @@ -106,6 +108,7 @@ async function createTestServer( options: { resolveAgentToken?: (token: string) => AgentCliToken | null dispatchOutput?: (method: string) => unknown + streamOutput?: Readonly<{ events: readonly JsonValue[]; result: unknown }> } = {} ): Promise<{ server: CliServer @@ -131,6 +134,20 @@ async function createTestServer( userDataPath, appVersion: '1.2.3', dispatch, + ...(options.streamOutput + ? { + dispatchStream: async ( + method: string, + _input: unknown, + _caller: CliRouteCaller, + _signal: AbortSignal, + emit: (event: string, data: JsonValue) => Promise + ) => { + for (const event of options.streamOutput?.events ?? []) await emit(method, event) + return options.streamOutput?.result + } + } + : {}), resolveAgentToken: options.resolveAgentToken, log: { warn: vi.fn(), error: vi.fn() } }) @@ -331,4 +348,47 @@ describe('CLI local transport', () => { scopes: ['system:read'] }) }) + + it('streams typed events and one terminal route result', async () => { + const { server, descriptor } = await createTestServer({ + streamOutput: { + events: [ + { type: 'text_delta', text: 'hello' }, + { type: 'stop', reason: 'complete' } + ], + result: { + providerId: 'provider-1', + modelId: 'model-1', + text: 'hello', + finishReason: 'complete', + durationMs: 10, + ttftMs: 1 + } + } + }) + const events: JsonValue[] = [] + + const result = await invokeLocalControlStream( + { + descriptor, + token: descriptor.token, + id: 'request-stream-1', + method: 'models.invoke', + params: { + providerId: 'provider-1', + modelId: 'model-1', + messages: [{ role: 'user', content: 'hello' }] + }, + signal: new AbortController().signal + }, + async (event) => events.push(event.data) + ) + + expect(events).toEqual([ + { type: 'text_delta', text: 'hello' }, + { type: 'stop', reason: 'complete' } + ]) + expect(result).toMatchObject({ ok: true, result: { text: 'hello' } }) + expect(server.getStatus().pendingRequests).toBe(0) + }) }) diff --git a/test/main/cli/stdin.test.ts b/test/main/cli/stdin.test.ts new file mode 100644 index 000000000..e0372042e --- /dev/null +++ b/test/main/cli/stdin.test.ts @@ -0,0 +1,29 @@ +import { Readable } from 'node:stream' +import { describe, expect, it } from 'vitest' +import { readBoundedUtf8Stdin } from '../../../src/cli/stdin' + +describe('CLI standard input', () => { + it('reads chunked UTF-8 without changing the prompt', async () => { + const input = Readable.from([Buffer.from('你好'), Buffer.from('\nDeepChat')]) + + await expect(readBoundedUtf8Stdin(input, new AbortController().signal, 64)).resolves.toBe( + '你好\nDeepChat' + ) + }) + + it('rejects cumulative overflow before constructing the final string', async () => { + const input = Readable.from([Buffer.from('1234'), Buffer.from('56789')]) + + await expect( + readBoundedUtf8Stdin(input, new AbortController().signal, 8) + ).rejects.toMatchObject({ code: 'body_too_large', exitCode: 2 }) + }) + + it('rejects malformed UTF-8', async () => { + const input = Readable.from([Buffer.from([0xc3, 0x28])]) + + await expect( + readBoundedUtf8Stdin(input, new AbortController().signal, 8) + ).rejects.toMatchObject({ code: 'invalid_request', exitCode: 2 }) + }) +}) diff --git a/test/main/cli/surface.test.ts b/test/main/cli/surface.test.ts index dc9838e92..353060a71 100644 --- a/test/main/cli/surface.test.ts +++ b/test/main/cli/surface.test.ts @@ -13,7 +13,9 @@ describe('CLI surface V1', () => { 'cli.capabilities', 'cli.doctor', 'cli.status', - 'cli.version' + 'cli.version', + 'models.invoke', + 'providers.listPublic' ]) for (const [method, entry] of CLI_SURFACE_V1) { expect(entry.contract).toBe( @@ -36,7 +38,13 @@ describe('CLI surface V1', () => { expect.objectContaining({ method: 'cli.capabilities', effect: 'read' }), expect.objectContaining({ method: 'cli.doctor', effect: 'read' }), expect.objectContaining({ method: 'cli.status', effect: 'read' }), - expect.objectContaining({ method: 'cli.version', effect: 'read' }) + expect.objectContaining({ method: 'cli.version', effect: 'read' }), + expect.objectContaining({ + method: 'models.invoke', + effect: 'compute', + transport: 'stream' + }), + expect.objectContaining({ method: 'providers.listPublic', effect: 'read' }) ]) expect( listCliSurfaceCapabilities().every((capability) => capability.approval === 'never') diff --git a/test/main/cli/transport.test.ts b/test/main/cli/transport.test.ts index c6d7fd4fb..4040b4707 100644 --- a/test/main/cli/transport.test.ts +++ b/test/main/cli/transport.test.ts @@ -7,9 +7,10 @@ import { LOCAL_CONTROL_SURFACE_VERSION, createLocalControlSuccess, type LocalControlDescriptor, - type LocalControlEndpoint + type LocalControlEndpoint, + type LocalControlEventEnvelope } from '@shared/contracts/localControl' -import { invokeLocalControlRpc } from '../../../src/cli/transport' +import { invokeLocalControlRpc, invokeLocalControlStream } from '../../../src/cli/transport' const servers: Server[] = [] const socketPaths: string[] = [] @@ -53,6 +54,27 @@ async function invoke(descriptor: LocalControlDescriptor) { }) } +async function invokeStream( + descriptor: LocalControlDescriptor, + onEvent: (event: LocalControlEventEnvelope) => void | Promise +) { + return await invokeLocalControlStream( + { + descriptor, + token: descriptor.token, + id: 'request-1', + method: 'models.invoke', + params: { + providerId: 'provider-1', + modelId: 'model-1', + messages: [{ role: 'user', content: 'hello' }] + }, + signal: new AbortController().signal + }, + onEvent + ) +} + afterEach(async () => { await Promise.all( servers.splice(0).map( @@ -104,4 +126,62 @@ describe('CLI response transport', () => { exitCode: 8 }) }) + + it('consumes ordered NDJSON events before the terminal envelope', async () => { + const descriptor = await listen((_request, response) => { + response.setHeader('content-type', 'application/x-ndjson; charset=utf-8') + response.end( + [ + { + protocolVersion: LOCAL_CONTROL_PROTOCOL_VERSION, + surfaceVersion: LOCAL_CONTROL_SURFACE_VERSION, + sequence: 0, + timestamp: Date.now(), + requestId: 'request-1', + event: 'models.invoke', + data: { type: 'text_delta', text: 'hello' } + }, + createLocalControlSuccess('request-1', { + providerId: 'provider-1', + modelId: 'model-1', + text: 'hello', + finishReason: 'complete', + durationMs: 10, + ttftMs: 1 + }) + ] + .map((record) => JSON.stringify(record)) + .join('\n') + '\n' + ) + }) + const events: LocalControlEventEnvelope[] = [] + + const result = await invokeStream(descriptor, async (event) => events.push(event)) + + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ sequence: 0, data: { text: 'hello' } }) + expect(result).toMatchObject({ ok: true, result: { text: 'hello' } }) + }) + + it('rejects out-of-order stream events', async () => { + const descriptor = await listen((_request, response) => { + response.setHeader('content-type', 'application/x-ndjson; charset=utf-8') + response.end( + `${JSON.stringify({ + protocolVersion: LOCAL_CONTROL_PROTOCOL_VERSION, + surfaceVersion: LOCAL_CONTROL_SURFACE_VERSION, + sequence: 1, + timestamp: Date.now(), + requestId: 'request-1', + event: 'models.invoke', + data: { type: 'text_delta', text: 'hello' } + })}\n` + ) + }) + + await expect(invokeStream(descriptor, async () => undefined)).rejects.toMatchObject({ + code: 'internal_error', + exitCode: 8 + }) + }) }) From c57475fee1a50ba7b8af2c0e8da80a16f54c1878 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 12:37:06 +0800 Subject: [PATCH 09/51] feat(cli): add media generation --- .../architecture/local-control-plane/tasks.md | 2 +- src/cli/args.ts | 426 +++++++++++++----- src/cli/format.ts | 18 + src/cli/run.ts | 63 ++- src/main/app/composition.ts | 6 +- src/main/cli/artifactSpool.ts | 37 +- src/main/cli/computeService.ts | 366 ++++++++++++++- src/main/cli/mediaOutput.ts | 233 ++++++++++ src/main/cli/server.ts | 3 +- src/main/cli/surface.ts | 19 + src/shared/contracts/routes.ts | 9 + src/shared/contracts/routes/media.routes.ts | 142 ++++++ test/main/cli/args.test.ts | 185 +++++++- test/main/cli/artifactSpool.test.ts | 56 +++ test/main/cli/client.test.ts | 115 ++++- test/main/cli/computeService.test.ts | 315 ++++++++++++- test/main/cli/mediaOutput.test.ts | 106 +++++ test/main/cli/server.test.ts | 1 + test/main/cli/surface.test.ts | 22 +- 19 files changed, 1941 insertions(+), 183 deletions(-) create mode 100644 src/main/cli/mediaOutput.ts create mode 100644 src/shared/contracts/routes/media.routes.ts create mode 100644 test/main/cli/mediaOutput.test.ts diff --git a/docs/architecture/local-control-plane/tasks.md b/docs/architecture/local-control-plane/tasks.md index 59041c56b..d45d6fddb 100644 --- a/docs/architecture/local-control-plane/tasks.md +++ b/docs/architecture/local-control-plane/tasks.md @@ -30,7 +30,7 @@ ## Compute and Artifacts - [x] Add raw `models.invoke` over `coreStream` with no Agent/session/tool side effects. -- [ ] Add image and video standalone generation surfaces. +- [x] Add image and video standalone generation surfaces. - [x] Add formal standalone speech generation and typed audio output. - [ ] Add upload and owned-artifact transcription inputs. - [x] Implement output-only `ArtifactSpool` ownership, quotas, expiry, and cleanup. diff --git a/src/cli/args.ts b/src/cli/args.ts index 7752753ea..8ed600fd5 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -11,6 +11,11 @@ import { artifactsReadRoute } from '@shared/contracts/routes/artifacts.routes' import { modelsInvokeRoute } from '@shared/contracts/routes/models.routes' +import { + imagesGenerateRoute, + speechGenerateRoute, + videosGenerateRoute +} from '@shared/contracts/routes/media.routes' import { providersListPublicRoute } from '@shared/contracts/routes/providers.routes' import type { JsonValue } from '@shared/contracts/json' import { LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS } from '@shared/contracts/localControl' @@ -20,7 +25,7 @@ export const CLI_OUTPUT_ENV = 'DEEPCHAT_CLI_OUTPUT' export const CLI_TIMEOUT_ENV = 'DEEPCHAT_CLI_TIMEOUT_MS' export const DEFAULT_CLI_TIMEOUT_MS = 30_000 export const MAX_CLI_TIMEOUT_MS = LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS -export const DEFAULT_MODEL_INVOKE_TIMEOUT_MS = MAX_CLI_TIMEOUT_MS +export const DEFAULT_COMPUTE_TIMEOUT_MS = MAX_CLI_TIMEOUT_MS export type CliOutputMode = 'text' | 'json' | 'jsonl' export type CliRpcContract = @@ -32,6 +37,9 @@ export type CliRpcContract = | typeof artifactsReadRoute | typeof artifactsDeleteRoute | typeof modelsInvokeRoute + | typeof imagesGenerateRoute + | typeof videosGenerateRoute + | typeof speechGenerateRoute | typeof providersListPublicRoute export type CliCommandOperation = 'rpc' | 'stream' | 'download' @@ -59,9 +67,120 @@ const COMMANDS = new Map([ ['artifact get', artifactsReadRoute], ['artifact delete', artifactsDeleteRoute], ['model invoke', modelsInvokeRoute], + ['image generate', imagesGenerateRoute], + ['video generate', videosGenerateRoute], + ['audio speak', speechGenerateRoute], ['provider list', providersListPublicRoute] ]) +function parseBoolean(value: string, source: string): boolean { + if (value === 'true') return true + if (value === 'false') return false + throw new CliUsageError(`${source} must be true or false`) +} + +function parseNumberInRange( + value: string, + source: string, + minimum: number, + maximum: number, + integer = false +): number { + const parsed = Number(value) + if ( + !Number.isFinite(parsed) || + (integer && !Number.isSafeInteger(parsed)) || + parsed < minimum || + parsed > maximum + ) { + const qualifier = integer ? 'an integer' : 'a number' + throw new CliUsageError(`${source} must be ${qualifier} between ${minimum} and ${maximum}`) + } + return parsed +} + +type DomainOptionValue = string | number | boolean +type DomainValueParser = (value: string) => DomainOptionValue + +const stringOption: DomainValueParser = (value) => value +const VALUE_DOMAIN_OPTIONS: Readonly> = { + id: (value) => { + const parsed = ArtifactIdSchema.safeParse(value) + if (!parsed.success) throw new CliUsageError('--id is not a valid artifact identifier') + return parsed.data + }, + out: stringOption, + provider: stringOption, + model: stringOption, + prompt: stringOption, + text: stringOption, + system: stringOption, + temperature: (value) => parseNumberInRange(value, '--temperature', 0, 2), + 'max-tokens': (value) => parseNumberInRange(value, '--max-tokens', 1, 1_000_000, true), + size: stringOption, + quality: stringOption, + format: stringOption, + compression: (value) => parseNumberInRange(value, '--compression', 0, 100, true), + background: stringOption, + moderation: stringOption, + seconds: stringOption, + ratio: stringOption, + duration: (value) => parseNumberInRange(value, '--duration', -1, 3_600, true), + resolution: stringOption, + watermark: (value) => parseBoolean(value, '--watermark'), + audio: (value) => parseBoolean(value, '--audio'), + voice: stringOption, + speed: (value) => parseNumberInRange(value, '--speed', 0.25, 4), + instructions: stringOption +} + +const FLAG_DOMAIN_OPTIONS = new Set(['overwrite', 'stdin', 'enabled-only']) +const COMMAND_DOMAIN_OPTIONS = new Map>([ + ['artifact describe', new Set(['id'])], + ['artifact get', new Set(['id', 'out', 'overwrite'])], + ['artifact delete', new Set(['id'])], + [ + 'model invoke', + new Set(['provider', 'model', 'prompt', 'system', 'temperature', 'max-tokens', 'stdin']) + ], + [ + 'image generate', + new Set([ + 'provider', + 'model', + 'prompt', + 'stdin', + 'size', + 'quality', + 'format', + 'compression', + 'background', + 'moderation' + ]) + ], + [ + 'video generate', + new Set([ + 'provider', + 'model', + 'prompt', + 'stdin', + 'seconds', + 'size', + 'ratio', + 'duration', + 'resolution', + 'watermark', + 'audio' + ]) + ], + [ + 'audio speak', + new Set(['provider', 'model', 'text', 'stdin', 'voice', 'format', 'speed', 'instructions']) + ], + ['provider list', new Set(['enabled-only'])] +]) + function parseOutputMode(value: string | undefined): CliOutputMode { if (value === undefined || value.trim() === '') return 'text' const normalized = value.trim().toLowerCase() @@ -115,23 +234,16 @@ export function parseCliArguments( let explicitOutputMode: CliOutputMode | undefined let timeoutMs = env[CLI_TIMEOUT_ENV] ? parseTimeout(env[CLI_TIMEOUT_ENV], CLI_TIMEOUT_ENV) - : commandKey === 'model invoke' - ? DEFAULT_MODEL_INVOKE_TIMEOUT_MS + : commandKey === 'model invoke' || + commandKey === 'image generate' || + commandKey === 'video generate' || + commandKey === 'audio speak' + ? DEFAULT_COMPUTE_TIMEOUT_MS : DEFAULT_CLI_TIMEOUT_MS let timeoutSeen = false let helpRequested = false - let artifactId: string | undefined - let outputPath: string | undefined - let overwrite = false - let providerId: string | undefined - let modelId: string | undefined - let prompt: string | undefined - let systemPrompt: string | undefined - let temperature: number | undefined - let maxTokens: number | undefined - let readStdin = false - let enabledOnly = false const domainOptions = new Set() + const domainValues = new Map() const readOptionValue = ( argument: string, @@ -181,102 +293,72 @@ export function parseCliArguments( timeoutSeen = true continue } - if (argument === '--id' || argument.startsWith('--id=')) { - domainOptions.add('id') - if (artifactId !== undefined) throw new CliUsageError('--id may be specified only once') - const parsedOption = readOptionValue(argument, index) - const parsedId = ArtifactIdSchema.safeParse(parsedOption.value) - if (!parsedId.success) throw new CliUsageError('--id is not a valid artifact identifier') - artifactId = parsedId.data - index = parsedOption.nextIndex - continue - } - if (argument === '--out' || argument.startsWith('--out=')) { - domainOptions.add('out') - if (outputPath !== undefined) throw new CliUsageError('--out may be specified only once') - const parsedOption = readOptionValue(argument, index) - outputPath = parsedOption.value - index = parsedOption.nextIndex - continue - } - if (argument === '--overwrite') { - domainOptions.add('overwrite') - if (overwrite) throw new CliUsageError('--overwrite may be specified only once') - overwrite = true - continue - } - if (argument === '--provider' || argument.startsWith('--provider=')) { - domainOptions.add('provider') - if (providerId !== undefined) throw new CliUsageError('--provider may be specified only once') - const parsedOption = readOptionValue(argument, index) - providerId = parsedOption.value - index = parsedOption.nextIndex - continue - } - if (argument === '--model' || argument.startsWith('--model=')) { - domainOptions.add('model') - if (modelId !== undefined) throw new CliUsageError('--model may be specified only once') - const parsedOption = readOptionValue(argument, index) - modelId = parsedOption.value - index = parsedOption.nextIndex - continue - } - if (argument === '--prompt' || argument.startsWith('--prompt=')) { - domainOptions.add('prompt') - if (prompt !== undefined) throw new CliUsageError('--prompt may be specified only once') - const parsedOption = readOptionValue(argument, index) - prompt = parsedOption.value - index = parsedOption.nextIndex - continue - } - if (argument === '--system' || argument.startsWith('--system=')) { - domainOptions.add('system') - if (systemPrompt !== undefined) throw new CliUsageError('--system may be specified only once') - const parsedOption = readOptionValue(argument, index) - systemPrompt = parsedOption.value - index = parsedOption.nextIndex - continue - } - if (argument === '--temperature' || argument.startsWith('--temperature=')) { - domainOptions.add('temperature') - if (temperature !== undefined) { - throw new CliUsageError('--temperature may be specified only once') - } - const parsedOption = readOptionValue(argument, index) - temperature = Number(parsedOption.value) - if (!Number.isFinite(temperature) || temperature < 0 || temperature > 2) { - throw new CliUsageError('--temperature must be a number between 0 and 2') + if (argument.startsWith('--')) { + const equalsIndex = argument.indexOf('=') + const optionName = argument.slice(2, equalsIndex >= 0 ? equalsIndex : undefined) + if (FLAG_DOMAIN_OPTIONS.has(optionName) && equalsIndex < 0) { + if (domainOptions.has(optionName)) { + throw new CliUsageError(`--${optionName} may be specified only once`) + } + domainOptions.add(optionName) + domainValues.set(optionName, true) + continue } - index = parsedOption.nextIndex - continue - } - if (argument === '--max-tokens' || argument.startsWith('--max-tokens=')) { - domainOptions.add('max-tokens') - if (maxTokens !== undefined) - throw new CliUsageError('--max-tokens may be specified only once') - const parsedOption = readOptionValue(argument, index) - maxTokens = Number(parsedOption.value) - if (!Number.isSafeInteger(maxTokens) || maxTokens < 1 || maxTokens > 1_000_000) { - throw new CliUsageError('--max-tokens must be an integer between 1 and 1000000') + const parseValue = VALUE_DOMAIN_OPTIONS[optionName] + if (parseValue) { + if (domainOptions.has(optionName)) { + throw new CliUsageError(`--${optionName} may be specified only once`) + } + const parsedOption = readOptionValue(argument, index) + domainOptions.add(optionName) + domainValues.set(optionName, parseValue(parsedOption.value)) + index = parsedOption.nextIndex + continue } - index = parsedOption.nextIndex - continue - } - if (argument === '--stdin') { - domainOptions.add('stdin') - if (readStdin) throw new CliUsageError('--stdin may be specified only once') - readStdin = true - continue - } - if (argument === '--enabled-only') { - domainOptions.add('enabled-only') - if (enabledOnly) throw new CliUsageError('--enabled-only may be specified only once') - enabledOnly = true - continue } throw new CliUsageError(`Unknown option after ${domain} ${verb}: ${argument}`) } + const getString = (name: string): string | undefined => { + const value = domainValues.get(name) + return typeof value === 'string' ? value : undefined + } + const getNumber = (name: string): number | undefined => { + const value = domainValues.get(name) + return typeof value === 'number' ? value : undefined + } + const getBoolean = (name: string): boolean | undefined => { + const value = domainValues.get(name) + return typeof value === 'boolean' ? value : undefined + } + const artifactId = getString('id') + const outputPath = getString('out') + const overwrite = getBoolean('overwrite') ?? false + const providerId = getString('provider') + const modelId = getString('model') + const prompt = getString('prompt') + const textInput = getString('text') + const systemPrompt = getString('system') + const temperature = getNumber('temperature') + const maxTokens = getNumber('max-tokens') + const readStdin = getBoolean('stdin') ?? false + const enabledOnly = getBoolean('enabled-only') ?? false + const size = getString('size') + const quality = getString('quality') + const format = getString('format') + const compression = getNumber('compression') + const background = getString('background') + const moderation = getString('moderation') + const seconds = getString('seconds') + const ratio = getString('ratio') + const duration = getNumber('duration') + const resolution = getString('resolution') + const watermark = getBoolean('watermark') + const generateAudio = getBoolean('audio') + const voice = getString('voice') + const speed = getNumber('speed') + const instructions = getString('instructions') + const isArtifactCommand = domain === 'artifact' if (!helpRequested && isArtifactCommand && !artifactId) { throw new CliUsageError(`deepchat ${domain} ${verb} requires --id `) @@ -296,14 +378,12 @@ export function parseCliArguments( } const isModelInvoke = commandKey === 'model invoke' + const isImageGenerate = commandKey === 'image generate' + const isVideoGenerate = commandKey === 'video generate' + const isSpeechGenerate = commandKey === 'audio speak' + const isMediaGenerate = isImageGenerate || isVideoGenerate || isSpeechGenerate const isProviderList = commandKey === 'provider list' - const allowedDomainOptions = isArtifactCommand - ? new Set(['id', 'out', 'overwrite']) - : isModelInvoke - ? new Set(['provider', 'model', 'prompt', 'system', 'temperature', 'max-tokens', 'stdin']) - : isProviderList - ? new Set(['enabled-only']) - : new Set() + const allowedDomainOptions = COMMAND_DOMAIN_OPTIONS.get(commandKey) ?? new Set() const invalidDomainOption = Array.from(domainOptions).find( (option) => !allowedDomainOptions.has(option) ) @@ -316,6 +396,21 @@ export function parseCliArguments( if (!helpRequested && isModelInvoke && (prompt !== undefined) === readStdin) { throw new CliUsageError('deepchat model invoke requires exactly one of --prompt or --stdin') } + if (!helpRequested && isMediaGenerate && (!providerId || !modelId)) { + throw new CliUsageError(`deepchat ${domain} ${verb} requires --provider and --model`) + } + if ( + !helpRequested && + (isImageGenerate || isVideoGenerate) && + (prompt !== undefined) === readStdin + ) { + throw new CliUsageError( + `deepchat ${domain} ${verb} requires exactly one of --prompt or --stdin` + ) + } + if (!helpRequested && isSpeechGenerate && (textInput !== undefined) === readStdin) { + throw new CliUsageError('deepchat audio speak requires exactly one of --text or --stdin') + } let params: JsonValue = artifactId ? { id: artifactId } : {} if (isProviderList) params = { enabledOnly } @@ -331,6 +426,53 @@ export function parseCliArguments( ...(maxTokens !== undefined ? { maxTokens } : {}) } } + if (isImageGenerate && providerId && modelId) { + const options = { + ...(size !== undefined ? { size } : {}), + ...(quality !== undefined ? { quality } : {}), + ...(format !== undefined ? { outputFormat: format } : {}), + ...(compression !== undefined ? { outputCompression: compression } : {}), + ...(background !== undefined ? { background } : {}), + ...(moderation !== undefined ? { moderation } : {}) + } + params = { + providerId, + modelId, + ...(prompt !== undefined ? { prompt } : {}), + ...(Object.keys(options).length > 0 ? { options } : {}) + } + } + if (isVideoGenerate && providerId && modelId) { + const options = { + ...(seconds !== undefined ? { seconds } : {}), + ...(size !== undefined ? { size } : {}), + ...(ratio !== undefined ? { ratio } : {}), + ...(duration !== undefined ? { duration } : {}), + ...(resolution !== undefined ? { resolution } : {}), + ...(watermark !== undefined ? { watermark } : {}), + ...(generateAudio !== undefined ? { generateAudio } : {}) + } + params = { + providerId, + modelId, + ...(prompt !== undefined ? { prompt } : {}), + ...(Object.keys(options).length > 0 ? { options } : {}) + } + } + if (isSpeechGenerate && providerId && modelId) { + const options = { + ...(voice !== undefined ? { voice } : {}), + ...(format !== undefined ? { responseFormat: format } : {}), + ...(speed !== undefined ? { speed } : {}), + ...(instructions !== undefined ? { instructions } : {}) + } + params = { + providerId, + modelId, + ...(textInput !== undefined ? { text: textInput } : {}), + ...(Object.keys(options).length > 0 ? { options } : {}) + } + } return { domain, @@ -339,7 +481,12 @@ export function parseCliArguments( outputMode, timeoutMs, helpRequested: helpRequested || isHelpCommand, - operation: commandKey === 'artifact get' ? 'download' : isModelInvoke ? 'stream' : 'rpc', + operation: + commandKey === 'artifact get' + ? 'download' + : isModelInvoke || isMediaGenerate + ? 'stream' + : 'rpc', params, ...(outputPath ? { outputPath } : {}), overwrite, @@ -356,13 +503,53 @@ export function formatCliHelp(command?: Pick' : command.domain === 'model' ? ' --provider --model (--prompt |--stdin)' - : command.domain === 'provider' - ? ' [--enabled-only]' - : '' + : command.domain === 'image' || command.domain === 'video' + ? ' --provider --model (--prompt |--stdin)' + : command.domain === 'audio' + ? ' --provider --model (--text |--stdin)' + : command.domain === 'provider' + ? ' [--enabled-only]' + : '' + const commandKey = `${command.domain} ${command.verb}` + const optionLines = + commandKey === 'model invoke' + ? [ + ' --system Add a system message', + ' --temperature Set sampling temperature (0..2)', + ' --max-tokens Set the output-token limit' + ] + : commandKey === 'image generate' + ? [ + ' --size Set output dimensions', + ' --quality Set low, medium, high, or auto quality', + ' --format Set png, jpeg, or webp output', + ' --compression Set jpeg/webp compression (0..100)', + ' --background Set auto or opaque background', + ' --moderation Set auto or low moderation' + ] + : commandKey === 'video generate' + ? [ + ' --seconds Set provider-specific clip seconds', + ' --size Set provider-specific dimensions', + ' --ratio Set aspect ratio', + ' --duration Set duration (-1..3600)', + ' --resolution Set output resolution', + ' --watermark Enable or disable watermarking', + ' --audio Enable or disable generated audio' + ] + : commandKey === 'audio speak' + ? [ + ' --voice Select a voice', + ' --format Set mp3, opus, aac, flac, wav, or pcm', + ' --speed Set playback speed (0.25..4)', + ' --instructions Add provider-supported speech guidance' + ] + : [] return [ `Usage: deepchat ${command.domain} ${command.verb}${commandOptions} [--json|--jsonl] [--timeout ]`, '', - 'Global flags must follow the domain and verb.' + 'Global flags must follow the domain and verb.', + ...(optionLines.length > 0 ? ['', 'Command options:', ...optionLines] : []) ].join('\n') } @@ -378,6 +565,9 @@ export function formatCliHelp(command?: Pick Set request timeout', - ' --help Show command usage' + ' --help Show command usage and options', + '', + 'Run deepchat --help for command-specific options.' ].join('\n') } diff --git a/src/cli/format.ts b/src/cli/format.ts index bdeed4a1c..5939fa0d0 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -89,6 +89,24 @@ export function formatHumanResult( case 'models.invoke': { return contract.output.parse(value).text } + case 'images.generate': + case 'videos.generate': + case 'speech.generate': { + const result = contract.output.parse(value) + const noun = + contract.name === 'images.generate' + ? 'image' + : contract.name === 'videos.generate' + ? 'video' + : 'audio' + return [ + `Generated ${result.artifacts.length} ${noun} artifact${result.artifacts.length === 1 ? '' : 's'} in ${formatDuration(result.durationMs)}`, + ...result.artifacts.flatMap((artifact) => [ + `${artifact.id} ${artifact.mimeType} ${artifact.size} bytes ${artifact.filename}`, + ` Download: deepchat artifact get --id ${artifact.id} --out ${artifact.filename}` + ]) + ].join('\n') + } } } diff --git a/src/cli/run.ts b/src/cli/run.ts index deadb1ea7..9c9fe8842 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -6,6 +6,7 @@ import { type LocalControlRpcResponse } from '@shared/contracts/localControl' import { artifactsDescribeRoute } from '@shared/contracts/routes/artifacts.routes' +import { MediaGenerationEventSchema } from '@shared/contracts/routes/media.routes' import { ModelInvokeEventSchema } from '@shared/contracts/routes/models.routes' import { parseCliArguments, formatCliHelp, inferCliOutputMode, type CliOutputMode } from './args' import { @@ -79,6 +80,22 @@ function writeClientError( ) } +function validateStreamEvent(method: string, data: unknown): void { + const parsed = + method === 'models.invoke' + ? ModelInvokeEventSchema.safeParse(data) + : method === 'images.generate' || method === 'videos.generate' || method === 'speech.generate' + ? MediaGenerationEventSchema.safeParse(data) + : null + if (!parsed?.success) { + throw new CliClientError( + 'internal_error', + 'DeepChat emitted an invalid stream event', + CLI_EXIT_CODES.internal + ) + } +} + export async function runCli( argv: readonly string[], dependencies: CliRunDependencies = {} @@ -151,7 +168,7 @@ export async function runCli( try { let params = parsed.params if (parsed.readStdin) { - const prompt = await readBoundedUtf8Stdin(stdin, controller.signal) + const input = await readBoundedUtf8Stdin(stdin, controller.signal) if (!params || typeof params !== 'object' || Array.isArray(params)) { throw new CliClientError( 'internal_error', @@ -159,8 +176,26 @@ export async function runCli( CLI_EXIT_CODES.internal ) } - const messages = Array.isArray(params.messages) ? params.messages : [] - params = { ...params, messages: [...messages, { role: 'user', content: prompt }] } + switch (parsed.contract.name) { + case 'models.invoke': { + const messages = Array.isArray(params.messages) ? params.messages : [] + params = { ...params, messages: [...messages, { role: 'user', content: input }] } + break + } + case 'images.generate': + case 'videos.generate': + params = { ...params, prompt: input } + break + case 'speech.generate': + params = { ...params, text: input } + break + default: + throw new CliClientError( + 'internal_error', + 'CLI command does not accept standard input', + CLI_EXIT_CODES.internal + ) + } } const validatedInput = parsed.contract.input.safeParse(params) if (!validatedInput.success) { @@ -206,23 +241,17 @@ export async function runCli( CLI_EXIT_CODES.internal ) } + validateStreamEvent(parsed.contract.name, event.data) if (parsed.outputMode === 'jsonl') { writeText(stdout, JSON.stringify(event)) return } if (parsed.outputMode !== 'text' || parsed.contract.name !== 'models.invoke') return - const parsedEvent = ModelInvokeEventSchema.safeParse(event.data) - if (!parsedEvent.success) { - throw new CliClientError( - 'internal_error', - 'DeepChat emitted an invalid model event', - CLI_EXIT_CODES.internal - ) - } - if (parsedEvent.data.type === 'text_delta' && parsedEvent.data.text) { - stdout.write(parsedEvent.data.text) + const parsedEvent = ModelInvokeEventSchema.parse(event.data) + if (parsedEvent.type === 'text_delta' && parsedEvent.text) { + stdout.write(parsedEvent.text) streamedText = true - streamedTextEndsWithNewline = parsedEvent.data.text.endsWith('\n') + streamedTextEndsWithNewline = parsedEvent.text.endsWith('\n') } } const response = @@ -265,7 +294,11 @@ export async function runCli( signal: controller.signal }) } - if (parsed.operation === 'stream' && parsed.outputMode === 'text') { + if ( + parsed.operation === 'stream' && + parsed.contract.name === 'models.invoke' && + parsed.outputMode === 'text' + ) { if (!streamedText || !streamedTextEndsWithNewline) stdout.write('\n') } else if (parsed.outputMode === 'text') { writeText( diff --git a/src/main/app/composition.ts b/src/main/app/composition.ts index 0e41c6fdd..131566c96 100644 --- a/src/main/app/composition.ts +++ b/src/main/app/composition.ts @@ -379,10 +379,10 @@ export async function createMainProcessControl(dependencies: { signal.throwIfAborted() return output }, - dispatchStream: async (method, input, caller, signal, emit) => { + dispatchStream: async (method, input, caller, requestId, signal, emit) => { if (!cliComputeService) throw new Error('CLI compute service is not ready') assertRouteAllowedDuringDatabaseMaintenance(method) - return await cliComputeService.dispatchStream(method, input, caller, signal, emit) + return await cliComputeService.dispatchStream(method, input, caller, requestId, signal, emit) }, artifactSpool, log: logger @@ -638,6 +638,8 @@ export async function createMainProcessControl(dependencies: { cliComputeService = new CliComputeService({ providerSettings, providerRuntime, + artifactSpool, + mediaCacheDirectory: path.join(app.getPath('userData'), 'images'), log: logger }) const agentDefaults = new DeepChatDefaults({ diff --git a/src/main/cli/artifactSpool.ts b/src/main/cli/artifactSpool.ts index 037534aa5..098656737 100644 --- a/src/main/cli/artifactSpool.ts +++ b/src/main/cli/artifactSpool.ts @@ -64,6 +64,7 @@ export type ArtifactWriteInput = Readonly<{ suggestedFilename?: string data: Uint8Array | AsyncIterable ttlMs?: number + signal?: AbortSignal }> export type OpenArtifact = Readonly<{ @@ -98,7 +99,7 @@ function normalizeMimeType(value: string): string { return ArtifactMetadataSchema.shape.mimeType.parse(value.trim().toLowerCase()) } -function extensionForMimeType(mimeType: string): string { +export function artifactExtensionForMimeType(mimeType: string): string { switch (mimeType.split(';', 1)[0]) { case 'image/png': return '.png' @@ -108,6 +109,8 @@ function extensionForMimeType(mimeType: string): string { return '.webp' case 'image/gif': return '.gif' + case 'image/avif': + return '.avif' case 'video/mp4': return '.mp4' case 'video/webm': @@ -122,6 +125,8 @@ function extensionForMimeType(mimeType: string): string { return '.aac' case 'audio/flac': return '.flac' + case 'audio/pcm': + return '.pcm' default: return '.bin' } @@ -141,7 +146,7 @@ function normalizeFilename(value: string | undefined, mimeType: string): string } return normalized && normalized !== '.' && normalized !== '..' ? normalized - : `artifact${extensionForMimeType(mimeType)}` + : `artifact${artifactExtensionForMimeType(mimeType)}` } function positiveInteger(value: number, name: string): number { @@ -166,6 +171,12 @@ async function writeAll(handle: FileHandle, bytes: Uint8Array, position: number) return position + bytes.byteLength } +function throwIfArtifactWriteCancelled(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new CliRequestError('cancelled', 'Artifact output was cancelled') + } +} + async function* artifactChunks( data: Uint8Array | AsyncIterable ): AsyncGenerator { @@ -282,6 +293,7 @@ export class ArtifactSpool { } private async writeInternal(input: ArtifactWriteInput): Promise { + throwIfArtifactWriteCancelled(input.signal) await this.initialize() const owner = ownerForCaller(input.caller) const ownerQuotaKey = ownerKey(owner) @@ -312,6 +324,7 @@ export class ArtifactSpool { const hash = createHash('sha256') let position = 0 for await (const chunk of artifactChunks(input.data)) { + throwIfArtifactWriteCancelled(input.signal) if (chunk.byteLength === 0) continue if (chunk.byteLength > this.limits.maxArtifactBytes - size) { throw new CliRequestError( @@ -328,10 +341,12 @@ export class ArtifactSpool { size += chunk.byteLength hash.update(chunk) } + throwIfArtifactWriteCancelled(input.signal) if (size === 0) { throw new CliRequestError('invalid_request', 'Artifact output is empty') } await handle.sync() + throwIfArtifactWriteCancelled(input.signal) const createdAt = this.now() if (createdAt > Number.MAX_SAFE_INTEGER - ttlMs) { throw new Error('Artifact expiry is outside the supported range') @@ -350,11 +365,15 @@ export class ArtifactSpool { } finally { await handle.close() } + throwIfArtifactWriteCancelled(input.signal) if (process.platform !== 'win32') await chmod(tempPath, 0o600) + throwIfArtifactWriteCancelled(input.signal) await link(tempPath, finalPath) published = true + throwIfArtifactWriteCancelled(input.signal) await unlink(tempPath) tempPath = '' + throwIfArtifactWriteCancelled(input.signal) this.artifacts.set(id, { metadata, filePath: finalPath, @@ -446,6 +465,12 @@ export class ArtifactSpool { await this.removeStoredArtifact(artifact, 'reject') } + async discard(id: string): Promise { + await this.initialize() + const artifact = this.artifacts.get(id) + if (artifact) await this.removeStoredArtifact(artifact) + } + async cleanupExpired(): Promise { if (this.closing) return await this.initialize() @@ -615,7 +640,7 @@ export class ArtifactSpool { private async getAuthorizedArtifact(id: string, caller: CliRouteCaller): Promise { const artifact = this.artifacts.get(id) - if (!artifact || this.removalPromises.has(id)) { + if (!artifact || this.pendingRemovalIds.has(id) || this.removalPromises.has(id)) { throw new CliRequestError('not_found', 'Artifact was not found', { httpStatus: 404 }) } if (artifact.metadata.expiresAt <= this.now()) { @@ -632,7 +657,11 @@ export class ArtifactSpool { private acquireRead(artifact: StoredArtifact): () => void { const id = artifact.metadata.id - if (this.artifacts.get(id) !== artifact || this.removalPromises.has(id)) { + if ( + this.artifacts.get(id) !== artifact || + this.pendingRemovalIds.has(id) || + this.removalPromises.has(id) + ) { throw new CliRequestError('not_found', 'Artifact was not found', { httpStatus: 404 }) } this.activeReads.set(id, (this.activeReads.get(id) ?? 0) + 1) diff --git a/src/main/cli/computeService.ts b/src/main/cli/computeService.ts index ed4a24f28..8573eaa4c 100644 --- a/src/main/cli/computeService.ts +++ b/src/main/cli/computeService.ts @@ -1,17 +1,32 @@ import { MODEL_INVOKE_MAX_OUTPUT_CHARACTERS, + MediaGenerationEventSchema, ModelInvokeEventSchema, PublicProviderSchema, + imagesGenerateRoute, modelsInvokeRoute, providersListPublicRoute, + speechGenerateRoute, + videosGenerateRoute, + type ImageGenerationInput, + type ImageGenerationOutput, + type ArtifactMetadata, + type MediaGenerationEvent, type ModelInvokeEvent, type ModelInvokeInput, type ModelInvokeOutput, - type PublicProvider + type PublicProvider, + type SpeechGenerationInput, + type SpeechGenerationOutput, + type VideoGenerationInput, + type VideoGenerationOutput } from '@shared/contracts/routes' import type { JsonValue } from '@shared/contracts/json' -import { ModelType } from '@shared/model' +import { ApiEndpointType, ModelType } from '@shared/model' import type { LLMCoreStreamEvent, ProviderRoundStopReason } from '@shared/types/core/llm-events' +import type { ModelConfig } from '@shared/types/provider' +import { isVideoGenerationModelConfig } from '@shared/videoGenerationSettings' +import { isTtsModelConfig, isTtsModelId } from '@shared/ttsSettings' import type { ProviderSettingsPort } from '@/provider/settings' import type { ProviderRuntime } from '@/provider' import { @@ -21,6 +36,8 @@ import { type RouteCaller } from '@/routes/routeRegistry' import { CliRequestError } from './errors' +import { resolveGeneratedMedia, type GeneratedMediaKind } from './mediaOutput' +import { artifactExtensionForMimeType, type ArtifactSpool } from './artifactSpool' const MAX_STREAM_DELTA_CHARACTERS = 1024 * 1024 const MAX_MODEL_STREAM_EVENTS = 10_000 @@ -41,11 +58,20 @@ type ComputeProviderSettings = Pick< | 'getModelConfig' > -type ComputeProviderRuntime = Pick +type ComputeProviderRuntime = Pick< + ProviderRuntime, + | 'executeWithRateLimit' + | 'streamChat' + | 'generateImageStandalone' + | 'generateVideoStandalone' + | 'generateSpeechStandalone' +> export type CliComputeServiceOptions = Readonly<{ providerSettings: ComputeProviderSettings providerRuntime: ComputeProviderRuntime + artifactSpool?: ArtifactSpool + mediaCacheDirectory?: string now?: () => number log?: Pick }> @@ -159,16 +185,57 @@ export class CliComputeService { async dispatchStream( method: string, rawInput: unknown, - _caller: CliRouteCaller, + caller: CliRouteCaller, + requestId: string, signal: AbortSignal, emit: ComputeEmitter ): Promise { - if (method !== modelsInvokeRoute.name) { - throw new CliRequestError('not_found', 'Streaming method is not implemented', { - httpStatus: 404 - }) + switch (method) { + case modelsInvokeRoute.name: + return await this.invokeModel(modelsInvokeRoute.input.parse(rawInput), signal, emit) + case imagesGenerateRoute.name: + return await this.generateImages( + imagesGenerateRoute.input.parse(rawInput), + caller, + requestId, + signal, + emit + ) + case videosGenerateRoute.name: + return await this.generateVideos( + videosGenerateRoute.input.parse(rawInput), + caller, + requestId, + signal, + emit + ) + case speechGenerateRoute.name: + return await this.generateSpeech( + speechGenerateRoute.input.parse(rawInput), + caller, + requestId, + signal, + emit + ) + default: + throw new CliRequestError('not_found', 'Streaming method is not implemented', { + httpStatus: 404 + }) + } + } + + private requireAvailableModel(providerId: string, modelId: string): ModelConfig { + const provider = this.options.providerSettings.getProviderById(providerId) + if (!provider?.enable) { + throw new CliRequestError('not_found', 'Provider is not available', { httpStatus: 404 }) + } + if (!this.options.providerSettings.isKnownModel(providerId, modelId)) { + throw new CliRequestError('not_found', 'Model is not available', { httpStatus: 404 }) + } + if (!this.options.providerSettings.getModelStatus(providerId, modelId)) { + throw new CliRequestError('conflict', 'Model is disabled', { httpStatus: 409 }) } - return await this.invokeModel(modelsInvokeRoute.input.parse(rawInput), signal, emit) + return this.options.providerSettings.getModelConfig(modelId, providerId) } private async invokeModel( @@ -190,21 +257,7 @@ export class CliComputeService { } try { signal.throwIfAborted() - const provider = this.options.providerSettings.getProviderById(input.providerId) - if (!provider?.enable) { - throw new CliRequestError('not_found', 'Provider is not available', { httpStatus: 404 }) - } - if (!this.options.providerSettings.isKnownModel(input.providerId, input.modelId)) { - throw new CliRequestError('not_found', 'Model is not available', { httpStatus: 404 }) - } - if (!this.options.providerSettings.getModelStatus(input.providerId, input.modelId)) { - throw new CliRequestError('conflict', 'Model is disabled', { httpStatus: 409 }) - } - - const modelConfig = this.options.providerSettings.getModelConfig( - input.modelId, - input.providerId - ) + const modelConfig = this.requireAvailableModel(input.providerId, input.modelId) if (modelConfig.type !== ModelType.Chat) { throw new CliRequestError('conflict', 'Model is not configured for raw text invocation', { httpStatus: 409 @@ -356,6 +409,271 @@ export class CliComputeService { }) } } + + private async generateImages( + input: ImageGenerationInput, + caller: CliRouteCaller, + requestId: string, + signal: AbortSignal, + emit: ComputeEmitter + ): Promise { + const startedAt = this.now() + try { + const modelConfig = this.requireAvailableModel(input.providerId, input.modelId) + if ( + modelConfig.type !== ModelType.ImageGeneration && + modelConfig.apiEndpoint !== ApiEndpointType.Image && + modelConfig.endpointType !== 'image-generation' + ) { + throw new CliRequestError('conflict', 'Model is not configured for image generation', { + httpStatus: 409 + }) + } + await this.emitMediaStarted(imagesGenerateRoute.name, input, emit) + const result = await this.options.providerRuntime.generateImageStandalone( + input.providerId, + input.prompt, + input.modelId, + input.options, + { signal } + ) + this.requireMatchingMediaIdentity(result, input) + const artifacts = await this.persistMedia( + imagesGenerateRoute.name, + result.images, + 'image', + 8, + caller, + requestId, + signal, + emit + ) + return imagesGenerateRoute.output.parse({ + providerId: input.providerId, + modelId: input.modelId, + artifacts, + ...(input.options ? { requestedOptions: input.options } : {}), + durationMs: Math.max(0, this.now() - startedAt) + }) + } catch (error) { + throw this.normalizeMediaFailure(error, input, signal, 'Image generation failed') + } + } + + private async generateVideos( + input: VideoGenerationInput, + caller: CliRouteCaller, + requestId: string, + signal: AbortSignal, + emit: ComputeEmitter + ): Promise { + const startedAt = this.now() + try { + const modelConfig = this.requireAvailableModel(input.providerId, input.modelId) + if (!isVideoGenerationModelConfig(modelConfig, input.modelId)) { + throw new CliRequestError('conflict', 'Model is not configured for video generation', { + httpStatus: 409 + }) + } + await this.emitMediaStarted(videosGenerateRoute.name, input, emit) + const result = await this.options.providerRuntime.generateVideoStandalone( + input.providerId, + input.prompt, + input.modelId, + input.options, + { signal } + ) + this.requireMatchingMediaIdentity(result, input) + const artifacts = await this.persistMedia( + videosGenerateRoute.name, + result.videos, + 'video', + 4, + caller, + requestId, + signal, + emit + ) + return videosGenerateRoute.output.parse({ + providerId: input.providerId, + modelId: input.modelId, + artifacts, + ...(input.options ? { requestedOptions: input.options } : {}), + durationMs: Math.max(0, this.now() - startedAt) + }) + } catch (error) { + throw this.normalizeMediaFailure(error, input, signal, 'Video generation failed') + } + } + + private async generateSpeech( + input: SpeechGenerationInput, + caller: CliRouteCaller, + requestId: string, + signal: AbortSignal, + emit: ComputeEmitter + ): Promise { + const startedAt = this.now() + try { + const modelConfig = this.requireAvailableModel(input.providerId, input.modelId) + if (!isTtsModelConfig(modelConfig) && !isTtsModelId(input.modelId)) { + throw new CliRequestError('conflict', 'Model is not configured for speech generation', { + httpStatus: 409 + }) + } + await this.emitMediaStarted(speechGenerateRoute.name, input, emit) + const result = await this.options.providerRuntime.generateSpeechStandalone( + input.providerId, + input.text, + input.modelId, + input.options, + { signal } + ) + this.requireMatchingMediaIdentity(result, input) + const artifacts = await this.persistMedia( + speechGenerateRoute.name, + [result.audio], + 'audio', + 1, + caller, + requestId, + signal, + emit + ) + return speechGenerateRoute.output.parse({ + providerId: input.providerId, + modelId: input.modelId, + artifacts, + ...(input.options ? { requestedOptions: input.options } : {}), + durationMs: Math.max(0, this.now() - startedAt) + }) + } catch (error) { + throw this.normalizeMediaFailure(error, input, signal, 'Speech generation failed') + } + } + + private async emitMediaStarted( + method: string, + input: { providerId: string; modelId: string }, + emit: ComputeEmitter + ): Promise { + await emit( + method, + MediaGenerationEventSchema.parse({ + type: 'started', + providerId: input.providerId, + modelId: input.modelId + }) + ) + } + + private requireMatchingMediaIdentity( + result: { providerId: string; modelId: string }, + input: { providerId: string; modelId: string } + ): void { + if (result.providerId !== input.providerId || result.modelId !== input.modelId) { + throw new CliRequestError('unavailable', 'Provider returned inconsistent media output', { + httpStatus: 503, + retriable: true + }) + } + } + + private async persistMedia( + method: string, + outputs: readonly { data: string; mimeType: string }[], + kind: GeneratedMediaKind, + maxOutputs: number, + caller: CliRouteCaller, + requestId: string, + signal: AbortSignal, + emit: ComputeEmitter + ) { + const artifactSpool = this.options.artifactSpool + const mediaCacheDirectory = this.options.mediaCacheDirectory + if (!artifactSpool || !mediaCacheDirectory) { + throw new CliRequestError('unavailable', 'Media artifact service is unavailable', { + httpStatus: 503, + retriable: true + }) + } + if (outputs.length === 0) { + throw new CliRequestError('unavailable', `Provider returned no ${kind} output`, { + httpStatus: 503, + retriable: true + }) + } + if (outputs.length > maxOutputs) { + throw new CliRequestError('result_too_large', `Provider returned too many ${kind} outputs`, { + httpStatus: 413 + }) + } + + const artifacts: ArtifactMetadata[] = [] + try { + for (const [index, output] of outputs.entries()) { + signal.throwIfAborted() + const resolved = await resolveGeneratedMedia( + output.data, + output.mimeType, + kind, + mediaCacheDirectory + ) + try { + const artifact = await artifactSpool.write({ + caller, + requestId, + mimeType: resolved.mimeType, + suggestedFilename: `generated-${kind}-${index + 1}${artifactExtensionForMimeType(resolved.mimeType)}`, + data: resolved.data, + signal + }) + if (artifact.size !== resolved.expectedBytes) { + await artifactSpool.discard(artifact.id) + throw new CliRequestError('unavailable', 'Generated media changed while being stored', { + httpStatus: 503, + retriable: true + }) + } + artifacts.push(artifact) + } finally { + await resolved.dispose?.() + } + } + for (const [index, artifact] of artifacts.entries()) { + const event: MediaGenerationEvent = MediaGenerationEventSchema.parse({ + type: 'artifact', + index, + artifact + }) + await emit(method, event) + } + return artifacts + } catch (error) { + await Promise.allSettled(artifacts.map((artifact) => artifactSpool.discard(artifact.id))) + throw error + } + } + + private normalizeMediaFailure( + error: unknown, + input: { providerId: string; modelId: string }, + signal: AbortSignal, + message: string + ): CliRequestError { + if (error instanceof CliRequestError) return error + if (signal.aborted || (error instanceof Error && error.name === 'AbortError')) { + return new CliRequestError('cancelled', 'Media generation was cancelled', { + retriable: true + }) + } + this.log.warn('[CLI] Media generation failed', { + providerId: input.providerId, + modelId: input.modelId, + failure: { name: error instanceof Error ? error.name : typeof error } + }) + return new CliRequestError('unavailable', message, { httpStatus: 503, retriable: true }) + } } export function createCliComputeRoutes(service: CliComputeService): DeepchatRouteMap { diff --git a/src/main/cli/mediaOutput.ts b/src/main/cli/mediaOutput.ts new file mode 100644 index 000000000..7ec602b29 --- /dev/null +++ b/src/main/cli/mediaOutput.ts @@ -0,0 +1,233 @@ +import { lstat, open } from 'node:fs/promises' +import path from 'node:path' +import { ArtifactMetadataSchema } from '@shared/contracts/routes/artifacts.routes' +import { CliRequestError } from './errors' + +const MAX_GENERATED_MEDIA_BYTES = 512 * 1024 * 1024 +const MAX_ENCODED_MEDIA_CHARACTERS = Math.ceil(MAX_GENERATED_MEDIA_BYTES / 3) * 4 +const BASE64_CHUNK_CHARACTERS = 1024 * 1024 +const MAX_DATA_URL_HEADER_CHARACTERS = 512 +const BASE64_ALPHABET_PATTERN = /^[A-Za-z0-9+/]+$/ +const CACHE_FILENAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$/ +const WINDOWS_DEVICE_NAME_PATTERN = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i + +export type GeneratedMediaKind = 'image' | 'video' | 'audio' + +export type ResolvedGeneratedMedia = Readonly<{ + mimeType: string + data: AsyncIterable + expectedBytes: number + dispose?: () => Promise +}> + +function normalizeMimeType(value: string, kind: GeneratedMediaKind): string { + const mimeType = ArtifactMetadataSchema.shape.mimeType.parse(value.trim().toLowerCase()) + if (!mimeType.startsWith(`${kind}/`)) { + throw new CliRequestError('unavailable', `Provider returned invalid ${kind} output`, { + httpStatus: 503, + retriable: true + }) + } + return mimeType +} + +function decodeUriPath(value: string): string { + try { + return decodeURIComponent(value) + } catch { + throw new CliRequestError('unavailable', 'Provider returned an invalid cached media path', { + httpStatus: 503 + }) + } +} + +function decodedBase64Length(value: string): number { + const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0 + return (value.length / 4) * 3 - padding +} + +function malformedMediaData(): CliRequestError { + return new CliRequestError('unavailable', 'Provider returned malformed media data', { + httpStatus: 503 + }) +} + +async function* decodeBase64(value: string): AsyncGenerator { + for (let offset = 0; offset < value.length; offset += BASE64_CHUNK_CHARACTERS) { + const end = Math.min(value.length, offset + BASE64_CHUNK_CHARACTERS) + const chunk = value.slice(offset, end) + const isFinalChunk = end === value.length + const padding = isFinalChunk ? (chunk.endsWith('==') ? 2 : chunk.endsWith('=') ? 1 : 0) : 0 + const alphabet = padding > 0 ? chunk.slice(0, -padding) : chunk + if (!alphabet || !BASE64_ALPHABET_PATTERN.test(alphabet)) throw malformedMediaData() + yield Buffer.from(chunk, 'base64') + } +} + +function resolveBase64( + rawValue: string, + claimedMimeType: string, + kind: GeneratedMediaKind +): ResolvedGeneratedMedia { + let encoded = rawValue.trim() + let mimeType = claimedMimeType + if (encoded.startsWith('data:')) { + const header = encoded.slice(0, MAX_DATA_URL_HEADER_CHARACTERS) + const commaIndex = header.indexOf(',') + const descriptor = commaIndex > 5 ? header.slice(5, commaIndex) : '' + const base64Suffix = ';base64' + if (!descriptor.toLowerCase().endsWith(base64Suffix)) throw malformedMediaData() + const dataMimeType = normalizeMimeType(descriptor.slice(0, -base64Suffix.length), kind) + const claimed = normalizeMimeType(claimedMimeType, kind) + if (dataMimeType.split(';', 1)[0] !== claimed.split(';', 1)[0]) { + throw new CliRequestError('unavailable', 'Provider returned conflicting media types', { + httpStatus: 503 + }) + } + mimeType = dataMimeType + encoded = encoded.slice(commaIndex + 1) + } + + if (encoded.length > MAX_ENCODED_MEDIA_CHARACTERS) { + throw new CliRequestError('result_too_large', 'Generated media exceeds the byte limit', { + httpStatus: 413 + }) + } + if (!encoded || encoded.length % 4 !== 0) throw malformedMediaData() + if (decodedBase64Length(encoded) > MAX_GENERATED_MEDIA_BYTES) { + throw new CliRequestError('result_too_large', 'Generated media exceeds the byte limit', { + httpStatus: 413 + }) + } + return { + mimeType: normalizeMimeType(mimeType, kind), + data: decodeBase64(encoded), + expectedBytes: decodedBase64Length(encoded) + } +} + +async function resolveCachedImage( + value: string, + claimedMimeType: string, + cacheDirectory: string +): Promise { + const decodedPath = decodeUriPath(value.slice('imgcache://'.length)) + const resolvedCacheDirectory = path.resolve(cacheDirectory) + const cacheDirectoryStat = await lstat(resolvedCacheDirectory).catch(() => null) + if (!cacheDirectoryStat?.isDirectory() || cacheDirectoryStat.isSymbolicLink()) { + throw new CliRequestError('unavailable', 'Generated media cache is unavailable', { + httpStatus: 503, + retriable: true + }) + } + if (typeof process.getuid === 'function' && cacheDirectoryStat.uid !== process.getuid()) { + throw new CliRequestError('unavailable', 'Generated media cache has an invalid owner', { + httpStatus: 503 + }) + } + if (!CACHE_FILENAME_PATTERN.test(decodedPath) || WINDOWS_DEVICE_NAME_PATTERN.test(decodedPath)) { + throw new CliRequestError('unavailable', 'Provider returned an invalid cached media path', { + httpStatus: 503 + }) + } + const filePath = path.resolve(resolvedCacheDirectory, decodedPath) + const relativePath = path.relative(resolvedCacheDirectory, filePath) + if ( + !relativePath || + relativePath.startsWith('..') || + path.isAbsolute(relativePath) || + decodedPath === '.' || + decodedPath === '..' + ) { + throw new CliRequestError('unavailable', 'Provider returned an invalid cached media path', { + httpStatus: 503 + }) + } + + const before = await lstat(filePath).catch(() => null) + if ( + !before?.isFile() || + before.isSymbolicLink() || + before.size <= 0 || + before.size > MAX_GENERATED_MEDIA_BYTES + ) { + throw new CliRequestError('unavailable', 'Generated media cache entry is unavailable', { + httpStatus: 503, + retriable: true + }) + } + const handle = await open(filePath, 'r').catch(() => null) + if (!handle) { + throw new CliRequestError('unavailable', 'Generated media cache entry is unavailable', { + httpStatus: 503, + retriable: true + }) + } + const opened = await handle.stat().catch(() => null) + if ( + !opened?.isFile() || + opened.size !== before.size || + opened.dev !== before.dev || + opened.ino !== before.ino + ) { + await handle.close().catch(() => undefined) + throw new CliRequestError('unavailable', 'Generated media cache entry changed before use', { + httpStatus: 503, + retriable: true + }) + } + try { + const stream = handle.createReadStream({ autoClose: false, end: opened.size - 1 }) + return { + mimeType: normalizeMimeType(claimedMimeType, 'image'), + data: stream, + expectedBytes: opened.size, + dispose: async () => { + if (!stream.destroyed) stream.destroy() + await handle.close().catch(() => undefined) + } + } + } catch (error) { + await handle.close().catch(() => undefined) + throw error + } +} + +export async function resolveGeneratedMedia( + value: string, + claimedMimeType: string, + kind: GeneratedMediaKind, + cacheDirectory: string +): Promise { + if (value.length > MAX_ENCODED_MEDIA_CHARACTERS + 1_024) { + throw new CliRequestError('result_too_large', 'Generated media exceeds the byte limit', { + httpStatus: 413 + }) + } + const normalized = value.trim() + if (!normalized) { + throw new CliRequestError('unavailable', `Provider returned empty ${kind} output`, { + httpStatus: 503, + retriable: true + }) + } + if (normalized.startsWith('imgcache://')) { + if (kind !== 'image') { + throw new CliRequestError( + 'unavailable', + 'Provider returned an invalid media cache reference', + { + httpStatus: 503 + } + ) + } + return await resolveCachedImage(normalized, claimedMimeType, cacheDirectory) + } + if (/^https?:\/\//i.test(normalized)) { + throw new CliRequestError('unavailable', 'Remote generated-media URLs are not accepted', { + httpStatus: 503, + retriable: true + }) + } + return resolveBase64(normalized, claimedMimeType, kind) +} diff --git a/src/main/cli/server.ts b/src/main/cli/server.ts index e3efe9d57..dcbbc3074 100644 --- a/src/main/cli/server.ts +++ b/src/main/cli/server.ts @@ -75,6 +75,7 @@ export type CliServerDependencies = Readonly<{ method: string, input: unknown, caller: CliRouteCaller, + requestId: string, signal: AbortSignal, emit: CliStreamEmitter ): Promise @@ -680,7 +681,7 @@ export class CliServer { try { const rawOutput = await runAbortable(signal, async () => - dispatchStream(entry.contract.name, input, caller, signal, emit) + dispatchStream(entry.contract.name, input, caller, requestId, signal, emit) ) if (signal.aborted) throw requestAbortError(signal) const result = this.parseRouteOutput(entry, rawOutput, entry.contract.name) diff --git a/src/main/cli/surface.ts b/src/main/cli/surface.ts index 432e53a15..69a9ded74 100644 --- a/src/main/cli/surface.ts +++ b/src/main/cli/surface.ts @@ -7,8 +7,11 @@ import { cliDoctorRoute, cliStatusRoute, cliVersionRoute, + imagesGenerateRoute, modelsInvokeRoute, providersListPublicRoute, + speechGenerateRoute, + videosGenerateRoute, type CliCapability } from '@shared/contracts/routes' import { @@ -51,6 +54,19 @@ const diagnosticEntry = (contract: RouteContract): CliSurfaceEntry => ({ limits: DIAGNOSTIC_LIMITS }) +const mediaEntry = (contract: RouteContract): CliSurfaceEntry => ({ + contract, + effect: 'compute', + callers: ['human', 'agent'], + scopes: ['media:generate'], + transport: 'stream', + approval: 'never', + limits: { + maxBodyBytes: 512 * 1024, + timeoutMs: LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS + } +}) + const CLI_SURFACE_V1_ENTRIES = [ { contract: modelsInvokeRoute, @@ -61,6 +77,9 @@ const CLI_SURFACE_V1_ENTRIES = [ approval: 'never', limits: { maxBodyBytes: 5 * 1024 * 1024, timeoutMs: LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS } }, + mediaEntry(imagesGenerateRoute), + mediaEntry(videosGenerateRoute), + mediaEntry(speechGenerateRoute), { contract: providersListPublicRoute, effect: 'read', diff --git a/src/shared/contracts/routes.ts b/src/shared/contracts/routes.ts index 235b57e2c..5eda1743d 100644 --- a/src/shared/contracts/routes.ts +++ b/src/shared/contracts/routes.ts @@ -85,6 +85,11 @@ import { cliStatusRoute, cliVersionRoute } from './routes/cli.routes' +import { + imagesGenerateRoute, + speechGenerateRoute, + videosGenerateRoute +} from './routes/media.routes' import { configAddCustomPromptRoute, configAddManualAcpAgentRoute, @@ -581,6 +586,7 @@ export * from './routes/knowledge.routes' export * from './routes/cli.routes' export * from './routes/mcp.routes' export * from './routes/memory.routes' +export * from './routes/media.routes' export * from './routes/models.routes' export * from './routes/notification.routes' export * from './routes/nowledgeMem.routes' @@ -932,6 +938,9 @@ const DEEPCHAT_ROUTE_CATALOG_PART_4 = { [providersImportApplyRoute.name]: providersImportApplyRoute, [modelsGetProviderCatalogRoute.name]: modelsGetProviderCatalogRoute, [modelsInvokeRoute.name]: modelsInvokeRoute, + [imagesGenerateRoute.name]: imagesGenerateRoute, + [videosGenerateRoute.name]: videosGenerateRoute, + [speechGenerateRoute.name]: speechGenerateRoute, [modelsListRuntimeRoute.name]: modelsListRuntimeRoute, [modelsSetBatchStatusRoute.name]: modelsSetBatchStatusRoute, [modelsSetStatusRoute.name]: modelsSetStatusRoute, diff --git a/src/shared/contracts/routes/media.routes.ts b/src/shared/contracts/routes/media.routes.ts new file mode 100644 index 000000000..e84b657d5 --- /dev/null +++ b/src/shared/contracts/routes/media.routes.ts @@ -0,0 +1,142 @@ +import { z } from 'zod' +import { + IMAGE_GENERATION_MODERATION_VALUES, + IMAGE_GENERATION_OUTPUT_FORMAT_VALUES, + IMAGE_GENERATION_QUALITY_VALUES, + OPENAI_IMAGE_GENERATION_BACKGROUND_VALUES +} from '../../imageGenerationSettings' +import { TTS_RESPONSE_FORMAT_VALUES } from '../../ttsSettings' +import { ArtifactMetadataSchema } from './artifacts.routes' +import { EntityIdSchema, defineRouteContract } from '../common' + +const MediaProviderIdSchema = EntityIdSchema.max(128) +const MediaModelIdSchema = z.string().min(1).max(256) +const MediaPromptSchema = z + .string() + .min(1) + .max(64 * 1024) + .refine((value) => value.trim().length > 0, { message: 'Media input must not be blank' }) + +export const PublicImageGenerationOptionsSchema = z + .object({ + size: z.string().min(1).max(32).optional(), + quality: z.enum(IMAGE_GENERATION_QUALITY_VALUES).optional(), + outputFormat: z.enum(IMAGE_GENERATION_OUTPUT_FORMAT_VALUES).optional(), + outputCompression: z.number().int().min(0).max(100).optional(), + background: z.enum(OPENAI_IMAGE_GENERATION_BACKGROUND_VALUES).optional(), + moderation: z.enum(IMAGE_GENERATION_MODERATION_VALUES).optional() + }) + .strict() + +export const PublicVideoGenerationOptionsSchema = z + .object({ + seconds: z.string().min(1).max(32).optional(), + size: z.string().min(1).max(32).optional(), + ratio: z.string().min(1).max(32).optional(), + duration: z.number().int().min(-1).max(3_600).optional(), + resolution: z.string().min(1).max(32).optional(), + watermark: z.boolean().optional(), + generateAudio: z.boolean().optional() + }) + .strict() + +export const PublicSpeechGenerationOptionsSchema = z + .object({ + voice: z.string().min(1).max(128).optional(), + responseFormat: z.enum(TTS_RESPONSE_FORMAT_VALUES).optional(), + speed: z.number().min(0.25).max(4).optional(), + instructions: z + .string() + .min(1) + .max(16 * 1024) + .optional() + }) + .strict() + +export const MediaGenerationEventSchema = z.discriminatedUnion('type', [ + z + .object({ + type: z.literal('started'), + providerId: MediaProviderIdSchema, + modelId: MediaModelIdSchema + }) + .strict(), + z + .object({ + type: z.literal('artifact'), + index: z.number().int().nonnegative(), + artifact: ArtifactMetadataSchema + }) + .strict() +]) + +export const imagesGenerateRoute = defineRouteContract({ + name: 'images.generate', + input: z + .object({ + providerId: MediaProviderIdSchema, + modelId: MediaModelIdSchema, + prompt: MediaPromptSchema, + options: PublicImageGenerationOptionsSchema.optional() + }) + .strict(), + output: z + .object({ + providerId: MediaProviderIdSchema, + modelId: MediaModelIdSchema, + artifacts: z.array(ArtifactMetadataSchema).min(1).max(8), + requestedOptions: PublicImageGenerationOptionsSchema.optional(), + durationMs: z.number().int().nonnegative() + }) + .strict() +}) + +export const videosGenerateRoute = defineRouteContract({ + name: 'videos.generate', + input: z + .object({ + providerId: MediaProviderIdSchema, + modelId: MediaModelIdSchema, + prompt: MediaPromptSchema, + options: PublicVideoGenerationOptionsSchema.optional() + }) + .strict(), + output: z + .object({ + providerId: MediaProviderIdSchema, + modelId: MediaModelIdSchema, + artifacts: z.array(ArtifactMetadataSchema).min(1).max(4), + requestedOptions: PublicVideoGenerationOptionsSchema.optional(), + durationMs: z.number().int().nonnegative() + }) + .strict() +}) + +export const speechGenerateRoute = defineRouteContract({ + name: 'speech.generate', + input: z + .object({ + providerId: MediaProviderIdSchema, + modelId: MediaModelIdSchema, + text: MediaPromptSchema, + options: PublicSpeechGenerationOptionsSchema.optional() + }) + .strict(), + output: z + .object({ + providerId: MediaProviderIdSchema, + modelId: MediaModelIdSchema, + artifacts: z.array(ArtifactMetadataSchema).length(1), + requestedOptions: PublicSpeechGenerationOptionsSchema.optional(), + durationMs: z.number().int().nonnegative() + }) + .strict() +}) + +export type MediaGenerationEvent = z.infer +export type ImageGenerationInput = z.infer +export type ImageGenerationOutput = z.infer +export type VideoGenerationInput = z.infer +export type VideoGenerationOutput = z.infer +export type SpeechGenerationInput = z.infer +export type SpeechGenerationOutput = z.infer diff --git a/test/main/cli/args.test.ts b/test/main/cli/args.test.ts index 918f80856..5ff22d30d 100644 --- a/test/main/cli/args.test.ts +++ b/test/main/cli/args.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { CLI_OUTPUT_ENV, CLI_TIMEOUT_ENV, parseCliArguments } from '../../../src/cli/args' +import { + CLI_OUTPUT_ENV, + CLI_TIMEOUT_ENV, + formatCliHelp, + parseCliArguments +} from '../../../src/cli/args' describe('CLI argument grammar', () => { it('maps the two-token command prefix to a canonical route', () => { @@ -148,4 +153,182 @@ describe('CLI argument grammar', () => { params: { enabledOnly: true } }) }) + + it('maps image and video options without exposing file output paths', () => { + expect( + parseCliArguments( + [ + 'image', + 'generate', + '--provider', + 'provider-1', + '--model', + 'image-1', + '--prompt', + 'a lighthouse', + '--size', + '1024x1024', + '--quality', + 'high', + '--format', + 'webp', + '--compression', + '80', + '--background', + 'opaque', + '--moderation', + 'auto' + ], + {} + ) + ).toMatchObject({ + operation: 'stream', + timeoutMs: 1_800_000, + params: { + providerId: 'provider-1', + modelId: 'image-1', + prompt: 'a lighthouse', + options: { + size: '1024x1024', + quality: 'high', + outputFormat: 'webp', + outputCompression: 80, + background: 'opaque', + moderation: 'auto' + } + } + }) + + expect( + parseCliArguments( + [ + 'video', + 'generate', + '--provider=provider-1', + '--model=video-1', + '--stdin', + '--seconds=8', + '--ratio=16:9', + '--duration', + '-1', + '--resolution=1080p', + '--watermark=false', + '--audio=true' + ], + {} + ) + ).toMatchObject({ + operation: 'stream', + readStdin: true, + params: { + providerId: 'provider-1', + modelId: 'video-1', + options: { + seconds: '8', + ratio: '16:9', + duration: -1, + resolution: '1080p', + watermark: false, + generateAudio: true + } + } + }) + + expect(() => + parseCliArguments( + [ + 'image', + 'generate', + '--provider', + 'provider-1', + '--model', + 'image-1', + '--prompt', + 'hello', + '--out', + './image.png' + ], + {} + ) + ).toThrow('Artifact options are not valid') + }) + + it('maps speech input and rejects ambiguous or cross-domain media flags', () => { + expect( + parseCliArguments( + [ + 'audio', + 'speak', + '--provider', + 'provider-1', + '--model', + 'tts-1', + '--text', + 'hello', + '--voice', + 'alloy', + '--format', + 'wav', + '--speed', + '1.25', + '--instructions', + 'Speak softly' + ], + {} + ) + ).toMatchObject({ + operation: 'stream', + params: { + providerId: 'provider-1', + modelId: 'tts-1', + text: 'hello', + options: { + voice: 'alloy', + responseFormat: 'wav', + speed: 1.25, + instructions: 'Speak softly' + } + } + }) + + expect(() => + parseCliArguments( + [ + 'audio', + 'speak', + '--provider', + 'provider-1', + '--model', + 'tts-1', + '--text', + 'hello', + '--stdin' + ], + {} + ) + ).toThrow('exactly one of --text or --stdin') + expect(() => + parseCliArguments( + [ + 'video', + 'generate', + '--provider', + 'provider-1', + '--model', + 'video-1', + '--prompt', + 'hello', + '--voice', + 'alloy' + ], + {} + ) + ).toThrow('--voice is not valid for deepchat video generate') + }) + + it('keeps media-specific options discoverable from command help', () => { + expect(formatCliHelp({ domain: 'image', verb: 'generate' })).toContain('--compression ') + expect(formatCliHelp({ domain: 'video', verb: 'generate' })).toContain('--watermark ') + expect(formatCliHelp({ domain: 'audio', verb: 'speak' })).toContain('--voice ') + }) }) diff --git a/test/main/cli/artifactSpool.test.ts b/test/main/cli/artifactSpool.test.ts index a778d3ce8..a6dea75fa 100644 --- a/test/main/cli/artifactSpool.test.ts +++ b/test/main/cli/artifactSpool.test.ts @@ -97,6 +97,44 @@ describe('ArtifactSpool', () => { expect(await collect(opened.stream)).toEqual(Buffer.from('generated-video')) }) + it('removes partial output and releases quota when a write is cancelled', async () => { + const { spool, directory } = await createSpool({ + limits: { + maxArtifactBytes: 8, + maxRequestBytes: 8, + maxConnectionBytes: 8, + maxOwnerBytes: 8, + maxTotalBytes: 8 + } + }) + const controller = new AbortController() + async function* chunks(): AsyncGenerator { + yield Buffer.from('part') + controller.abort() + yield Buffer.from('more') + } + + await expect( + spool.write({ + caller: humanCaller, + requestId: 'request-cancelled', + mimeType: 'application/octet-stream', + data: chunks(), + signal: controller.signal + }) + ).rejects.toMatchObject({ code: 'cancelled' }) + expect(await readdir(directory)).toEqual([]) + + await expect( + spool.write({ + caller: humanCaller, + requestId: 'request-after-cancel', + mimeType: 'application/octet-stream', + data: Buffer.alloc(8) + }) + ).resolves.toMatchObject({ size: 8 }) + }) + it('isolates Agent artifacts by conversation while allowing human recovery', async () => { const { spool } = await createSpool() const owner = agentCaller('conversation-a') @@ -231,6 +269,24 @@ describe('ArtifactSpool', () => { }) }) + it('defers internal discard until active reads finish and blocks new readers', async () => { + const { spool, directory } = await createSpool() + const metadata = await spool.write({ + caller: humanCaller, + requestId: 'request-discard', + mimeType: 'application/octet-stream', + data: Buffer.alloc(64 * 1024, 1) + }) + const opened = await spool.openRead(metadata.id, humanCaller) + + await expect(spool.discard(metadata.id)).resolves.toBeUndefined() + await expect(spool.openRead(metadata.id, humanCaller)).rejects.toMatchObject({ + code: 'not_found' + }) + expect(await collect(opened.stream)).toHaveLength(metadata.size) + await expect.poll(async () => readdir(directory)).toEqual([]) + }) + it('cleans only spool-owned crash remnants during initialization', async () => { const { spool, directory } = await createSpool() await writeFile(path.join(path.dirname(directory), 'keep.txt'), 'outside') diff --git a/test/main/cli/client.test.ts b/test/main/cli/client.test.ts index ef86100d0..0f5591f58 100644 --- a/test/main/cli/client.test.ts +++ b/test/main/cli/client.test.ts @@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events' import { mkdtemp, rm } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' +import { Readable } from 'node:stream' import { afterEach, describe, expect, it, vi } from 'vitest' import type { DeepchatRouteName } from '@shared/contracts/routes' import type { JsonValue } from '@shared/contracts/json' @@ -40,6 +41,7 @@ async function createClientServer( userDataPath: string server: CliServer dispatch: ReturnType + dispatchStream: ReturnType }> { const userDataPath = await mkdtemp(path.join(os.tmpdir(), 'deepchat-cli-client-')) temporaryDirectories.push(userDataPath) @@ -57,35 +59,40 @@ async function createClientServer( return await route(input, { caller }) } ) + const dispatchStream = vi.fn( + async ( + method: string, + _input: unknown, + _caller: CliRouteCaller, + _requestId: string, + _signal: AbortSignal, + emit: (event: string, data: JsonValue) => Promise + ) => { + if (options.hangStream) return await new Promise(() => undefined) + for (const event of options.stream?.events ?? []) await emit(method, event) + return options.stream?.result + } + ) server = new CliServer({ userDataPath, appVersion: '9.8.7', dispatch, ...(options.stream || options.hangStream ? { - dispatchStream: async ( - method: string, - _input: unknown, - _caller: CliRouteCaller, - _signal: AbortSignal, - emit: (event: string, data: JsonValue) => Promise - ) => { - if (options.hangStream) return await new Promise(() => undefined) - for (const event of options.stream?.events ?? []) await emit(method, event) - return options.stream?.result - } + dispatchStream } : {}), log: { warn: vi.fn(), error: vi.fn() } }) servers.push(server) await server.start() - return { userDataPath, server, dispatch } + return { userDataPath, server, dispatch, dispatchStream } } function runWithCapturedOutput( argv: readonly string[], - env: NodeJS.ProcessEnv + env: NodeJS.ProcessEnv, + stdin?: NodeJS.ReadableStream ): { result: Promise stdout: ReturnType @@ -100,6 +107,7 @@ function runWithCapturedOutput( env, stdout: stdout.stream, stderr: stderr.stream, + ...(stdin ? { stdin } : {}), signalHost: signalHost as unknown as NodeJS.Process, randomId: () => 'request-1', forceExit: vi.fn() @@ -310,4 +318,85 @@ describe('bundled CLI client', () => { ) await vi.waitFor(() => expect(server.getStatus().pendingRequests).toBe(0)) }) + + it('maps media stdin and renders artifact retrieval instructions', async () => { + const artifact = { + id: 'artifact_identifier_123', + requestId: 'request-1', + owner: 'human', + mimeType: 'image/png', + size: 5, + sha256: 'a'.repeat(64), + filename: 'generated-image-1.png', + createdAt: 1_000, + expiresAt: 2_000 + } as const + const stream = { + events: [ + { type: 'started', providerId: 'provider-1', modelId: 'image-1' }, + { type: 'artifact', index: 0, artifact } + ], + result: { + providerId: 'provider-1', + modelId: 'image-1', + artifacts: [artifact], + durationMs: 25 + } + } as const + const { userDataPath, dispatchStream } = await createClientServer({ stream }) + const invocation = runWithCapturedOutput( + ['image', 'generate', '--provider', 'provider-1', '--model', 'image-1', '--stdin'], + { DEEPCHAT_E2E_USER_DATA_DIR: userDataPath }, + Readable.from(['a lighthouse']) + ) + + await expect(invocation.result).resolves.toBe(0) + expect(invocation.stdout.read()).toContain('Generated 1 image artifact in 25ms') + expect(invocation.stdout.read()).toContain( + 'deepchat artifact get --id artifact_identifier_123 --out generated-image-1.png' + ) + expect(dispatchStream).toHaveBeenCalledWith( + 'images.generate', + { + providerId: 'provider-1', + modelId: 'image-1', + prompt: 'a lighthouse' + }, + expect.objectContaining({ principal: 'human' }), + 'request-1', + expect.any(AbortSignal), + expect.any(Function) + ) + }) + + it('rejects invalid media events before emitting JSONL records', async () => { + const stream = { + events: [{ type: 'artifact', index: -1, artifact: {} }], + result: {} + } as const + const { userDataPath } = await createClientServer({ stream }) + const invocation = runWithCapturedOutput( + [ + 'audio', + 'speak', + '--provider', + 'provider-1', + '--model', + 'tts-1', + '--text', + 'hello', + '--jsonl' + ], + { DEEPCHAT_E2E_USER_DATA_DIR: userDataPath } + ) + + await expect(invocation.result).resolves.toBe(8) + const records = invocation.stdout + .read() + .trimEnd() + .split('\n') + .map((line) => LocalControlRpcResponseSchema.parse(JSON.parse(line))) + expect(records).toHaveLength(1) + expect(records[0]).toMatchObject({ ok: false, error: { code: 'internal_error' } }) + }) }) diff --git a/test/main/cli/computeService.test.ts b/test/main/cli/computeService.test.ts index 30ad85ef5..ccdf92523 100644 --- a/test/main/cli/computeService.test.ts +++ b/test/main/cli/computeService.test.ts @@ -1,11 +1,25 @@ -import { describe, expect, it, vi } from 'vitest' -import { modelsInvokeRoute, type ModelInvokeEvent } from '@shared/contracts/routes' +import { mkdtemp, readdir, rm } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + imagesGenerateRoute, + modelsInvokeRoute, + speechGenerateRoute, + videosGenerateRoute, + type MediaGenerationEvent, + type ModelInvokeEvent +} from '@shared/contracts/routes' import { ModelType } from '@shared/model' import type { LLMCoreStreamEvent } from '@shared/types/core/llm-events' import type { MODEL_META, ModelConfig } from '@shared/types/provider' import { CliComputeService, type CliComputeServiceOptions } from '@/cli/computeService' +import { ArtifactSpool } from '@/cli/artifactSpool' import type { CliRouteCaller } from '@/routes/routeRegistry' +const mediaSpools: ArtifactSpool[] = [] +const temporaryDirectories: string[] = [] + const provider = { id: 'provider-1', name: 'Provider One', @@ -38,9 +52,16 @@ const caller: CliRouteCaller = { kind: 'cli', principal: 'human', connectionId: 'connection-1', - scopes: ['models:invoke'] + scopes: ['models:invoke', 'media:generate'] } +afterEach(async () => { + await Promise.allSettled(mediaSpools.splice(0).map((spool) => spool.close())) + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })) + ) +}) + async function* streamEvents( events: readonly LLMCoreStreamEvent[] ): AsyncGenerator { @@ -71,6 +92,73 @@ function createService(events: readonly LLMCoreStreamEvent[]) { } } +async function createMediaService( + type: ModelType, + overrides: Partial = {} +) { + const root = await mkdtemp(path.join(os.tmpdir(), 'deepchat-cli-compute-')) + temporaryDirectories.push(root) + const artifactDirectory = path.join(root, 'artifacts') + const mediaCacheDirectory = path.join(root, 'images') + const artifactSpool = new ArtifactSpool({ directory: artifactDirectory }) + mediaSpools.push(artifactSpool) + const mediaModel = { ...model, id: `${type}-model`, type } + const mediaConfig = { ...modelConfig, type } + const providerSettings: CliComputeServiceOptions['providerSettings'] = { + getProviders: vi.fn(() => [provider]), + getProviderById: vi.fn(() => provider), + getProviderModels: vi.fn(() => [mediaModel]), + getCustomModels: vi.fn(() => []), + getBatchModelStatus: vi.fn(() => ({ [mediaModel.id]: true })), + getModelStatus: vi.fn(() => true), + isKnownModel: vi.fn(() => true), + getModelConfig: vi.fn(() => mediaConfig) + } + const providerRuntime: CliComputeServiceOptions['providerRuntime'] = { + executeWithRateLimit: vi.fn(async () => undefined), + streamChat: vi.fn(() => streamEvents([])), + generateImageStandalone: vi.fn(async (providerId, _prompt, modelId) => ({ + providerId, + modelId, + images: [{ data: 'aW1hZ2U=', mimeType: 'image/png' }] + })), + generateVideoStandalone: vi.fn(async (providerId, _prompt, modelId) => ({ + providerId, + modelId, + videos: [{ data: 'dmlkZW8=', mimeType: 'video/mp4' }] + })), + generateSpeechStandalone: vi.fn(async (providerId, _text, modelId) => ({ + providerId, + modelId, + audio: { data: 'YXVkaW8=', mimeType: 'audio/mpeg' } + })), + ...overrides + } + const log = { warn: vi.fn() } + return { + service: new CliComputeService({ + providerSettings, + providerRuntime, + artifactSpool, + mediaCacheDirectory, + log, + now: () => 100 + }), + artifactSpool, + artifactDirectory, + mediaModel, + providerRuntime, + log + } +} + +async function collectArtifact(spool: ArtifactSpool, id: string): Promise { + const opened = await spool.openRead(id, caller) + const chunks: Buffer[] = [] + for await (const chunk of opened.stream) chunks.push(Buffer.from(chunk)) + return Buffer.concat(chunks) +} + describe('CLI compute service', () => { it('returns an explicitly redacted provider and model view', () => { const { service } = createService([]) @@ -127,6 +215,7 @@ describe('CLI compute service', () => { messages: [{ role: 'user', content: 'hello' }] }, caller, + 'request-1', signal, async (event, data) => { expect(event).toBe(modelsInvokeRoute.name) @@ -168,6 +257,7 @@ describe('CLI compute service', () => { messages: [{ role: 'user', content: 'hello' }] }, caller, + 'request-1', new AbortController().signal, async () => undefined ) @@ -194,6 +284,7 @@ describe('CLI compute service', () => { messages: [{ role: 'user', content: 'hello' }] }, caller, + 'request-1', new AbortController().signal, async () => undefined ) @@ -202,4 +293,222 @@ describe('CLI compute service', () => { message: 'Raw model invocation returned an unsupported event' }) }) + + it('persists generated images and emits artifacts only after publication', async () => { + const { service, artifactSpool, mediaModel, providerRuntime } = await createMediaService( + ModelType.ImageGeneration + ) + const emitted: MediaGenerationEvent[] = [] + const signal = new AbortController().signal + + const result = await service.dispatchStream( + imagesGenerateRoute.name, + { + providerId: provider.id, + modelId: mediaModel.id, + prompt: 'a lighthouse', + options: { quality: 'high', outputFormat: 'png' } + }, + caller, + 'request-image', + signal, + async (event, data) => { + expect(event).toBe(imagesGenerateRoute.name) + emitted.push(data as MediaGenerationEvent) + } + ) + + expect(result).toMatchObject({ + providerId: provider.id, + modelId: mediaModel.id, + requestedOptions: { quality: 'high', outputFormat: 'png' }, + artifacts: [{ owner: 'human', mimeType: 'image/png', filename: 'generated-image-1.png' }] + }) + expect(emitted.map((event) => event.type)).toEqual(['started', 'artifact']) + const artifact = imagesGenerateRoute.output.parse(result).artifacts[0] + expect(await collectArtifact(artifactSpool, artifact.id)).toEqual(Buffer.from('image')) + expect(providerRuntime.generateImageStandalone).toHaveBeenCalledWith( + provider.id, + 'a lighthouse', + mediaModel.id, + { quality: 'high', outputFormat: 'png' }, + { signal } + ) + }) + + it('persists video and speech results with typed media artifacts', async () => { + const video = await createMediaService(ModelType.VideoGeneration) + const videoResult = await video.service.dispatchStream( + videosGenerateRoute.name, + { + providerId: provider.id, + modelId: video.mediaModel.id, + prompt: 'ocean waves', + options: { duration: 8, generateAudio: true } + }, + caller, + 'request-video', + new AbortController().signal, + async () => undefined + ) + const videoArtifact = videosGenerateRoute.output.parse(videoResult).artifacts[0] + expect(videoArtifact).toMatchObject({ + mimeType: 'video/mp4', + filename: 'generated-video-1.mp4' + }) + expect(await collectArtifact(video.artifactSpool, videoArtifact.id)).toEqual( + Buffer.from('video') + ) + + const speech = await createMediaService(ModelType.TTS) + const speechResult = await speech.service.dispatchStream( + speechGenerateRoute.name, + { + providerId: provider.id, + modelId: speech.mediaModel.id, + text: 'hello', + options: { voice: 'alloy', responseFormat: 'mp3' } + }, + caller, + 'request-speech', + new AbortController().signal, + async () => undefined + ) + const speechArtifact = speechGenerateRoute.output.parse(speechResult).artifacts[0] + expect(speechArtifact).toMatchObject({ + mimeType: 'audio/mpeg', + filename: 'generated-audio-1.mp3' + }) + expect(await collectArtifact(speech.artifactSpool, speechArtifact.id)).toEqual( + Buffer.from('audio') + ) + }) + + it('rejects mismatched provider identity before publishing media', async () => { + const media = await createMediaService(ModelType.ImageGeneration, { + generateImageStandalone: vi.fn(async (_providerId, _prompt, modelId) => ({ + providerId: 'different-provider', + modelId, + images: [{ data: 'aW1hZ2U=', mimeType: 'image/png' }] + })) + }) + + await expect( + media.service.dispatchStream( + imagesGenerateRoute.name, + { + providerId: provider.id, + modelId: media.mediaModel.id, + prompt: 'hello' + }, + caller, + 'request-mismatch', + new AbortController().signal, + async () => undefined + ) + ).rejects.toMatchObject({ + code: 'unavailable', + message: 'Provider returned inconsistent media output' + }) + }) + + it('rejects media requests whose model is configured for another capability', async () => { + const media = await createMediaService(ModelType.TTS) + + await expect( + media.service.dispatchStream( + imagesGenerateRoute.name, + { + providerId: provider.id, + modelId: media.mediaModel.id, + prompt: 'hello' + }, + caller, + 'request-wrong-model', + new AbortController().signal, + async () => undefined + ) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(media.providerRuntime.generateImageStandalone).not.toHaveBeenCalled() + }) + + it('removes earlier artifacts when a later provider output is invalid', async () => { + const media = await createMediaService(ModelType.ImageGeneration, { + generateImageStandalone: vi.fn(async (providerId, _prompt, modelId) => ({ + providerId, + modelId, + images: [ + { data: 'Zmlyc3Q=', mimeType: 'image/png' }, + { data: 'invalid base64', mimeType: 'image/png' } + ] + })) + }) + + await expect( + media.service.dispatchStream( + imagesGenerateRoute.name, + { + providerId: provider.id, + modelId: media.mediaModel.id, + prompt: 'hello' + }, + caller, + 'request-partial', + new AbortController().signal, + async () => undefined + ) + ).rejects.toMatchObject({ code: 'unavailable' }) + expect(await readdir(media.artifactDirectory)).toEqual([]) + }) + + it('discards published artifacts when stream delivery fails', async () => { + const media = await createMediaService(ModelType.ImageGeneration) + + await expect( + media.service.dispatchStream( + imagesGenerateRoute.name, + { + providerId: provider.id, + modelId: media.mediaModel.id, + prompt: 'hello' + }, + caller, + 'request-delivery-failure', + new AbortController().signal, + async (_event, data) => { + if ((data as MediaGenerationEvent).type === 'artifact') { + throw new Error('stream disconnected') + } + } + ) + ).rejects.toMatchObject({ code: 'unavailable' }) + expect(await readdir(media.artifactDirectory)).toEqual([]) + }) + + it('normalizes media failures without logging provider response text', async () => { + const media = await createMediaService(ModelType.TTS, { + generateSpeechStandalone: vi.fn(async () => { + throw new Error('secret upstream media response') + }) + }) + + await expect( + media.service.dispatchStream( + speechGenerateRoute.name, + { + providerId: provider.id, + modelId: media.mediaModel.id, + text: 'hello' + }, + caller, + 'request-error', + new AbortController().signal, + async () => undefined + ) + ).rejects.toMatchObject({ code: 'unavailable', message: 'Speech generation failed' }) + expect(media.log.warn).toHaveBeenCalledOnce() + expect(JSON.stringify(media.log.warn.mock.calls)).not.toContain( + 'secret upstream media response' + ) + }) }) diff --git a/test/main/cli/mediaOutput.test.ts b/test/main/cli/mediaOutput.test.ts new file mode 100644 index 000000000..50ef33b92 --- /dev/null +++ b/test/main/cli/mediaOutput.test.ts @@ -0,0 +1,106 @@ +import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { resolveGeneratedMedia } from '@/cli/mediaOutput' + +const temporaryDirectories: string[] = [] + +async function createCacheDirectory(): Promise { + const directory = await mkdtemp(path.join(os.tmpdir(), 'deepchat-cli-media-')) + temporaryDirectories.push(directory) + return directory +} + +async function collect(data: AsyncIterable): Promise { + const chunks: Buffer[] = [] + for await (const chunk of data) chunks.push(Buffer.from(chunk)) + return Buffer.concat(chunks) +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })) + ) +}) + +describe('generated media resolver', () => { + it('strictly decodes raw base64 and matching data URLs', async () => { + const directory = await createCacheDirectory() + const raw = await resolveGeneratedMedia('aGVsbG8=', 'image/png', 'image', directory) + const dataUrl = await resolveGeneratedMedia( + 'data:audio/ogg; codecs=opus;base64,aGVsbG8=', + 'audio/ogg; codecs=opus', + 'audio', + directory + ) + + expect(await collect(raw.data)).toEqual(Buffer.from('hello')) + expect(raw.mimeType).toBe('image/png') + expect(await collect(dataUrl.data)).toEqual(Buffer.from('hello')) + expect(dataUrl.mimeType).toBe('audio/ogg; codecs=opus') + }) + + it('rejects malformed, conflicting, cross-kind, and remote outputs', async () => { + const directory = await createCacheDirectory() + + await expect( + resolveGeneratedMedia('not base64', 'image/png', 'image', directory) + ).rejects.toMatchObject({ code: 'unavailable' }) + await expect( + resolveGeneratedMedia('data:image/jpeg;base64,aGVsbG8=', 'image/png', 'image', directory) + ).rejects.toMatchObject({ code: 'unavailable' }) + await expect( + resolveGeneratedMedia('aGVsbG8=', 'video/mp4', 'image', directory) + ).rejects.toMatchObject({ code: 'unavailable' }) + await expect( + resolveGeneratedMedia('https://private.example/output.png', 'image/png', 'image', directory) + ).rejects.toMatchObject({ code: 'unavailable' }) + + const invalidAlphabet = await resolveGeneratedMedia('abcd!===', 'image/png', 'image', directory) + await expect(collect(invalidAlphabet.data)).rejects.toMatchObject({ code: 'unavailable' }) + }) + + it('streams only regular files contained by the image cache', async () => { + const directory = await createCacheDirectory() + await writeFile(path.join(directory, 'generated.png'), 'cached-image') + const resolved = await resolveGeneratedMedia( + 'imgcache://generated.png', + 'image/png', + 'image', + directory + ) + + expect(await collect(resolved.data)).toEqual(Buffer.from('cached-image')) + await resolved.dispose?.() + await resolved.dispose?.() + + await expect( + resolveGeneratedMedia('imgcache://%2e%2e%2foutside.png', 'image/png', 'image', directory) + ).rejects.toMatchObject({ code: 'unavailable' }) + await expect( + resolveGeneratedMedia('imgcache://CON.png', 'image/png', 'image', directory) + ).rejects.toMatchObject({ code: 'unavailable' }) + await expect( + resolveGeneratedMedia('imgcache://generated.png', 'image/png', 'video', directory) + ).rejects.toMatchObject({ code: 'unavailable' }) + }) + + it('rejects symbolic links in the image cache', async () => { + const root = await createCacheDirectory() + const directory = path.join(root, 'images') + await writeFile(path.join(root, 'target.png'), 'cached-image') + await symlink(root, directory) + + await expect( + resolveGeneratedMedia('imgcache://target.png', 'image/png', 'image', directory) + ).rejects.toMatchObject({ code: 'unavailable' }) + + await rm(directory) + await writeFile(path.join(root, 'linked-target.png'), 'cached-image') + await symlink(path.join(root, 'linked-target.png'), path.join(root, 'linked.png')) + await expect( + resolveGeneratedMedia('imgcache://linked.png', 'image/png', 'image', root) + ).rejects.toMatchObject({ code: 'unavailable' }) + }) +}) diff --git a/test/main/cli/server.test.ts b/test/main/cli/server.test.ts index 197b02662..7f308a7b5 100644 --- a/test/main/cli/server.test.ts +++ b/test/main/cli/server.test.ts @@ -140,6 +140,7 @@ async function createTestServer( method: string, _input: unknown, _caller: CliRouteCaller, + _requestId: string, _signal: AbortSignal, emit: (event: string, data: JsonValue) => Promise ) => { diff --git a/test/main/cli/surface.test.ts b/test/main/cli/surface.test.ts index 353060a71..e6d273b9b 100644 --- a/test/main/cli/surface.test.ts +++ b/test/main/cli/surface.test.ts @@ -14,8 +14,11 @@ describe('CLI surface V1', () => { 'cli.doctor', 'cli.status', 'cli.version', + 'images.generate', 'models.invoke', - 'providers.listPublic' + 'providers.listPublic', + 'speech.generate', + 'videos.generate' ]) for (const [method, entry] of CLI_SURFACE_V1) { expect(entry.contract).toBe( @@ -39,12 +42,27 @@ describe('CLI surface V1', () => { expect.objectContaining({ method: 'cli.doctor', effect: 'read' }), expect.objectContaining({ method: 'cli.status', effect: 'read' }), expect.objectContaining({ method: 'cli.version', effect: 'read' }), + expect.objectContaining({ + method: 'images.generate', + effect: 'compute', + transport: 'stream' + }), expect.objectContaining({ method: 'models.invoke', effect: 'compute', transport: 'stream' }), - expect.objectContaining({ method: 'providers.listPublic', effect: 'read' }) + expect.objectContaining({ method: 'providers.listPublic', effect: 'read' }), + expect.objectContaining({ + method: 'speech.generate', + effect: 'compute', + transport: 'stream' + }), + expect.objectContaining({ + method: 'videos.generate', + effect: 'compute', + transport: 'stream' + }) ]) expect( listCliSurfaceCapabilities().every((capability) => capability.approval === 'never') From 50e6dcb42bfa5b68d3489e42fb74153a84b50108 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 12:48:20 +0800 Subject: [PATCH 10/51] feat(cli): add bounded upload inputs --- docs/architecture/local-control-plane/spec.md | 15 +- src/cli/transport.ts | 287 +++++++++++++----- src/main/cli/artifactSpool.ts | 135 +++++--- src/main/cli/body.ts | 34 ++- src/main/cli/descriptor.ts | 20 +- src/main/cli/server.ts | 162 ++++++++-- src/shared/contracts/localControl.ts | 3 + test/main/cli/artifactSpool.test.ts | 25 ++ test/main/cli/body.test.ts | 12 +- test/main/cli/descriptor.test.ts | 17 +- test/main/cli/server.test.ts | 183 ++++++++++- test/main/cli/transport.test.ts | 109 ++++++- 12 files changed, 823 insertions(+), 179 deletions(-) diff --git a/docs/architecture/local-control-plane/spec.md b/docs/architecture/local-control-plane/spec.md index a3b541d4f..42b5f9831 100644 --- a/docs/architecture/local-control-plane/spec.md +++ b/docs/architecture/local-control-plane/spec.md @@ -180,7 +180,8 @@ this protocol. - `POST /v1/rpc`: bounded JSON request and JSON response for unary methods. - `POST /v1/stream`: bounded JSON request and `application/x-ndjson` response for streamed methods. -- `POST /v1/upload`: strict bounded multipart request for methods with byte input. +- `POST /v1/upload`: a strict typed-envelope header plus a bounded binary body for methods with byte + input. - `GET /v1/artifacts/:id`: ownership-checked binary output download. - `GET /v1/events`: ownership-checked NDJSON event subscription with request/run filters. @@ -190,10 +191,14 @@ object. HTTP status communicates transport/authentication failure; CLI exit code domain outcome. Proxy environment variables are ignored for local transport. `Content-Length` is rejected when missing for fixed JSON bodies, invalid, conflicting, or above the -route limit. Uploads and chunked streams enforce a cumulative byte limit while reading. Bodies spill -to a private `0700` directory above a route-specific memory threshold. Abort, parse error, timeout, -limit failure, and shutdown all remove partial files. The public protocol does not expose those -temporary paths. +route limit. Upload metadata is the normal RPC envelope encoded as canonical base64url in a singular, +4 KiB-bounded `X-DeepChat-Upload-Request` header. This lets main authenticate and validate version, +surface, caller, scopes, and typed metadata before accepting the large body. The body is raw +`application/octet-stream`; uploads with or without `Content-Length` enforce a cumulative route byte +limit while reading. Bodies spill to a private `0700` directory above a route-specific memory +threshold. Upload bytes always stream into a private temporary file, so there is no multipart +extraction pass or base64 expansion. Abort, parse error, timeout, limit failure, and shutdown all +remove partial files. The public protocol does not expose those temporary paths. ## Contract Ownership and Surface diff --git a/src/cli/transport.ts b/src/cli/transport.ts index 22f6ed95d..df27f8208 100644 --- a/src/cli/transport.ts +++ b/src/cli/transport.ts @@ -1,9 +1,14 @@ -import { request as httpRequest, type IncomingHttpHeaders } from 'node:http' +import { constants as fsConstants } from 'node:fs' +import { lstat, open } from 'node:fs/promises' +import { request as httpRequest, type IncomingHttpHeaders, type IncomingMessage } from 'node:http' import { LOCAL_CONTROL_RPC_PATH, LOCAL_CONTROL_STREAM_PATH, + LOCAL_CONTROL_UPLOAD_PATH, + LOCAL_CONTROL_UPLOAD_REQUEST_HEADER, LOCAL_CONTROL_MAX_JSON_RESPONSE_BYTES, LOCAL_CONTROL_MAX_STREAM_RECORD_BYTES, + LOCAL_CONTROL_MAX_UPLOAD_REQUEST_HEADER_BYTES, LocalControlEventEnvelopeSchema, LocalControlRpcRequestSchema, LocalControlRpcResponseSchema, @@ -27,6 +32,12 @@ export type CliRpcInvocation = Readonly<{ signal: AbortSignal }> +export type CliUploadInvocation = CliRpcInvocation & + Readonly<{ + filePath: string + maxBytes: number + }> + export type CliStreamEventHandler = (event: LocalControlEventEnvelope) => void | Promise function transportFailure(message: string, retriable = true): CliClientError { @@ -77,6 +88,71 @@ function createInvocationBody(invocation: CliRpcInvocation): Buffer { ) } +async function readJsonResponse( + response: IncomingMessage, + expectedRequestId: string, + signal: AbortSignal +): Promise { + try { + const contentTypes = response.headersDistinct['content-type'] + const contentType = contentTypes?.[0] ?? response.headers['content-type'] + const [mediaType, ...parameters] = + typeof contentType === 'string' + ? contentType.split(';').map((part) => part.trim().toLowerCase()) + : [] + if ( + (contentTypes && contentTypes.length !== 1) || + mediaType !== 'application/json' || + !parameters.every((parameter) => parameter === 'charset=utf-8') + ) { + throw protocolFailure('DeepChat returned a non-JSON response') + } + if (response.headers['content-encoding'] !== undefined) { + throw protocolFailure('Compressed local responses are not supported') + } + + const expectedLength = declaredResponseLength( + response.headers, + response.headersDistinct['content-length'] + ) + const chunks: Buffer[] = [] + let size = 0 + for await (const rawChunk of response) { + const chunk = Buffer.from(rawChunk) + size += chunk.length + if (size > LOCAL_CONTROL_MAX_JSON_RESPONSE_BYTES) { + throw protocolFailure('DeepChat response exceeds the CLI byte limit') + } + chunks.push(chunk) + } + if (expectedLength !== null && expectedLength !== size) { + throw protocolFailure('DeepChat response length did not match') + } + + let parsed: LocalControlRpcResponse + try { + parsed = LocalControlRpcResponseSchema.parse( + JSON.parse(Buffer.concat(chunks, size).toString('utf8')) + ) + } catch { + throw protocolFailure('DeepChat returned an invalid response envelope') + } + const isHttpSuccess = (response.statusCode ?? 0) >= 200 && (response.statusCode ?? 0) < 300 + if (parsed.ok !== isHttpSuccess) { + throw protocolFailure('DeepChat HTTP status and response envelope disagree') + } + if (parsed.id !== expectedRequestId && !(!parsed.ok && parsed.id === 'unknown')) { + throw protocolFailure('DeepChat response ID did not match the request') + } + return parsed + } catch (error) { + if (!response.destroyed) response.destroy() + if (signal.aborted) throw abortReason(signal) + if (error instanceof CliClientError) throw error + throw transportFailure(error instanceof Error ? error.message : 'Local response failed') + } +} + export async function invokeLocalControlRpc( invocation: CliRpcInvocation ): Promise { @@ -109,82 +185,10 @@ export async function invokeLocalControlRpc( }) request.once('response', (response) => { - const contentTypes = response.headersDistinct['content-type'] - const contentType = contentTypes?.[0] ?? response.headers['content-type'] - const [mediaType, ...parameters] = - typeof contentType === 'string' - ? contentType.split(';').map((part) => part.trim().toLowerCase()) - : [] - if ( - (contentTypes && contentTypes.length !== 1) || - mediaType !== 'application/json' || - !parameters.every((parameter) => parameter === 'charset=utf-8') - ) { - response.resume() - finish(() => reject(protocolFailure('DeepChat returned a non-JSON response'))) - return - } - if (response.headers['content-encoding'] !== undefined) { - response.resume() - finish(() => reject(protocolFailure('Compressed local responses are not supported'))) - return - } - - let expectedLength: number | null - try { - expectedLength = declaredResponseLength( - response.headers, - response.headersDistinct['content-length'] - ) - } catch (error) { - response.destroy() - finish(() => reject(error)) - return - } - - const chunks: Buffer[] = [] - let size = 0 - response.on('data', (rawChunk: Buffer | string) => { - if (settled) return - const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk) - size += chunk.length - if (size > LOCAL_CONTROL_MAX_JSON_RESPONSE_BYTES) { - response.destroy() - finish(() => reject(protocolFailure('DeepChat response exceeds the CLI byte limit'))) - return - } - chunks.push(chunk) - }) - response.once('error', (error) => finish(() => reject(transportFailure(error.message)))) - response.once('end', () => { - if (settled) return - if (expectedLength !== null && expectedLength !== size) { - finish(() => reject(protocolFailure('DeepChat response length did not match'))) - return - } - try { - const parsed = LocalControlRpcResponseSchema.parse( - JSON.parse(Buffer.concat(chunks, size).toString('utf8')) - ) - const isHttpSuccess = - (response.statusCode ?? 0) >= 200 && (response.statusCode ?? 0) < 300 - if (parsed.ok !== isHttpSuccess) { - throw protocolFailure('DeepChat HTTP status and response envelope disagree') - } - if (parsed.id !== invocation.id && !(!parsed.ok && parsed.id === 'unknown')) { - throw protocolFailure('DeepChat response ID did not match the request') - } - finish(() => resolve(parsed)) - } catch (error) { - finish(() => - reject( - error instanceof CliClientError - ? error - : protocolFailure('DeepChat returned an invalid response envelope') - ) - ) - } - }) + void readJsonResponse(response, invocation.id, invocation.signal).then( + (result) => finish(() => resolve(result)), + (error: unknown) => finish(() => reject(error)) + ) }) request.once('error', (error: NodeJS.ErrnoException) => { if (invocation.signal.aborted) { @@ -201,6 +205,135 @@ export async function invokeLocalControlRpc( }) } +function uploadFileError(message: string, code: 'invalid_request' | 'body_too_large') { + return new CliClientError( + code, + message, + code === 'invalid_request' ? CLI_EXIT_CODES.usage : CLI_EXIT_CODES.domain + ) +} + +export async function invokeLocalControlUpload( + invocation: CliUploadInvocation +): Promise { + if (invocation.signal.aborted) throw abortReason(invocation.signal) + if (!Number.isSafeInteger(invocation.maxBytes) || invocation.maxBytes <= 0) { + throw protocolFailure('CLI upload limit is invalid') + } + + const envelope = createInvocationBody(invocation).toString('base64url') + if (Buffer.byteLength(envelope, 'ascii') > LOCAL_CONTROL_MAX_UPLOAD_REQUEST_HEADER_BYTES) { + throw uploadFileError('Upload metadata exceeds the CLI byte limit', 'invalid_request') + } + + let pathStat + try { + pathStat = await lstat(invocation.filePath) + } catch { + throw uploadFileError('Upload source is unavailable', 'invalid_request') + } + if (!pathStat.isFile() || pathStat.isSymbolicLink()) { + throw uploadFileError('Upload source must be a regular non-symlink file', 'invalid_request') + } + if (pathStat.size <= 0) { + throw uploadFileError('Upload source is empty', 'invalid_request') + } + if (!Number.isSafeInteger(pathStat.size) || pathStat.size > invocation.maxBytes) { + throw uploadFileError('Upload source exceeds the command byte limit', 'body_too_large') + } + + const openFlags = + process.platform === 'win32' + ? fsConstants.O_RDONLY + : fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW + let handle + try { + handle = await open(invocation.filePath, openFlags) + } catch { + throw uploadFileError('Upload source could not be opened safely', 'invalid_request') + } + + try { + const openedStat = await handle.stat() + if ( + !openedStat.isFile() || + openedStat.size !== pathStat.size || + openedStat.dev !== pathStat.dev || + openedStat.ino !== pathStat.ino + ) { + throw uploadFileError('Upload source changed before it could be read', 'invalid_request') + } + + const uploadStream = handle.createReadStream({ + autoClose: false, + start: 0, + end: openedStat.size - 1, + signal: invocation.signal + }) + try { + return await new Promise((resolve, reject) => { + let settled = false + let responseReceived = false + const finish = (callback: () => void) => { + if (settled) return + settled = true + callback() + } + const request = httpRequest({ + socketPath: + invocation.descriptor.endpoint.kind === 'unix' + ? invocation.descriptor.endpoint.path + : invocation.descriptor.endpoint.name, + path: LOCAL_CONTROL_UPLOAD_PATH, + method: 'POST', + agent: false, + signal: invocation.signal, + headers: { + authorization: `Bearer ${invocation.token}`, + 'content-type': 'application/octet-stream', + 'content-length': openedStat.size, + [LOCAL_CONTROL_UPLOAD_REQUEST_HEADER]: envelope, + connection: 'close', + 'user-agent': `DeepChat-CLI/${CLI_VERSION}` + } + }) + + request.once('response', (response) => { + responseReceived = true + void readJsonResponse(response, invocation.id, invocation.signal).then( + (result) => finish(() => resolve(result)), + (error: unknown) => finish(() => reject(error)) + ) + }) + request.once('error', (error: NodeJS.ErrnoException) => { + if (responseReceived) return + if (invocation.signal.aborted) { + finish(() => reject(abortReason(invocation.signal))) + return + } + const message = + error.code === 'ENOENT' || error.code === 'ECONNREFUSED' || error.code === 'EPIPE' + ? 'DeepChat local control server is unavailable' + : `Cannot connect to DeepChat: ${error.message}` + finish(() => reject(transportFailure(message))) + }) + uploadStream.once('error', (error) => { + if (responseReceived) return + request.destroy(error) + if (!invocation.signal.aborted) { + finish(() => reject(transportFailure('Upload source could not be read'))) + } + }) + uploadStream.pipe(request) + }) + } finally { + uploadStream.destroy() + } + } finally { + await handle.close().catch(() => undefined) + } +} + export async function invokeLocalControlStream( invocation: CliRpcInvocation, onEvent: CliStreamEventHandler diff --git a/src/main/cli/artifactSpool.ts b/src/main/cli/artifactSpool.ts index 098656737..a3da88ba4 100644 --- a/src/main/cli/artifactSpool.ts +++ b/src/main/cli/artifactSpool.ts @@ -72,6 +72,17 @@ export type OpenArtifact = Readonly<{ stream: ReadStream }> +export type ArtifactInputFile = Readonly<{ + metadata: ArtifactMetadata + path: string +}> + +type OpenArtifactHandle = Readonly<{ + artifact: StoredArtifact + handle: FileHandle + release: () => void +}> + export type ArtifactSpoolOptions = Readonly<{ directory: string limits?: Partial @@ -399,66 +410,45 @@ export class ArtifactSpool { } async openRead(id: string, caller: CliRouteCaller): Promise { - await this.initialize() - const artifact = await this.getAuthorizedArtifact(id, caller) - const releaseRead = this.acquireRead(artifact) + const opened = await this.openAuthorizedHandle(id, caller) try { - const fileStat = await lstat(artifact.filePath).catch(() => null) - if ( - !fileStat?.isFile() || - fileStat.isSymbolicLink() || - fileStat.size !== artifact.metadata.size - ) { - await this.removeStoredArtifact(artifact).catch((error) => { - this.log.warn('[CLI] Failed to remove invalid artifact', error) - }) - throw new CliRequestError('unavailable', 'Artifact data is unavailable', { - httpStatus: 410 - }) - } - const handle = await open(artifact.filePath, 'r').catch(async () => { - await this.removeStoredArtifact(artifact).catch((error) => { - this.log.warn('[CLI] Failed to remove unavailable artifact', error) - }) - throw new CliRequestError('unavailable', 'Artifact data is unavailable', { - httpStatus: 410 - }) - }) - const openedStat = await handle.stat().catch(() => null) - if ( - !openedStat?.isFile() || - openedStat.size !== artifact.metadata.size || - openedStat.dev !== fileStat.dev || - openedStat.ino !== fileStat.ino - ) { - await handle.close().catch(() => undefined) - await this.removeStoredArtifact(artifact).catch((error) => { - this.log.warn('[CLI] Failed to remove changed artifact', error) - }) - throw new CliRequestError('unavailable', 'Artifact data changed before it could be read', { - httpStatus: 410 - }) - } let stream: ReadStream try { - stream = handle.createReadStream({ autoClose: true }) + stream = opened.handle.createReadStream({ autoClose: true }) } catch (error) { - await handle.close().catch(() => undefined) + await opened.handle.close().catch(() => undefined) throw error } this.openReadStreams.add(stream) stream.once('close', () => { this.openReadStreams.delete(stream) - releaseRead() + opened.release() }) if (this.closing) stream.destroy() - return { metadata: artifact.metadata, stream } + return { metadata: opened.artifact.metadata, stream } } catch (error) { - releaseRead() + opened.release() throw error } } + async withFile( + id: string, + caller: CliRouteCaller, + operation: (file: ArtifactInputFile) => Promise + ): Promise { + const opened = await this.openAuthorizedHandle(id, caller) + try { + return await operation({ + metadata: opened.artifact.metadata, + path: opened.artifact.filePath + }) + } finally { + await opened.handle.close().catch(() => undefined) + opened.release() + } + } + async delete(id: string, caller: CliRouteCaller): Promise { await this.initialize() const artifact = await this.getAuthorizedArtifact(id, caller) @@ -655,6 +645,61 @@ export class ArtifactSpool { return artifact } + private async openAuthorizedHandle( + id: string, + caller: CliRouteCaller + ): Promise { + await this.initialize() + const artifact = await this.getAuthorizedArtifact(id, caller) + const release = this.acquireRead(artifact) + try { + const fileStat = await lstat(artifact.filePath).catch(() => null) + if ( + !fileStat?.isFile() || + fileStat.isSymbolicLink() || + fileStat.size !== artifact.metadata.size + ) { + await this.removeStoredArtifact(artifact).catch((error) => { + this.log.warn('[CLI] Failed to remove invalid artifact', error) + }) + throw new CliRequestError('unavailable', 'Artifact data is unavailable', { + httpStatus: 410 + }) + } + const handle = await open(artifact.filePath, 'r').catch(async () => { + await this.removeStoredArtifact(artifact).catch((error) => { + this.log.warn('[CLI] Failed to remove unavailable artifact', error) + }) + throw new CliRequestError('unavailable', 'Artifact data is unavailable', { + httpStatus: 410 + }) + }) + const openedStat = await handle.stat().catch(() => null) + if ( + !openedStat?.isFile() || + openedStat.size !== artifact.metadata.size || + openedStat.dev !== fileStat.dev || + openedStat.ino !== fileStat.ino + ) { + await handle.close().catch(() => undefined) + await this.removeStoredArtifact(artifact).catch((error) => { + this.log.warn('[CLI] Failed to remove changed artifact', error) + }) + throw new CliRequestError('unavailable', 'Artifact data changed before it could be read', { + httpStatus: 410 + }) + } + if (this.closing) { + await handle.close().catch(() => undefined) + throw new CliRequestError('unavailable', 'Artifact spool is closed', { httpStatus: 503 }) + } + return { artifact, handle, release } + } catch (error) { + release() + throw error + } + } + private acquireRead(artifact: StoredArtifact): () => void { const id = artifact.metadata.id if ( @@ -700,7 +745,7 @@ export class ArtifactSpool { if (existingRemoval) return await existingRemoval if ((this.activeReads.get(id) ?? 0) > 0) { if (activeReadBehavior === 'reject') { - throw new CliRequestError('conflict', 'Artifact is currently being downloaded', { + throw new CliRequestError('conflict', 'Artifact is currently in use', { httpStatus: 409, retriable: true }) diff --git a/src/main/cli/body.ts b/src/main/cli/body.ts index e0b2d4390..3c3c6d32d 100644 --- a/src/main/cli/body.ts +++ b/src/main/cli/body.ts @@ -222,24 +222,28 @@ function assertBoundedJsonShape(value: unknown): void { } } +export function parseBoundedJsonBytes(bytes: Uint8Array): unknown { + let text: string + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(bytes) + } catch { + throw new CliRequestError('invalid_request', 'Request body is not valid UTF-8') + } + + let parsed: unknown + try { + parsed = JSON.parse(text) as unknown + } catch { + throw new CliRequestError('invalid_request', 'Request body is not valid JSON') + } + assertBoundedJsonShape(parsed) + return parsed +} + export async function parseBoundedJsonBody(body: BoundedRequestBody): Promise { try { const bytes = body.kind === 'memory' ? body.bytes : await readFile(body.path) - let text: string - try { - text = new TextDecoder('utf-8', { fatal: true }).decode(bytes) - } catch { - throw new CliRequestError('invalid_request', 'Request body is not valid UTF-8') - } - - let parsed: unknown - try { - parsed = JSON.parse(text) as unknown - } catch { - throw new CliRequestError('invalid_request', 'Request body is not valid JSON') - } - assertBoundedJsonShape(parsed) - return parsed + return parseBoundedJsonBytes(bytes) } finally { await body.cleanup() } diff --git a/src/main/cli/descriptor.ts b/src/main/cli/descriptor.ts index 50585a7ea..59533720d 100644 --- a/src/main/cli/descriptor.ts +++ b/src/main/cli/descriptor.ts @@ -1,6 +1,16 @@ import { createHash, randomBytes, randomUUID } from 'node:crypto' import { execFile } from 'node:child_process' -import { chmod, lstat, mkdir, open, readFile, rename, rmdir, unlink } from 'node:fs/promises' +import { + chmod, + lstat, + mkdir, + open, + readFile, + readdir, + rename, + rmdir, + unlink +} from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { promisify } from 'node:util' @@ -15,6 +25,8 @@ import { const execFileAsync = promisify(execFile) const MAX_POSIX_SOCKET_PATH_BYTES = 100 +const OWNED_TEMP_FILE_PATTERN = + /^body-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/ export type CliControlLayout = Readonly<{ controlDirectory: string @@ -131,6 +143,12 @@ export async function prepareLocalControlLayout( } } + for (const entry of await readdir(layout.tempDirectory, { withFileTypes: true })) { + if (OWNED_TEMP_FILE_PATTERN.test(entry.name) && (entry.isFile() || entry.isSymbolicLink())) { + await unlink(path.join(layout.tempDirectory, entry.name)) + } + } + if (layout.endpoint.kind === 'unix') { try { const socketStat = await lstat(layout.endpoint.path) diff --git a/src/main/cli/server.ts b/src/main/cli/server.ts index dcbbc3074..ced41492e 100644 --- a/src/main/cli/server.ts +++ b/src/main/cli/server.ts @@ -14,8 +14,11 @@ import { LOCAL_CONTROL_SCOPES, LOCAL_CONTROL_STREAM_PATH, LOCAL_CONTROL_SURFACE_VERSION, + LOCAL_CONTROL_UPLOAD_PATH, + LOCAL_CONTROL_UPLOAD_REQUEST_HEADER, LOCAL_CONTROL_MAX_JSON_RESPONSE_BYTES, LOCAL_CONTROL_MAX_STREAM_RECORD_BYTES, + LOCAL_CONTROL_MAX_UPLOAD_REQUEST_HEADER_BYTES, LocalControlEventEnvelopeSchema, LocalControlScopesSchema, LocalControlTokenSchema, @@ -26,7 +29,7 @@ import { type LocalControlStreamRecord } from '@shared/contracts/localControl' import type { CliRouteCaller } from '@/routes/routeRegistry' -import { parseBoundedJsonBody, readBoundedRequestBody } from './body' +import { parseBoundedJsonBody, parseBoundedJsonBytes, readBoundedRequestBody } from './body' import { cleanupLocalControlLayout, createLocalControlLayout, @@ -37,7 +40,7 @@ import { type CliControlLayout } from './descriptor' import { CliRequestError } from './errors' -import { CLI_SURFACE_V1, getCliSurfaceEntry } from './surface' +import { CLI_SURFACE_V1 } from './surface' import type { CliSurfaceEntry } from './surface' import type { CliRuntimeStatus } from './routes' import type { ArtifactSpool } from './artifactSpool' @@ -62,6 +65,11 @@ export type AgentCliToken = z.infer export type CliStreamEmitter = (event: string, data: JsonValue) => Promise +export type CliUploadedInputFile = Readonly<{ + path: string + size: number +}> + export type CliServerDependencies = Readonly<{ userDataPath: string appVersion: string @@ -79,6 +87,14 @@ export type CliServerDependencies = Readonly<{ signal: AbortSignal, emit: CliStreamEmitter ): Promise + dispatchUpload?( + method: string, + input: unknown, + upload: CliUploadedInputFile, + caller: CliRouteCaller, + signal: AbortSignal + ): Promise + surface?: ReadonlyMap resolveAgentToken?(token: string): AgentCliToken | null artifactSpool?: ArtifactSpool now?: () => number @@ -95,9 +111,16 @@ function tokensEqual(left: string, right: string): boolean { return timingSafeEqual(hashToken(left), hashToken(right)) } +function readSingularRequestHeader(request: IncomingMessage, name: string): string | null { + const distinctValues = request.headersDistinct[name] + if (distinctValues && distinctValues.length !== 1) return null + const value = distinctValues?.[0] ?? request.headers[name] + return typeof value === 'string' ? value : null +} + function readBearerToken(request: IncomingMessage): string | null { - const authorization = request.headers.authorization - if (typeof authorization !== 'string') return null + const authorization = readSingularRequestHeader(request, 'authorization') + if (!authorization) return null const match = /^Bearer (\S+)$/.exec(authorization) if (!match) return null const token = LocalControlTokenSchema.safeParse(match[1]) @@ -105,16 +128,47 @@ function readBearerToken(request: IncomingMessage): string | null { } function requestContentTypeIsJson(request: IncomingMessage): boolean { - const contentType = request.headers['content-type'] - if (typeof contentType !== 'string') return false + const contentType = readSingularRequestHeader(request, 'content-type') + if (!contentType) return false const [mediaType, ...parameters] = contentType.split(';').map((part) => part.trim().toLowerCase()) if (mediaType !== 'application/json') return false return parameters.every((parameter) => parameter === 'charset=utf-8') } -function getMaxBodyBytes(transport: 'rpc' | 'stream'): number { +function requestContentTypeIsBinary(request: IncomingMessage): boolean { + const contentType = readSingularRequestHeader(request, 'content-type') + if (!contentType) return false + return contentType.trim().toLowerCase() === 'application/octet-stream' +} + +function parseUploadRequestHeader(request: IncomingMessage): unknown { + const distinctValues = request.headersDistinct[LOCAL_CONTROL_UPLOAD_REQUEST_HEADER] + if (distinctValues && distinctValues.length !== 1) { + throw new CliRequestError('invalid_request', 'Upload request header must be singular') + } + const rawValue = distinctValues?.[0] ?? request.headers[LOCAL_CONTROL_UPLOAD_REQUEST_HEADER] + if ( + typeof rawValue !== 'string' || + rawValue.length === 0 || + Buffer.byteLength(rawValue, 'ascii') > LOCAL_CONTROL_MAX_UPLOAD_REQUEST_HEADER_BYTES || + !/^[A-Za-z0-9_-]+$/.test(rawValue) + ) { + throw new CliRequestError('invalid_request', 'Upload request header is invalid') + } + + const decoded = Buffer.from(rawValue, 'base64url') + if (decoded.toString('base64url') !== rawValue) { + throw new CliRequestError('invalid_request', 'Upload request header is not canonical') + } + return parseBoundedJsonBytes(decoded) +} + +function getMaxBodyBytes( + surface: ReadonlyMap, + transport: 'rpc' | 'stream' +): number { let maxBytes = 1 - for (const entry of CLI_SURFACE_V1.values()) { + for (const entry of surface.values()) { if (entry.transport === transport) maxBytes = Math.max(maxBytes, entry.limits.maxBodyBytes) } return maxBytes @@ -177,6 +231,7 @@ export class CliServer { private readonly platform: NodeJS.Platform private readonly pid: number private readonly log: Pick + private readonly surface: ReadonlyMap private readonly sockets = new Set() private readonly connectionIds = new WeakMap() private readonly pendingByConnection = new Map() @@ -195,6 +250,7 @@ export class CliServer { this.platform = dependencies.platform ?? process.platform this.pid = dependencies.pid ?? process.pid this.log = dependencies.log ?? console + this.surface = new Map(dependencies.surface ?? CLI_SURFACE_V1) } getStatus(): CliRuntimeStatus { @@ -416,9 +472,10 @@ export class CliServer { const connectionId = this.connectionIds.get(request.socket) ?? randomUUID() const isRpcRequest = request.method === 'POST' && request.url === LOCAL_CONTROL_RPC_PATH const isStreamRequest = request.method === 'POST' && request.url === LOCAL_CONTROL_STREAM_PATH + const isUploadRequest = request.method === 'POST' && request.url === LOCAL_CONTROL_UPLOAD_PATH const isArtifactRequest = request.method === 'GET' && request.url?.startsWith(LOCAL_CONTROL_ARTIFACT_PATH_PREFIX) - if (!isRpcRequest && !isStreamRequest && !isArtifactRequest) { + if (!isRpcRequest && !isStreamRequest && !isUploadRequest && !isArtifactRequest) { this.sendFailure( response, 404, @@ -457,15 +514,22 @@ export class CliServer { await this.handleArtifactDownload(request, response, caller) return } - const requestTransport = isStreamRequest ? 'stream' : 'rpc' - if (!requestContentTypeIsJson(request)) { + const requestTransport = isUploadRequest ? 'upload' : isStreamRequest ? 'stream' : 'rpc' + if ( + (requestTransport === 'upload' && !requestContentTypeIsBinary(request)) || + (requestTransport !== 'upload' && !requestContentTypeIsJson(request)) + ) { this.sendFailure( response, 415, UNKNOWN_REQUEST_ID, - new CliRequestError('invalid_request', 'Content-Type must be application/json', { - httpStatus: 415 - }) + new CliRequestError( + 'invalid_request', + requestTransport === 'upload' + ? 'Content-Type must be application/octet-stream' + : 'Content-Type must be application/json', + { httpStatus: 415 } + ) ) return } @@ -502,14 +566,21 @@ export class CliServer { let requestId = UNKNOWN_REQUEST_ID let routeMethod = 'unknown' try { - const body = await readBoundedRequestBody(request, { - maxBytes: getMaxBodyBytes(requestTransport), - memoryThresholdBytes: Math.min(getMaxBodyBytes(requestTransport), MAX_IN_MEMORY_BODY_BYTES), - tempDirectory: this.layout?.tempDirectory ?? this.dependencies.userDataPath, - requireContentLength: true - }) - const bodySize = body.size - const rawRequest = await parseBoundedJsonBody(body) + let bodySize = 0 + let rawRequest: unknown + if (requestTransport === 'upload') { + rawRequest = parseUploadRequestHeader(request) + } else { + const maxBodyBytes = getMaxBodyBytes(this.surface, requestTransport) + const body = await readBoundedRequestBody(request, { + maxBytes: maxBodyBytes, + memoryThresholdBytes: Math.min(maxBodyBytes, MAX_IN_MEMORY_BODY_BYTES), + tempDirectory: this.layout?.tempDirectory ?? this.dependencies.userDataPath, + requireContentLength: true + }) + bodySize = body.size + rawRequest = await parseBoundedJsonBody(body) + } if (isRecord(rawRequest)) requestId = toSafeRequestId(rawRequest.id) if ( isRecord(rawRequest) && @@ -530,13 +601,13 @@ export class CliServer { const rpcRequest = parsedRequest.data requestId = rpcRequest.id routeMethod = rpcRequest.method - const entry = getCliSurfaceEntry(rpcRequest.method) + const entry = this.surface.get(rpcRequest.method) if (!entry || entry.transport !== requestTransport) { throw new CliRequestError('not_found', 'Method is not exposed by CLI surface V1', { httpStatus: 404 }) } - if (bodySize > entry.limits.maxBodyBytes) { + if (requestTransport !== 'upload' && bodySize > entry.limits.maxBodyBytes) { throw new CliRequestError('body_too_large', 'Request body exceeds method limit', { httpStatus: 413 }) @@ -571,9 +642,44 @@ export class CliServer { ) return } - rawOutput = await runAbortable(controller.signal, async () => - this.dependencies.dispatch(entry.contract.name, input, caller, controller.signal) - ) + if (requestTransport === 'upload') { + const dispatchUpload = this.dependencies.dispatchUpload + if (!dispatchUpload) { + throw new CliRequestError('unavailable', 'Upload service is unavailable', { + httpStatus: 503, + retriable: true + }) + } + const uploadBody = await readBoundedRequestBody(request, { + maxBytes: entry.limits.maxBodyBytes, + memoryThresholdBytes: 0, + tempDirectory: this.layout?.tempDirectory ?? this.dependencies.userDataPath, + requireContentLength: false + }) + try { + if (uploadBody.size === 0) { + throw new CliRequestError('invalid_request', 'Upload body is empty') + } + if (uploadBody.kind !== 'file') { + throw new CliRequestError('internal_error', 'Upload body was not persisted', { + httpStatus: 500 + }) + } + rawOutput = await dispatchUpload( + entry.contract.name, + input, + { path: uploadBody.path, size: uploadBody.size }, + caller, + controller.signal + ) + } finally { + await uploadBody.cleanup() + } + } else { + rawOutput = await runAbortable(controller.signal, async () => + this.dependencies.dispatch(entry.contract.name, input, caller, controller.signal) + ) + } } finally { clearTimeout(timeout) } @@ -762,7 +868,7 @@ export class CliServer { ): Promise { const rawId = request.url?.slice(LOCAL_CONTROL_ARTIFACT_PATH_PREFIX.length) ?? '' const parsedId = ArtifactIdSchema.safeParse(rawId) - const entry = getCliSurfaceEntry(artifactsReadRoute.name) + const entry = this.surface.get(artifactsReadRoute.name) if (!parsedId.success || !entry || entry.transport !== 'download') { this.sendFailure( response, diff --git a/src/shared/contracts/localControl.ts b/src/shared/contracts/localControl.ts index 47244f904..466257cf8 100644 --- a/src/shared/contracts/localControl.ts +++ b/src/shared/contracts/localControl.ts @@ -9,6 +9,9 @@ export const LOCAL_CONTROL_MAX_STREAM_RECORD_BYTES = 20 * 1024 * 1024 export const LOCAL_CONTROL_DESCRIPTOR_FILENAME = 'local-control.json' export const LOCAL_CONTROL_RPC_PATH = '/v1/rpc' export const LOCAL_CONTROL_STREAM_PATH = '/v1/stream' +export const LOCAL_CONTROL_UPLOAD_PATH = '/v1/upload' +export const LOCAL_CONTROL_UPLOAD_REQUEST_HEADER = 'x-deepchat-upload-request' +export const LOCAL_CONTROL_MAX_UPLOAD_REQUEST_HEADER_BYTES = 4 * 1024 export const LOCAL_CONTROL_ARTIFACT_PATH_PREFIX = '/v1/artifacts/' export const LOCAL_CONTROL_AGENT_TOKEN_ENV = 'DEEPCHAT_CLI_AGENT_TOKEN' diff --git a/test/main/cli/artifactSpool.test.ts b/test/main/cli/artifactSpool.test.ts index a6dea75fa..e06c6da15 100644 --- a/test/main/cli/artifactSpool.test.ts +++ b/test/main/cli/artifactSpool.test.ts @@ -269,6 +269,31 @@ describe('ArtifactSpool', () => { }) }) + it('leases an owned file for domain input without exposing its path in metadata', async () => { + const { spool } = await createSpool() + const owner = agentCaller('conversation-a') + const metadata = await spool.write({ + caller: owner, + requestId: 'request-input', + mimeType: 'audio/wav', + data: Buffer.from('audio-input') + }) + + const bytes = await spool.withFile(metadata.id, owner, async (file) => { + expect(file.metadata).not.toHaveProperty('path') + await expect(spool.delete(metadata.id, humanCaller)).rejects.toMatchObject({ + code: 'conflict' + }) + return await readFile(file.path) + }) + + expect(bytes).toEqual(Buffer.from('audio-input')) + await expect( + spool.withFile(metadata.id, agentCaller('conversation-b'), async () => undefined) + ).rejects.toMatchObject({ code: 'permission_denied' }) + await expect(spool.delete(metadata.id, humanCaller)).resolves.toBeUndefined() + }) + it('defers internal discard until active reads finish and blocks new readers', async () => { const { spool, directory } = await createSpool() const metadata = await spool.write({ diff --git a/test/main/cli/body.test.ts b/test/main/cli/body.test.ts index d689a5492..9f033cf52 100644 --- a/test/main/cli/body.test.ts +++ b/test/main/cli/body.test.ts @@ -4,7 +4,7 @@ import { mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { parseBoundedJsonBody, readBoundedRequestBody } from '@/cli/body' +import { parseBoundedJsonBody, parseBoundedJsonBytes, readBoundedRequestBody } from '@/cli/body' import { CliRequestError } from '@/cli/errors' const temporaryDirectories: string[] = [] @@ -123,4 +123,14 @@ describe('bounded CLI request bodies', () => { ).rejects.toMatchObject({ code: 'invalid_request' }) expect(cleanup).toHaveBeenCalledOnce() }) + + it('applies the same UTF-8 and shape limits to header JSON', () => { + expect(parseBoundedJsonBytes(Buffer.from('{"value":42}'))).toEqual({ value: 42 }) + expect(() => parseBoundedJsonBytes(Buffer.from([0xc3, 0x28]))).toThrowError( + expect.objectContaining({ code: 'invalid_request' }) + ) + expect(() => parseBoundedJsonBytes(Buffer.from('{"constructor":true}'))).toThrowError( + expect.objectContaining({ code: 'invalid_request' }) + ) + }) }) diff --git a/test/main/cli/descriptor.test.ts b/test/main/cli/descriptor.test.ts index 54589dec2..e542b2857 100644 --- a/test/main/cli/descriptor.test.ts +++ b/test/main/cli/descriptor.test.ts @@ -1,5 +1,5 @@ import { createServer, type Server } from 'node:net' -import { lstat, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { lstat, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -66,6 +66,21 @@ describe.skipIf(process.platform === 'win32')('CLI discovery descriptor', () => await expect(stat(layout.descriptorPath)).rejects.toMatchObject({ code: 'ENOENT' }) }) + it('removes only owned request-body remnants from the private temp directory', async () => { + const userDataPath = await createTemporaryDirectory() + const layout = createLocalControlLayout(userDataPath, 'darwin') + await prepareLocalControlLayout(layout, 'darwin') + await writeFile( + path.join(layout.tempDirectory, 'body-123e4567-e89b-42d3-a456-426614174000.tmp'), + 'partial' + ) + await writeFile(path.join(layout.tempDirectory, 'keep.tmp'), 'foreign') + + await prepareLocalControlLayout(layout, 'darwin') + + expect(await readdir(layout.tempDirectory)).toEqual(['keep.tmp']) + }) + it('refuses a non-socket endpoint without deleting discovery state', async () => { const userDataPath = await createTemporaryDirectory() const layout = createLocalControlLayout(userDataPath, 'darwin') diff --git a/test/main/cli/server.test.ts b/test/main/cli/server.test.ts index 7f308a7b5..c7daa2c45 100644 --- a/test/main/cli/server.test.ts +++ b/test/main/cli/server.test.ts @@ -1,22 +1,25 @@ import { request as httpRequest } from 'node:http' -import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' +import { mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { DeepchatRouteName } from '@shared/contracts/routes' +import { cliVersionRoute, type DeepchatRouteName } from '@shared/contracts/routes' import type { JsonValue } from '@shared/contracts/json' import { LOCAL_CONTROL_PROTOCOL_VERSION, LOCAL_CONTROL_SCOPES, LOCAL_CONTROL_SURFACE_VERSION, + LOCAL_CONTROL_UPLOAD_REQUEST_HEADER, LocalControlDescriptorSchema, + LocalControlRpcRequestSchema, LocalControlRpcResponseSchema, type LocalControlDescriptor, type LocalControlRpcResponse, type LocalControlScope } from '@shared/contracts/localControl' import { createCliRoutes } from '@/cli/routes' -import { CliServer, type AgentCliToken } from '@/cli/server' +import { CliServer, type AgentCliToken, type CliUploadedInputFile } from '@/cli/server' +import type { CliSurfaceEntry } from '@/cli/surface' import type { CliRouteCaller } from '@/routes/routeRegistry' import { invokeLocalControlStream } from '../../../src/cli/transport' @@ -104,16 +107,115 @@ function rpcRequest( }) } +function uploadRequest( + descriptor: LocalControlDescriptor, + input: { + token?: string + body: Buffer + includeContentLength?: boolean + } +): Promise { + const envelope = Buffer.from( + JSON.stringify( + LocalControlRpcRequestSchema.parse({ + protocolVersion: LOCAL_CONTROL_PROTOCOL_VERSION, + surfaceVersion: LOCAL_CONTROL_SURFACE_VERSION, + id: 'request-upload', + method: cliVersionRoute.name, + params: {} + }) + ) + ).toString('base64url') + const headers: Record = { + authorization: `Bearer ${input.token ?? descriptor.token}`, + 'content-type': 'application/octet-stream', + [LOCAL_CONTROL_UPLOAD_REQUEST_HEADER]: envelope + } + if (input.includeContentLength !== false) headers['content-length'] = input.body.length + + return new Promise((resolve, reject) => { + let responseReceived = false + const request = httpRequest( + { + socketPath: + descriptor.endpoint.kind === 'unix' ? descriptor.endpoint.path : descriptor.endpoint.name, + path: '/v1/upload', + method: 'POST', + headers + }, + (response) => { + responseReceived = true + const chunks: Buffer[] = [] + response.on('data', (chunk: Buffer) => chunks.push(chunk)) + response.once('error', reject) + response.once('end', () => { + try { + resolve({ + status: response.statusCode ?? 0, + connection: + typeof response.headers.connection === 'string' + ? response.headers.connection + : undefined, + body: LocalControlRpcResponseSchema.parse( + JSON.parse(Buffer.concat(chunks).toString('utf8')) + ) + }) + } catch (error) { + reject(error) + } + }) + } + ) + request.once('error', (error) => { + if (!responseReceived) reject(error) + }) + if (input.includeContentLength === false) { + const midpoint = Math.max(1, Math.floor(input.body.length / 2)) + request.write(input.body.subarray(0, midpoint)) + request.end(input.body.subarray(midpoint)) + } else { + request.end(input.body) + } + }) +} + +function createUploadSurface(maxBodyBytes: number): ReadonlyMap { + return new Map([ + [ + cliVersionRoute.name, + { + contract: cliVersionRoute, + effect: 'compute', + callers: ['human'], + scopes: ['system:read'], + transport: 'upload', + approval: 'never', + limits: { maxBodyBytes, timeoutMs: 5_000 } + } satisfies CliSurfaceEntry + ] + ]) +} + async function createTestServer( options: { resolveAgentToken?: (token: string) => AgentCliToken | null dispatchOutput?: (method: string) => unknown streamOutput?: Readonly<{ events: readonly JsonValue[]; result: unknown }> + surface?: ReadonlyMap + dispatchUpload?: ( + method: string, + input: unknown, + upload: CliUploadedInputFile, + caller: CliRouteCaller, + signal: AbortSignal + ) => Promise } = {} ): Promise<{ + userDataPath: string server: CliServer descriptor: LocalControlDescriptor dispatch: ReturnType + dispatchUpload: ReturnType }> { const userDataPath = await createTemporaryDirectory() let server: CliServer @@ -130,6 +232,7 @@ async function createTestServer( return await route(input, { caller }) } ) + const dispatchUpload = vi.fn(options.dispatchUpload ?? (async () => ({}))) server = new CliServer({ userDataPath, appVersion: '1.2.3', @@ -150,11 +253,13 @@ async function createTestServer( } : {}), resolveAgentToken: options.resolveAgentToken, + dispatchUpload, + surface: options.surface, log: { warn: vi.fn(), error: vi.fn() } }) servers.push(server) const descriptor = await server.start() - return { server, descriptor, dispatch } + return { userDataPath, server, descriptor, dispatch, dispatchUpload } } afterEach(async () => { @@ -271,6 +376,76 @@ describe('CLI local transport', () => { expect(dispatch).not.toHaveBeenCalled() }) + it('validates upload policy before spilling and cleans the private input file', async () => { + let uploadedPath = '' + let uploadedBytes = Buffer.alloc(0) + const { descriptor, userDataPath, dispatchUpload } = await createTestServer({ + surface: createUploadSurface(16), + dispatchUpload: async (_method, _input, upload) => { + uploadedPath = upload.path + uploadedBytes = await readFile(upload.path) + return { + appVersion: '1.2.3', + protocolVersion: LOCAL_CONTROL_PROTOCOL_VERSION, + surfaceVersion: LOCAL_CONTROL_SURFACE_VERSION + } + } + }) + + const response = await uploadRequest(descriptor, { body: Buffer.from('audio-input') }) + + expect(response).toMatchObject({ status: 200, body: { ok: true } }) + expect(uploadedBytes).toEqual(Buffer.from('audio-input')) + expect(dispatchUpload).toHaveBeenCalledOnce() + await expect(stat(uploadedPath)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await readdir(path.join(userDataPath, 'local-control', 'tmp'))).toEqual([]) + }) + + it('bounds chunked uploads cumulatively and removes partial spill files', async () => { + const { descriptor, userDataPath, dispatchUpload } = await createTestServer({ + surface: createUploadSurface(8) + }) + + const response = await uploadRequest(descriptor, { + body: Buffer.from('123456789'), + includeContentLength: false + }) + + expect(response).toMatchObject({ + status: 413, + body: { ok: false, error: { code: 'body_too_large' } } + }) + expect(dispatchUpload).not.toHaveBeenCalled() + expect(await readdir(path.join(userDataPath, 'local-control', 'tmp'))).toEqual([]) + }) + + it('denies Agent upload callers before reading their body', async () => { + const agentToken = 'a'.repeat(43) + const { descriptor, userDataPath, dispatchUpload } = await createTestServer({ + surface: createUploadSurface(16), + resolveAgentToken: (token) => + token === agentToken + ? { + conversationId: 'conversation-1', + expiresAt: Date.now() + 60_000, + scopes: ['system:read'] + } + : null + }) + + const response = await uploadRequest(descriptor, { + token: agentToken, + body: Buffer.from('audio-input') + }) + + expect(response).toMatchObject({ + status: 403, + body: { ok: false, error: { code: 'permission_denied' } } + }) + expect(dispatchUpload).not.toHaveBeenCalled() + expect(await readdir(path.join(userDataPath, 'local-control', 'tmp'))).toEqual([]) + }) + it('rejects request bodies on artifact download endpoints', async () => { const { descriptor } = await createTestServer() const body = await new Promise((resolve, reject) => { diff --git a/test/main/cli/transport.test.ts b/test/main/cli/transport.test.ts index 4040b4707..eba4277fc 100644 --- a/test/main/cli/transport.test.ts +++ b/test/main/cli/transport.test.ts @@ -1,19 +1,28 @@ import { randomUUID } from 'node:crypto' -import { rm } from 'node:fs/promises' +import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { createServer, type RequestListener, type Server } from 'node:http' +import os from 'node:os' +import path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { LOCAL_CONTROL_PROTOCOL_VERSION, LOCAL_CONTROL_SURFACE_VERSION, + LOCAL_CONTROL_UPLOAD_REQUEST_HEADER, + LocalControlRpcRequestSchema, createLocalControlSuccess, type LocalControlDescriptor, type LocalControlEndpoint, type LocalControlEventEnvelope } from '@shared/contracts/localControl' -import { invokeLocalControlRpc, invokeLocalControlStream } from '../../../src/cli/transport' +import { + invokeLocalControlRpc, + invokeLocalControlStream, + invokeLocalControlUpload +} from '../../../src/cli/transport' const servers: Server[] = [] const socketPaths: string[] = [] +const temporaryDirectories: string[] = [] function createEndpoint(): LocalControlEndpoint { if (process.platform === 'win32') { @@ -86,6 +95,9 @@ afterEach(async () => { ) ) await Promise.all(socketPaths.splice(0).map((socketPath) => rm(socketPath, { force: true }))) + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })) + ) }) describe('CLI response transport', () => { @@ -127,6 +139,25 @@ describe('CLI response transport', () => { }) }) + it('preserves cancellation after JSON response headers arrive', async () => { + const descriptor = await listen((_request, response) => { + response.setHeader('content-type', 'application/json; charset=utf-8') + response.flushHeaders() + }) + const controller = new AbortController() + const result = invokeLocalControlRpc({ + descriptor, + token: descriptor.token, + id: 'request-1', + method: 'cli.version', + params: {}, + signal: controller.signal + }) + controller.abort(new Error('cancelled-by-test')) + + await expect(result).rejects.toMatchObject({ message: 'cancelled-by-test' }) + }) + it('consumes ordered NDJSON events before the terminal envelope', async () => { const descriptor = await listen((_request, response) => { response.setHeader('content-type', 'application/x-ndjson; charset=utf-8') @@ -184,4 +215,78 @@ describe('CLI response transport', () => { exitCode: 8 }) }) + + it('uploads a stable regular-file snapshot with a typed envelope header', async () => { + let receivedBody = Buffer.alloc(0) + let receivedEnvelope: unknown + const descriptor = await listen((request, response) => { + const rawEnvelope = request.headers[LOCAL_CONTROL_UPLOAD_REQUEST_HEADER] + receivedEnvelope = LocalControlRpcRequestSchema.parse( + JSON.parse(Buffer.from(String(rawEnvelope), 'base64url').toString('utf8')) + ) + const chunks: Buffer[] = [] + request.on('data', (chunk: Buffer) => chunks.push(chunk)) + request.once('end', () => { + receivedBody = Buffer.concat(chunks) + response.setHeader('content-type', 'application/json; charset=utf-8') + response.end(JSON.stringify(createLocalControlSuccess('request-1', { accepted: true }))) + }) + }) + const directory = await mkdtemp(path.join(os.tmpdir(), 'deepchat-cli-upload-')) + temporaryDirectories.push(directory) + const filePath = path.join(directory, 'sample.wav') + await writeFile(filePath, 'audio-bytes') + + const result = await invokeLocalControlUpload({ + descriptor, + token: descriptor.token, + id: 'request-1', + method: 'audio.transcribeUpload', + params: { mimeType: 'audio/wav', filename: 'sample.wav' }, + signal: new AbortController().signal, + filePath, + maxBytes: 64 + }) + + expect(result).toMatchObject({ ok: true, result: { accepted: true } }) + expect(receivedEnvelope).toMatchObject({ + id: 'request-1', + method: 'audio.transcribeUpload', + params: { filename: 'sample.wav' } + }) + expect(receivedBody).toEqual(Buffer.from('audio-bytes')) + }) + + it.runIf(process.platform !== 'win32')( + 'rejects symlink upload sources before connecting', + async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'deepchat-cli-upload-link-')) + temporaryDirectories.push(directory) + const targetPath = path.join(directory, 'target.wav') + const linkPath = path.join(directory, 'link.wav') + await writeFile(targetPath, 'audio-bytes') + await symlink(targetPath, linkPath) + + await expect( + invokeLocalControlUpload({ + descriptor: { + protocolVersion: LOCAL_CONTROL_PROTOCOL_VERSION, + surfaceVersion: LOCAL_CONTROL_SURFACE_VERSION, + appVersion: '1.2.3', + endpoint: { kind: 'unix', path: '/tmp/unused-deepchat.sock' }, + pid: process.pid, + token: 't'.repeat(43), + startedAt: Date.now() + }, + token: 't'.repeat(43), + id: 'request-1', + method: 'audio.transcribeUpload', + params: {}, + signal: new AbortController().signal, + filePath: linkPath, + maxBytes: 64 + }) + ).rejects.toMatchObject({ code: 'invalid_request', exitCode: 2 }) + } + ) }) From edbcac5f33b8e06dce52de35030796c15723f416 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 13:25:31 +0800 Subject: [PATCH 11/51] feat(cli): add transcription and OCR --- docs/architecture/local-control-plane/spec.md | 15 +- .../architecture/local-control-plane/tasks.md | 10 +- src/cli/args.ts | 202 ++++++++- src/cli/format.ts | 25 + src/cli/run.ts | 41 +- src/main/app/composition.ts | 40 ++ src/main/cli/audioTranscriptionService.ts | 242 ++++++++++ src/main/cli/index.ts | 5 + src/main/cli/ocrService.ts | 395 ++++++++++++++++ src/main/cli/server.ts | 19 +- src/main/cli/surface.ts | 77 ++++ src/main/ocr/ocrRuntimeService.ts | 9 +- src/main/ocr/routes.ts | 41 +- src/shared/contracts/localControl.ts | 1 + src/shared/contracts/routes.ts | 13 +- src/shared/contracts/routes/audio.routes.ts | 76 ++++ src/shared/contracts/routes/ocr.routes.ts | 198 +++++++- test/main/cli/args.test.ts | 129 ++++++ test/main/cli/client.test.ts | 98 +++- test/main/cli/inputCapabilityServices.test.ts | 427 ++++++++++++++++++ test/main/cli/server.test.ts | 21 + test/main/cli/surface.test.ts | 39 ++ 22 files changed, 2054 insertions(+), 69 deletions(-) create mode 100644 src/main/cli/audioTranscriptionService.ts create mode 100644 src/main/cli/ocrService.ts create mode 100644 src/shared/contracts/routes/audio.routes.ts create mode 100644 test/main/cli/inputCapabilityServices.test.ts diff --git a/docs/architecture/local-control-plane/spec.md b/docs/architecture/local-control-plane/spec.md index 42b5f9831..d73d493a6 100644 --- a/docs/architecture/local-control-plane/spec.md +++ b/docs/architecture/local-control-plane/spec.md @@ -425,12 +425,15 @@ Results distinguish: - `cold-runtime`; - `offline-availability`. -They report at least `runtimeWasReady`, cache state, input bytes/type, pages where applicable, -duration, output characters/tokens, engine identity, app/protocol/surface version, and availability. -`clearCache()` first calls `getResources()` and therefore warms an unstarted runtime. A clear followed -by extraction is necessarily a warm-runtime cache miss, never a cold-runtime measurement. Cold -runtime requires restarting the desktop application or an external harness. V1 does not expose -`restart-runtime` merely to improve a benchmark. +They report at least `runtimeStateBefore`, `runtimeWasReady`, cache state, input bytes/type, pages +where applicable, duration, output characters/tokens, engine identity, app/protocol/surface version, +and availability. +`clearCache()` first calls `getResources()`, which initializes the resource graph and cache backend +but does not spawn the OCR helper. A clear followed by extraction is a warm-runtime miss only when +the helper was already `ready`; a fresh or restarted application whose host is still `idle` produces +a cold-runtime miss. `busy`, `starting`, and `stopping` states reject clearing. Classification always +uses the actual pre-extraction host state. V1 does not expose `restart-runtime` merely to improve a +benchmark. ## File I/O Boundary diff --git a/docs/architecture/local-control-plane/tasks.md b/docs/architecture/local-control-plane/tasks.md index d45d6fddb..657a8c681 100644 --- a/docs/architecture/local-control-plane/tasks.md +++ b/docs/architecture/local-control-plane/tasks.md @@ -32,17 +32,17 @@ - [x] Add raw `models.invoke` over `coreStream` with no Agent/session/tool side effects. - [x] Add image and video standalone generation surfaces. - [x] Add formal standalone speech generation and typed audio output. -- [ ] Add upload and owned-artifact transcription inputs. +- [x] Add upload and owned-artifact transcription inputs. - [x] Implement output-only `ArtifactSpool` ownership, quotas, expiry, and cleanup. - [ ] Add stream, media, speech, transcription, artifact, and quota tests. ## OCR -- [ ] Add explicit upload and owned-artifact extraction contracts and handlers. -- [ ] Preserve automatic-attachment-setting independence and background priority. -- [ ] Enforce bounded text output and exclude layout/batch/model administration. +- [x] Add explicit upload and owned-artifact extraction contracts and handlers. +- [x] Preserve automatic-attachment-setting independence and background priority. +- [x] Enforce bounded text output and exclude layout/batch/model administration. - [ ] Classify cache clear as audited human-only `local-maintenance` without approval. -- [ ] Report cache hit, warm-runtime miss, cold-runtime, and offline metrics accurately. +- [x] Report cache hit, warm-runtime miss, cold-runtime, and offline metrics accurately. - [ ] Add OCR caller, input, cache, runtime-state, output-bound, and benchmark tests. ## Effects and Approval diff --git a/src/cli/args.ts b/src/cli/args.ts index 8ed600fd5..634a6d059 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -10,6 +10,11 @@ import { artifactsDescribeRoute, artifactsReadRoute } from '@shared/contracts/routes/artifacts.routes' +import { + AUDIO_TRANSCRIPTION_MAX_INPUT_BYTES, + audioTranscribeArtifactRoute, + audioTranscribeUploadRoute +} from '@shared/contracts/routes/audio.routes' import { modelsInvokeRoute } from '@shared/contracts/routes/models.routes' import { imagesGenerateRoute, @@ -17,8 +22,20 @@ import { videosGenerateRoute } from '@shared/contracts/routes/media.routes' import { providersListPublicRoute } from '@shared/contracts/routes/providers.routes' +import { + OCR_EXTRACTION_MAX_INPUT_BYTES, + ocrClearCacheRoute, + ocrExtractArtifactRoute, + ocrExtractUploadRoute, + ocrGetRuntimeStatusRoute +} from '@shared/contracts/routes/ocr.routes' import type { JsonValue } from '@shared/contracts/json' import { LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS } from '@shared/contracts/localControl' +import { + ATTACHMENT_PDF_OCR_MAX_TOKENS, + PDF_PAGE_COUNT_SANITY_LIMIT +} from '@shared/types/attachment' +import path from 'node:path' import { CliUsageError } from './errors' export const CLI_OUTPUT_ENV = 'DEEPCHAT_CLI_OUTPUT' @@ -40,9 +57,15 @@ export type CliRpcContract = | typeof imagesGenerateRoute | typeof videosGenerateRoute | typeof speechGenerateRoute + | typeof audioTranscribeUploadRoute + | typeof audioTranscribeArtifactRoute + | typeof ocrGetRuntimeStatusRoute + | typeof ocrExtractUploadRoute + | typeof ocrExtractArtifactRoute + | typeof ocrClearCacheRoute | typeof providersListPublicRoute -export type CliCommandOperation = 'rpc' | 'stream' | 'download' +export type CliCommandOperation = 'rpc' | 'stream' | 'upload' | 'download' export type ParsedCliArguments = Readonly<{ domain: string @@ -53,6 +76,8 @@ export type ParsedCliArguments = Readonly<{ helpRequested: boolean operation: CliCommandOperation params: JsonValue + inputPath?: string + uploadMaxBytes?: number outputPath?: string overwrite: boolean readStdin: boolean @@ -70,6 +95,10 @@ const COMMANDS = new Map([ ['image generate', imagesGenerateRoute], ['video generate', videosGenerateRoute], ['audio speak', speechGenerateRoute], + ['audio transcribe', audioTranscribeUploadRoute], + ['ocr status', ocrGetRuntimeStatusRoute], + ['ocr extract', ocrExtractUploadRoute], + ['ocr clear-cache', ocrClearCacheRoute], ['provider list', providersListPublicRoute] ]) @@ -110,6 +139,17 @@ const VALUE_DOMAIN_OPTIONS: Readonly> = { return parsed.data }, out: stringOption, + file: stringOption, + artifact: (value) => { + const parsed = ArtifactIdSchema.safeParse(value) + if (!parsed.success) throw new CliUsageError('--artifact is not a valid artifact identifier') + return parsed.data + }, + mime: (value) => { + const normalized = value.trim().toLowerCase() + if (!normalized) throw new CliUsageError('--mime must not be empty') + return normalized + }, provider: stringOption, model: stringOption, prompt: stringOption, @@ -131,7 +171,10 @@ const VALUE_DOMAIN_OPTIONS: Readonly> = { audio: (value) => parseBoolean(value, '--audio'), voice: stringOption, speed: (value) => parseNumberInRange(value, '--speed', 0.25, 4), - instructions: stringOption + instructions: stringOption, + backend: stringOption, + 'page-count': (value) => + parseNumberInRange(value, '--page-count', 1, PDF_PAGE_COUNT_SANITY_LIMIT, true) } const FLAG_DOMAIN_OPTIONS = new Set(['overwrite', 'stdin', 'enabled-only']) @@ -178,9 +221,51 @@ const COMMAND_DOMAIN_OPTIONS = new Map>([ 'audio speak', new Set(['provider', 'model', 'text', 'stdin', 'voice', 'format', 'speed', 'instructions']) ], + ['audio transcribe', new Set(['provider', 'model', 'file', 'artifact', 'mime'])], + ['ocr extract', new Set(['file', 'artifact', 'mime', 'backend', 'page-count', 'max-tokens'])], + ['ocr status', new Set()], + ['ocr clear-cache', new Set()], ['provider list', new Set(['enabled-only'])] ]) +const AUDIO_MIME_BY_EXTENSION: Readonly> = { + '.aac': 'audio/aac', + '.amr': 'audio/amr', + '.flac': 'audio/flac', + '.m4a': 'audio/mp4', + '.mp3': 'audio/mpeg', + '.mp4': 'audio/mp4', + '.ogg': 'audio/ogg', + '.opus': 'audio/ogg', + '.wav': 'audio/wav', + '.webm': 'audio/webm' +} + +const OCR_MIME_BY_EXTENSION: Readonly> = { + '.bmp': 'image/bmp', + '.gif': 'image/gif', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.pdf': 'application/pdf', + '.png': 'image/png', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.webp': 'image/webp' +} + +function resolveInputMimeType( + filePath: string, + explicitMimeType: string | undefined, + mimeTypesByExtension: Readonly> +): string { + if (explicitMimeType) return explicitMimeType + const inferred = mimeTypesByExtension[path.extname(filePath).toLowerCase()] + if (!inferred) { + throw new CliUsageError('Cannot infer input MIME type; provide --mime ') + } + return inferred +} + function parseOutputMode(value: string | undefined): CliOutputMode { if (value === undefined || value.trim() === '') return 'text' const normalized = value.trim().toLowerCase() @@ -225,7 +310,7 @@ export function parseCliArguments( const commandKey = `${domain} ${verb}` const isHelpCommand = commandKey === 'help commands' - const contract = COMMANDS.get(commandKey) ?? null + let contract = COMMANDS.get(commandKey) ?? null if (!contract && !isHelpCommand) { throw new CliUsageError(`Unknown command: deepchat ${domain} ${verb}`) } @@ -237,7 +322,10 @@ export function parseCliArguments( : commandKey === 'model invoke' || commandKey === 'image generate' || commandKey === 'video generate' || - commandKey === 'audio speak' + commandKey === 'audio speak' || + commandKey === 'audio transcribe' || + commandKey === 'ocr extract' || + commandKey === 'ocr clear-cache' ? DEFAULT_COMPUTE_TIMEOUT_MS : DEFAULT_CLI_TIMEOUT_MS let timeoutSeen = false @@ -332,6 +420,9 @@ export function parseCliArguments( return typeof value === 'boolean' ? value : undefined } const artifactId = getString('id') + const inputArtifactId = getString('artifact') + const inputPath = getString('file') + const mimeType = getString('mime') const outputPath = getString('out') const overwrite = getBoolean('overwrite') ?? false const providerId = getString('provider') @@ -358,6 +449,8 @@ export function parseCliArguments( const voice = getString('voice') const speed = getNumber('speed') const instructions = getString('instructions') + const backend = getString('backend') + const sourcePageCountHint = getNumber('page-count') const isArtifactCommand = domain === 'artifact' if (!helpRequested && isArtifactCommand && !artifactId) { @@ -381,6 +474,8 @@ export function parseCliArguments( const isImageGenerate = commandKey === 'image generate' const isVideoGenerate = commandKey === 'video generate' const isSpeechGenerate = commandKey === 'audio speak' + const isAudioTranscribe = commandKey === 'audio transcribe' + const isOcrExtract = commandKey === 'ocr extract' const isMediaGenerate = isImageGenerate || isVideoGenerate || isSpeechGenerate const isProviderList = commandKey === 'provider list' const allowedDomainOptions = COMMAND_DOMAIN_OPTIONS.get(commandKey) ?? new Set() @@ -411,6 +506,27 @@ export function parseCliArguments( if (!helpRequested && isSpeechGenerate && (textInput !== undefined) === readStdin) { throw new CliUsageError('deepchat audio speak requires exactly one of --text or --stdin') } + if (!helpRequested && isAudioTranscribe && (!providerId || !modelId)) { + throw new CliUsageError('deepchat audio transcribe requires --provider and --model') + } + if ( + !helpRequested && + (isAudioTranscribe || isOcrExtract) && + (inputPath !== undefined) === (inputArtifactId !== undefined) + ) { + throw new CliUsageError( + `deepchat ${domain} ${verb} requires exactly one of --file or --artifact` + ) + } + if (!helpRequested && inputArtifactId && mimeType) { + throw new CliUsageError('--mime is only valid together with --file') + } + if (backend !== undefined && backend !== 'auto' && backend !== 'cpu') { + throw new CliUsageError('--backend must be auto or cpu') + } + if (isOcrExtract && maxTokens !== undefined && maxTokens > ATTACHMENT_PDF_OCR_MAX_TOKENS) { + throw new CliUsageError(`--max-tokens must not exceed ${ATTACHMENT_PDF_OCR_MAX_TOKENS}`) + } let params: JsonValue = artifactId ? { id: artifactId } : {} if (isProviderList) params = { enabledOnly } @@ -473,6 +589,44 @@ export function parseCliArguments( ...(Object.keys(options).length > 0 ? { options } : {}) } } + if (isAudioTranscribe && providerId && modelId && (inputPath || inputArtifactId)) { + if (inputPath) { + contract = audioTranscribeUploadRoute + params = { + providerId, + modelId, + mimeType: resolveInputMimeType(inputPath, mimeType, AUDIO_MIME_BY_EXTENSION), + filename: path.basename(inputPath) + } + } else if (inputArtifactId) { + contract = audioTranscribeArtifactRoute + params = { providerId, modelId, artifactId: inputArtifactId } + } + } + if (isOcrExtract && (inputPath || inputArtifactId)) { + const options = { + ...(backend !== undefined ? { backend } : {}), + ...(sourcePageCountHint !== undefined ? { sourcePageCountHint } : {}), + ...(maxTokens !== undefined ? { generationTokenLimit: maxTokens } : {}) + } + if (inputPath) { + const resolvedMimeType = resolveInputMimeType(inputPath, mimeType, OCR_MIME_BY_EXTENSION) + if ( + resolvedMimeType !== 'application/pdf' && + (sourcePageCountHint !== undefined || maxTokens !== undefined) + ) { + throw new CliUsageError('--page-count and --max-tokens are only valid for PDF input') + } + contract = ocrExtractUploadRoute + params = { + ...options, + mimeType: resolvedMimeType + } + } else if (inputArtifactId) { + contract = ocrExtractArtifactRoute + params = { ...options, artifactId: inputArtifactId } + } + } return { domain, @@ -484,10 +638,17 @@ export function parseCliArguments( operation: commandKey === 'artifact get' ? 'download' - : isModelInvoke || isMediaGenerate - ? 'stream' - : 'rpc', + : inputPath && (isAudioTranscribe || isOcrExtract) + ? 'upload' + : isModelInvoke || isMediaGenerate + ? 'stream' + : 'rpc', params, + ...(inputPath ? { inputPath } : {}), + ...(isAudioTranscribe && inputPath + ? { uploadMaxBytes: AUDIO_TRANSCRIPTION_MAX_INPUT_BYTES } + : {}), + ...(isOcrExtract && inputPath ? { uploadMaxBytes: OCR_EXTRACTION_MAX_INPUT_BYTES } : {}), ...(outputPath ? { outputPath } : {}), overwrite, readStdin @@ -505,11 +666,15 @@ export function formatCliHelp(command?: Pick --model (--prompt |--stdin)' : command.domain === 'image' || command.domain === 'video' ? ' --provider --model (--prompt |--stdin)' - : command.domain === 'audio' + : command.domain === 'audio' && command.verb === 'speak' ? ' --provider --model (--text |--stdin)' - : command.domain === 'provider' - ? ' [--enabled-only]' - : '' + : command.domain === 'audio' + ? ' --provider --model (--file |--artifact )' + : command.domain === 'ocr' && command.verb === 'extract' + ? ' (--file |--artifact )' + : command.domain === 'provider' + ? ' [--enabled-only]' + : '' const commandKey = `${command.domain} ${command.verb}` const optionLines = commandKey === 'model invoke' @@ -544,7 +709,16 @@ export function formatCliHelp(command?: Pick Set playback speed (0.25..4)', ' --instructions Add provider-supported speech guidance' ] - : [] + : commandKey === 'audio transcribe' + ? [' --mime Override the MIME type inferred from --file'] + : commandKey === 'ocr extract' + ? [ + ' --mime Override the MIME type inferred from --file', + ' --backend Select auto or cpu', + ' --page-count Provide a PDF page-count hint', + ' --max-tokens Limit PDF OCR output tokens' + ] + : [] return [ `Usage: deepchat ${command.domain} ${command.verb}${commandOptions} [--json|--jsonl] [--timeout ]`, '', @@ -568,6 +742,10 @@ export function formatCliHelp(command?: Pick Promise + invokeUpload?: (invocation: CliUploadInvocation) => Promise forceExit?: (code: number) => void }> @@ -221,6 +224,16 @@ export async function runCli( CLI_EXIT_CODES.authorization ) } + if ( + parsed.operation === 'upload' && + Object.prototype.hasOwnProperty.call(env, LOCAL_CONTROL_AGENT_TOKEN_ENV) + ) { + throw new CliClientError( + 'permission_denied', + 'Agent callers cannot upload local file bytes or use --file', + CLI_EXIT_CODES.authorization + ) + } const invocationContract = parsed.operation === 'download' ? artifactsDescribeRoute : parsed.contract const invocation: CliRpcInvocation = { @@ -254,10 +267,28 @@ export async function runCli( streamedTextEndsWithNewline = parsedEvent.text.endsWith('\n') } } - const response = - parsed.operation === 'stream' - ? await (dependencies.invokeStream ?? invokeLocalControlStream)(invocation, onStreamEvent) - : await (dependencies.invokeRpc ?? invokeLocalControlRpc)(invocation) + let response: LocalControlRpcResponse + if (parsed.operation === 'stream') { + response = await (dependencies.invokeStream ?? invokeLocalControlStream)( + invocation, + onStreamEvent + ) + } else if (parsed.operation === 'upload') { + if (!parsed.inputPath || !parsed.uploadMaxBytes) { + throw new CliClientError( + 'internal_error', + 'Upload command has no validated input source', + CLI_EXIT_CODES.internal + ) + } + response = await (dependencies.invokeUpload ?? invokeLocalControlUpload)({ + ...invocation, + filePath: parsed.inputPath, + maxBytes: parsed.uploadMaxBytes + }) + } else { + response = await (dependencies.invokeRpc ?? invokeLocalControlRpc)(invocation) + } if (!response.ok) { if (streamedText && !streamedTextEndsWithNewline) stdout.write('\n') diff --git a/src/main/app/composition.ts b/src/main/app/composition.ts index 131566c96..18a875b22 100644 --- a/src/main/app/composition.ts +++ b/src/main/app/composition.ts @@ -209,7 +209,9 @@ import { import { createNodeScheduler } from '@/routes/scheduler' import { ArtifactSpool, + CliAudioTranscriptionService, CliComputeService, + CliOcrService, CliServer, createArtifactRoutes, createCliComputeRoutes, @@ -345,6 +347,8 @@ export async function createMainProcessControl(dependencies: { let acpAsLlmProviderPermission: AcpAsLlmProviderPermissionPort let routeDispatcher: RouteDispatcher | undefined let cliComputeService: CliComputeService + let cliAudioTranscriptionService: CliAudioTranscriptionService + let cliOcrService: CliOcrService let hasInitialized = false let databaseMaintenanceState: 'running' | 'maintenance' | 'failed' = 'running' let appLifecycleState: 'starting' | 'running' | 'stopping' | 'stopped' = 'starting' @@ -373,6 +377,14 @@ export async function createMainProcessControl(dependencies: { userDataPath: app.getPath('userData'), appVersion: app.getVersion(), dispatch: async (method, input, caller, signal) => { + if (cliAudioTranscriptionService?.handlesRpc(method)) { + assertRouteAllowedDuringDatabaseMaintenance(method) + return await cliAudioTranscriptionService.dispatchRpc(method, input, caller, signal) + } + if (cliOcrService?.handlesRpc(method)) { + assertRouteAllowedDuringDatabaseMaintenance(method) + return await cliOcrService.dispatchRpc(method, input, caller, signal) + } if (!routeDispatcher) throw new Error('CLI route dispatcher is not ready') signal.throwIfAborted() const output = await dispatchDeepchatRoute(routeDispatcher, method, input, { caller }) @@ -384,6 +396,22 @@ export async function createMainProcessControl(dependencies: { assertRouteAllowedDuringDatabaseMaintenance(method) return await cliComputeService.dispatchStream(method, input, caller, requestId, signal, emit) }, + dispatchUpload: async (method, input, upload, caller, signal) => { + assertRouteAllowedDuringDatabaseMaintenance(method) + if (cliAudioTranscriptionService?.handlesUpload(method)) { + return await cliAudioTranscriptionService.dispatchUpload( + method, + input, + upload, + caller, + signal + ) + } + if (cliOcrService?.handlesUpload(method)) { + return await cliOcrService.dispatchUpload(method, input, upload, caller, signal) + } + throw new Error(`CLI upload service is not ready for ${method}`) + }, artifactSpool, log: logger }) @@ -642,6 +670,12 @@ export async function createMainProcessControl(dependencies: { mediaCacheDirectory: path.join(app.getPath('userData'), 'images'), log: logger }) + cliAudioTranscriptionService = new CliAudioTranscriptionService({ + providerSettings, + providerRuntime, + artifactSpool, + log: logger + }) const agentDefaults = new DeepChatDefaults({ settings: dependencies.settingsStore, publishSettingChanged: (key, value) => @@ -716,6 +750,12 @@ export async function createMainProcessControl(dependencies: { tempBaseDir: app.getPath('temp'), userDataDir: app.getPath('userData') }) + cliOcrService = new CliOcrService({ + appVersion: app.getVersion(), + ocrRuntime: ocrRuntimeService, + artifactSpool, + log: logger + }) const attachmentRouter = new AttachmentCapabilityRouter({ extraction: ocrRuntimeService, getAutomaticOcrEnabled: () => ocrSettings.getAutomaticExtractionEnabled(), diff --git a/src/main/cli/audioTranscriptionService.ts b/src/main/cli/audioTranscriptionService.ts new file mode 100644 index 000000000..ce2363eb0 --- /dev/null +++ b/src/main/cli/audioTranscriptionService.ts @@ -0,0 +1,242 @@ +import { readFile } from 'node:fs/promises' +import { + AUDIO_TRANSCRIPTION_MAX_INPUT_BYTES, + AUDIO_TRANSCRIPTION_MAX_TEXT_CHARACTERS, + AudioInputMimeTypeSchema, + audioTranscribeArtifactRoute, + audioTranscribeUploadRoute, + type AudioTranscriptionArtifactInput, + type AudioTranscriptionOutput, + type AudioTranscriptionUploadInput +} from '@shared/contracts/routes' +import type { ProviderRuntime } from '@/provider' +import type { ProviderSettingsPort } from '@/provider/settings' +import type { CliRouteCaller } from '@/routes/routeRegistry' +import type { ArtifactSpool } from './artifactSpool' +import { CliRequestError } from './errors' +import type { CliUploadedInputFile } from './server' + +const MAX_ACTIVE_TRANSCRIPTIONS = 2 + +type AudioTranscriptionProviderSettings = Pick< + ProviderSettingsPort, + 'getProviderById' | 'getModelStatus' | 'isKnownModel' +> + +type AudioTranscriptionProviderRuntime = Pick + +export type CliAudioTranscriptionServiceOptions = Readonly<{ + providerSettings: AudioTranscriptionProviderSettings + providerRuntime: AudioTranscriptionProviderRuntime + artifactSpool: ArtifactSpool + now?: () => number + log?: Pick +}> + +function truncateAtCodePoint(value: string, maxCharacters: number): string { + let end = Math.min(value.length, maxCharacters) + const code = value.charCodeAt(end - 1) + if (code >= 0xd800 && code <= 0xdbff) end -= 1 + return value.slice(0, end) +} + +export class CliAudioTranscriptionService { + private readonly now: () => number + private readonly log: Pick + private activeTranscriptions = 0 + + constructor(private readonly options: CliAudioTranscriptionServiceOptions) { + this.now = options.now ?? Date.now + this.log = options.log ?? console + } + + handlesRpc(method: string): boolean { + return method === audioTranscribeArtifactRoute.name + } + + handlesUpload(method: string): boolean { + return method === audioTranscribeUploadRoute.name + } + + async dispatchRpc( + method: string, + rawInput: unknown, + caller: CliRouteCaller, + signal: AbortSignal + ): Promise { + if (!this.handlesRpc(method)) { + throw new CliRequestError('not_found', 'Audio transcription method is not implemented', { + httpStatus: 404 + }) + } + return await this.transcribeArtifact( + audioTranscribeArtifactRoute.input.parse(rawInput), + caller, + signal + ) + } + + async dispatchUpload( + method: string, + rawInput: unknown, + upload: CliUploadedInputFile, + caller: CliRouteCaller, + signal: AbortSignal + ): Promise { + if (caller.principal !== 'human') { + throw new CliRequestError('permission_denied', 'Agent callers cannot upload file bytes', { + httpStatus: 403 + }) + } + if (!this.handlesUpload(method)) { + throw new CliRequestError('not_found', 'Audio upload method is not implemented', { + httpStatus: 404 + }) + } + return await this.transcribeUpload( + audioTranscribeUploadRoute.input.parse(rawInput), + upload, + signal + ) + } + + private async transcribeArtifact( + input: AudioTranscriptionArtifactInput, + caller: CliRouteCaller, + signal: AbortSignal + ): Promise { + return await this.options.artifactSpool.withFile(input.artifactId, caller, async (file) => { + const mimeType = AudioInputMimeTypeSchema.safeParse(file.metadata.mimeType) + if (!mimeType.success) { + throw new CliRequestError('invalid_request', 'Artifact is not an audio input') + } + this.assertInputSize(file.metadata.size, 'Audio artifact') + return await this.transcribeFile( + input, + file.path, + file.metadata.size, + mimeType.data, + file.metadata.filename, + signal + ) + }) + } + + private async transcribeUpload( + input: AudioTranscriptionUploadInput, + upload: CliUploadedInputFile, + signal: AbortSignal + ): Promise { + this.assertInputSize(upload.size, 'Audio upload') + return await this.transcribeFile( + input, + upload.path, + upload.size, + input.mimeType, + input.filename, + signal + ) + } + + private async transcribeFile( + input: Pick, + filePath: string, + inputBytes: number, + mimeType: string, + filename: string | undefined, + signal: AbortSignal + ): Promise { + this.requireAvailableModel(input.providerId, input.modelId) + if (this.activeTranscriptions >= MAX_ACTIVE_TRANSCRIPTIONS) { + throw new CliRequestError('rate_limited', 'Audio transcription capacity is full', { + httpStatus: 429, + retriable: true + }) + } + + this.activeTranscriptions += 1 + const startedAt = this.now() + try { + signal.throwIfAborted() + const bytes = await readFile(filePath, { signal }) + if (bytes.byteLength !== inputBytes) { + throw new CliRequestError('unavailable', 'Audio input changed before transcription', { + httpStatus: 410 + }) + } + signal.throwIfAborted() + const transcript = await this.options.providerRuntime.transcribeAudioStandalone( + input.providerId, + input.modelId, + bytes.toString('base64'), + mimeType, + filename, + { signal } + ) + signal.throwIfAborted() + const normalized = transcript.trim() + const truncated = normalized.length > AUDIO_TRANSCRIPTION_MAX_TEXT_CHARACTERS + return audioTranscribeUploadRoute.output.parse({ + providerId: input.providerId, + modelId: input.modelId, + text: truncated + ? truncateAtCodePoint(normalized, AUDIO_TRANSCRIPTION_MAX_TEXT_CHARACTERS) + : normalized, + truncated, + inputBytes, + mimeType, + durationMs: Math.max(0, this.now() - startedAt) + }) + } catch (error) { + throw this.normalizeError(error, input, signal) + } finally { + this.activeTranscriptions = Math.max(0, this.activeTranscriptions - 1) + } + } + + private assertInputSize(size: number, name: string): void { + if (!Number.isSafeInteger(size) || size <= 0) { + throw new CliRequestError('invalid_request', `${name} is empty or invalid`) + } + if (size > AUDIO_TRANSCRIPTION_MAX_INPUT_BYTES) { + throw new CliRequestError('body_too_large', `${name} exceeds its byte limit`, { + httpStatus: 413 + }) + } + } + + private requireAvailableModel(providerId: string, modelId: string): void { + const provider = this.options.providerSettings.getProviderById(providerId) + if (!provider?.enable) { + throw new CliRequestError('not_found', 'Provider is not available', { httpStatus: 404 }) + } + if (!this.options.providerSettings.isKnownModel(providerId, modelId)) { + throw new CliRequestError('not_found', 'Model is not available', { httpStatus: 404 }) + } + if (!this.options.providerSettings.getModelStatus(providerId, modelId)) { + throw new CliRequestError('conflict', 'Model is disabled', { httpStatus: 409 }) + } + } + + private normalizeError( + error: unknown, + input: Pick, + signal: AbortSignal + ): CliRequestError { + if (error instanceof CliRequestError) return error + if (signal.aborted || (error instanceof Error && error.name === 'AbortError')) { + return new CliRequestError('cancelled', 'Audio transcription was cancelled', { + retriable: true + }) + } + this.log.warn('[CLI] Audio transcription failed', { + providerId: input.providerId, + modelId: input.modelId, + failure: { name: error instanceof Error ? error.name : typeof error } + }) + return new CliRequestError('unavailable', 'Audio transcription failed', { + httpStatus: 503, + retriable: true + }) + } +} diff --git a/src/main/cli/index.ts b/src/main/cli/index.ts index e84c90eb2..3c2df8c01 100644 --- a/src/main/cli/index.ts +++ b/src/main/cli/index.ts @@ -2,5 +2,10 @@ export { CliServer, type CliServerDependencies } from './server' export { ArtifactSpool, type ArtifactSpoolOptions } from './artifactSpool' export { createArtifactRoutes } from './artifactRoutes' export { CliComputeService, createCliComputeRoutes } from './computeService' +export { + CliAudioTranscriptionService, + type CliAudioTranscriptionServiceOptions +} from './audioTranscriptionService' +export { CliOcrService, type CliOcrServiceOptions } from './ocrService' export { createCliRoutes, type CliRuntimeStatus } from './routes' export { CLI_SURFACE_V1, getCliSurfaceEntry, listCliSurfaceCapabilities } from './surface' diff --git a/src/main/cli/ocrService.ts b/src/main/cli/ocrService.ts new file mode 100644 index 000000000..d13350e06 --- /dev/null +++ b/src/main/cli/ocrService.ts @@ -0,0 +1,395 @@ +import { open } from 'node:fs/promises' +import { + OCR_EXTRACTION_MAX_INPUT_BYTES, + OcrInputMimeTypeSchema, + ocrClearCacheRoute, + ocrExtractArtifactRoute, + ocrExtractUploadRoute, + ocrGetRuntimeStatusRoute, + type OcrExtractArtifactInput, + type OcrExtractionOutput, + type OcrExtractUploadInput +} from '@shared/contracts/routes' +import { + LOCAL_CONTROL_PROTOCOL_VERSION, + LOCAL_CONTROL_SURFACE_VERSION +} from '@shared/contracts/localControl' +import { + DocumentTextExtractionError, + type DocumentTextExtractionResult +} from '@/ocr/documentTextExtractionService' +import { + ImageTextExtractionError, + type ImageTextExtractionResult +} from '@/ocr/imageTextExtractionService' +import { ImagePreprocessingError, sniffOcrImageMimeType } from '@/ocr/imagePreprocessor' +import { + OcrRuntimeBusyError, + type OcrRuntimeService, + type OcrRuntimeServiceStatus +} from '@/ocr/ocrRuntimeService' +import { toPublicOcrEngine, toPublicOcrStatus } from '@/ocr/routes' +import type { CliRouteCaller } from '@/routes/routeRegistry' +import type { ArtifactSpool } from './artifactSpool' +import { CliRequestError } from './errors' +import type { CliUploadedInputFile } from './server' + +const OCR_SIGNATURE_BYTES = 1_024 + +type CliOcrRuntime = Pick< + OcrRuntimeService, + 'clearCache' | 'extract' | 'extractDocument' | 'getStatus' +> + +export type CliOcrServiceOptions = Readonly<{ + appVersion: string + ocrRuntime: CliOcrRuntime + artifactSpool: ArtifactSpool + now?: () => number + log?: Pick +}> + +export class CliOcrService { + private readonly now: () => number + private readonly log: Pick + + constructor(private readonly options: CliOcrServiceOptions) { + this.now = options.now ?? Date.now + this.log = options.log ?? console + } + + handlesRpc(method: string): boolean { + return ( + method === ocrGetRuntimeStatusRoute.name || + method === ocrExtractArtifactRoute.name || + method === ocrClearCacheRoute.name + ) + } + + handlesUpload(method: string): boolean { + return method === ocrExtractUploadRoute.name + } + + async dispatchRpc( + method: string, + rawInput: unknown, + caller: CliRouteCaller, + signal: AbortSignal + ): Promise { + switch (method) { + case ocrExtractArtifactRoute.name: + return await this.extractArtifact( + ocrExtractArtifactRoute.input.parse(rawInput), + caller, + signal + ) + case ocrGetRuntimeStatusRoute.name: + ocrGetRuntimeStatusRoute.input.parse(rawInput) + signal.throwIfAborted() + return ocrGetRuntimeStatusRoute.output.parse( + toPublicOcrStatus( + await this.options.ocrRuntime.getStatus(), + process.platform, + process.arch + ) + ) + case ocrClearCacheRoute.name: + ocrClearCacheRoute.input.parse(rawInput) + if (caller.principal !== 'human') { + throw new CliRequestError('permission_denied', 'Agent callers cannot clear OCR cache', { + httpStatus: 403 + }) + } + signal.throwIfAborted() + try { + await this.options.ocrRuntime.clearCache() + } catch (error) { + if (error instanceof OcrRuntimeBusyError) { + throw new CliRequestError('conflict', error.message, { httpStatus: 409 }) + } + this.log.warn('[CLI] OCR cache clear failed', { + failure: { name: error instanceof Error ? error.name : typeof error } + }) + throw new CliRequestError('unavailable', 'OCR cache could not be cleared', { + httpStatus: 503, + retriable: true + }) + } + const status = await this.options.ocrRuntime.getStatus() + if (!status.cache) { + throw new CliRequestError('internal_error', 'OCR cache status is unavailable', { + httpStatus: 500 + }) + } + return ocrClearCacheRoute.output.parse({ cache: status.cache }) + default: + throw new CliRequestError('not_found', 'OCR method is not implemented', { + httpStatus: 404 + }) + } + } + + async dispatchUpload( + method: string, + rawInput: unknown, + upload: CliUploadedInputFile, + caller: CliRouteCaller, + signal: AbortSignal + ): Promise { + if (caller.principal !== 'human') { + throw new CliRequestError('permission_denied', 'Agent callers cannot upload file bytes', { + httpStatus: 403 + }) + } + switch (method) { + case ocrExtractUploadRoute.name: + return await this.extractUpload(ocrExtractUploadRoute.input.parse(rawInput), upload, signal) + default: + throw new CliRequestError('not_found', 'Upload method is not implemented', { + httpStatus: 404 + }) + } + } + + private async extractArtifact( + input: OcrExtractArtifactInput, + caller: CliRouteCaller, + signal: AbortSignal + ): Promise { + return await this.options.artifactSpool.withFile(input.artifactId, caller, async (file) => { + const mimeType = OcrInputMimeTypeSchema.safeParse(file.metadata.mimeType) + if (!mimeType.success) { + throw new CliRequestError('invalid_request', 'Artifact is not a supported OCR input') + } + this.assertInputSize(file.metadata.size, 'OCR artifact') + return await this.extractFile(input, file.path, file.metadata.size, mimeType.data, signal) + }) + } + + private async extractUpload( + input: OcrExtractUploadInput, + upload: CliUploadedInputFile, + signal: AbortSignal + ): Promise { + this.assertInputSize(upload.size, 'OCR upload') + return await this.extractFile(input, upload.path, upload.size, input.mimeType, signal) + } + + private async extractFile( + input: Pick, + filePath: string, + inputBytes: number, + declaredMimeType: string, + signal: AbortSignal + ): Promise { + const startedAt = this.now() + try { + signal.throwIfAborted() + const statusBefore = await this.options.ocrRuntime.getStatus() + if (statusBefore.availability.status === 'unavailable') { + throw new CliRequestError('unavailable', 'OCR runtime is unavailable', { + httpStatus: 503, + details: { reason: statusBefore.availability.reason } + }) + } + const kind = await this.inspectOcrInput(filePath, inputBytes, declaredMimeType) + signal.throwIfAborted() + if (kind === 'image') { + if (input.sourcePageCountHint !== undefined || input.generationTokenLimit !== undefined) { + throw new CliRequestError( + 'invalid_request', + 'PDF-specific OCR options cannot be used with an image' + ) + } + const result = await this.options.ocrRuntime.extract({ + filePath, + maxFileSize: inputBytes, + backend: input.backend, + priority: 'background', + signal + }) + return this.toImageOutput(result, statusBefore, inputBytes, startedAt) + } + + const result = await this.options.ocrRuntime.extractDocument({ + filePath, + maxFileSize: inputBytes, + backend: input.backend, + ...(input.sourcePageCountHint !== undefined + ? { sourcePageCountHint: input.sourcePageCountHint } + : {}), + ...(input.generationTokenLimit !== undefined + ? { generationTokenLimit: input.generationTokenLimit } + : {}), + priority: 'background', + signal + }) + return this.toDocumentOutput(result, statusBefore, inputBytes, startedAt) + } catch (error) { + throw this.normalizeOcrError(error, signal) + } + } + + private async inspectOcrInput( + filePath: string, + inputBytes: number, + declaredMimeType: string + ): Promise<'image' | 'document'> { + const handle = await open(filePath, 'r') + try { + const fileStat = await handle.stat() + if (!fileStat.isFile() || fileStat.size !== inputBytes) { + throw new CliRequestError('unavailable', 'OCR input changed before extraction', { + httpStatus: 410 + }) + } + const prefix = Buffer.allocUnsafe(Math.min(OCR_SIGNATURE_BYTES, inputBytes)) + const { bytesRead } = await handle.read(prefix, 0, prefix.byteLength, 0) + const signature = prefix.subarray(0, bytesRead) + if (declaredMimeType === 'application/pdf') { + if (signature.indexOf('%PDF-') < 0) { + throw new CliRequestError('invalid_request', 'OCR input is not a valid PDF file') + } + return 'document' + } + + const detected = sniffOcrImageMimeType(signature) + if (detected !== declaredMimeType) { + throw new CliRequestError( + 'invalid_request', + 'OCR image signature does not match its declared MIME type' + ) + } + return 'image' + } finally { + await handle.close() + } + } + + private toImageOutput( + result: ImageTextExtractionResult, + statusBefore: OcrRuntimeServiceStatus, + inputBytes: number, + startedAt: number + ): OcrExtractionOutput { + return ocrExtractUploadRoute.output.parse({ + kind: 'image', + text: result.text, + tokenCount: result.tokenCount, + truncated: result.truncated, + mimeType: result.mimeType, + imageWidth: result.imageWidth, + imageHeight: result.imageHeight, + strategy: result.strategy, + engine: toPublicOcrEngine(result.engine), + cacheHit: result.cacheHit, + timingMs: result.timingMs, + benchmark: this.createOcrBenchmark(result.cacheHit, statusBefore, inputBytes, startedAt) + }) + } + + private toDocumentOutput( + result: DocumentTextExtractionResult, + statusBefore: OcrRuntimeServiceStatus, + inputBytes: number, + startedAt: number + ): OcrExtractionOutput { + return ocrExtractUploadRoute.output.parse({ + kind: 'document', + text: result.text, + tokenCount: result.tokenCount, + truncated: + result.generationOutputLimitReached || result.artifactTermination !== 'request_complete', + mimeType: 'application/pdf', + pageSpans: result.pageSpans, + artifactTermination: result.artifactTermination, + generationOutputLimitReached: result.generationOutputLimitReached, + generationTokenLimit: result.generationTokenLimit, + emittedPages: result.emittedPages, + ...(result.sourcePageCountHint !== undefined + ? { sourcePageCountHint: result.sourcePageCountHint } + : {}), + ...(result.resourceLimit + ? { + resourceLimit: { + code: result.resourceLimit.code, + message: 'OCR document processing reached a resource limit' + } + } + : {}), + engine: toPublicOcrEngine(result.engine), + cacheHit: result.cacheHit, + timingMs: result.timingMs, + benchmark: this.createOcrBenchmark(result.cacheHit, statusBefore, inputBytes, startedAt) + }) + } + + private createOcrBenchmark( + cacheHit: boolean, + statusBefore: OcrRuntimeServiceStatus, + inputBytes: number, + startedAt: number + ) { + const stateBefore = statusBefore.process?.state + return { + state: cacheHit + ? ('hit' as const) + : stateBefore === 'ready' || stateBefore === 'busy' + ? ('miss-warm' as const) + : ('cold-runtime' as const), + runtimeStateBefore: stateBefore ?? ('not-started' as const), + runtimeWasReady: stateBefore === 'ready', + inputBytes, + durationMs: Math.max(0, this.now() - startedAt), + appVersion: this.options.appVersion, + protocolVersion: LOCAL_CONTROL_PROTOCOL_VERSION, + surfaceVersion: LOCAL_CONTROL_SURFACE_VERSION + } + } + + private assertInputSize(size: number, name: string): void { + if (!Number.isSafeInteger(size) || size <= 0) { + throw new CliRequestError('invalid_request', `${name} is empty or invalid`) + } + if (size > OCR_EXTRACTION_MAX_INPUT_BYTES) { + throw new CliRequestError('body_too_large', `${name} exceeds its byte limit`, { + httpStatus: 413 + }) + } + } + + private normalizeOcrError(error: unknown, signal: AbortSignal): CliRequestError { + if (error instanceof CliRequestError) return error + if ( + signal.aborted || + (error instanceof ImagePreprocessingError && error.code === 'cancelled') || + (error instanceof ImageTextExtractionError && error.code === 'cancelled') || + (error instanceof DocumentTextExtractionError && error.code === 'cancelled') + ) { + return new CliRequestError('cancelled', 'OCR extraction was cancelled', { retriable: true }) + } + if ( + (error instanceof ImageTextExtractionError && error.code === 'queue_full') || + (error instanceof DocumentTextExtractionError && error.code === 'queue_full') + ) { + return new CliRequestError('rate_limited', 'OCR extraction capacity is full', { + httpStatus: 429, + retriable: true + }) + } + if ( + error instanceof ImagePreprocessingError || + (error instanceof DocumentTextExtractionError && + ['empty_input', 'input_too_large', 'invalid_input'].includes(error.code)) + ) { + return new CliRequestError('invalid_request', 'OCR input is invalid or unsupported') + } + this.log.warn('[CLI] OCR extraction failed', { + failure: { name: error instanceof Error ? error.name : typeof error } + }) + return new CliRequestError('unavailable', 'OCR extraction failed', { + httpStatus: 503, + retriable: true + }) + } +} diff --git a/src/main/cli/server.ts b/src/main/cli/server.ts index ced41492e..52a2902cd 100644 --- a/src/main/cli/server.ts +++ b/src/main/cli/server.ts @@ -218,7 +218,10 @@ async function runAbortable(signal: AbortSignal, action: () => Promise): P signal.addEventListener('abort', onAbort, { once: true }) void Promise.resolve() - .then(action) + .then(() => { + if (signal.aborted) throw requestAbortError(signal) + return action() + }) .then( (value) => finish(() => resolve(value)), (error: unknown) => finish(() => reject(error)) @@ -665,12 +668,14 @@ export class CliServer { httpStatus: 500 }) } - rawOutput = await dispatchUpload( - entry.contract.name, - input, - { path: uploadBody.path, size: uploadBody.size }, - caller, - controller.signal + rawOutput = await runAbortable(controller.signal, async () => + dispatchUpload( + entry.contract.name, + input, + { path: uploadBody.path, size: uploadBody.size }, + caller, + controller.signal + ) ) } finally { await uploadBody.cleanup() diff --git a/src/main/cli/surface.ts b/src/main/cli/surface.ts index 69a9ded74..1c83ff4a5 100644 --- a/src/main/cli/surface.ts +++ b/src/main/cli/surface.ts @@ -1,14 +1,22 @@ import type { RouteContract } from '@shared/contracts/contract' import { + AUDIO_TRANSCRIPTION_MAX_INPUT_BYTES, + OCR_EXTRACTION_MAX_INPUT_BYTES, artifactsDeleteRoute, artifactsDescribeRoute, artifactsReadRoute, + audioTranscribeArtifactRoute, + audioTranscribeUploadRoute, cliCapabilitiesRoute, cliDoctorRoute, cliStatusRoute, cliVersionRoute, imagesGenerateRoute, modelsInvokeRoute, + ocrClearCacheRoute, + ocrExtractArtifactRoute, + ocrExtractUploadRoute, + ocrGetRuntimeStatusRoute, providersListPublicRoute, speechGenerateRoute, videosGenerateRoute, @@ -80,6 +88,75 @@ const CLI_SURFACE_V1_ENTRIES = [ mediaEntry(imagesGenerateRoute), mediaEntry(videosGenerateRoute), mediaEntry(speechGenerateRoute), + { + contract: audioTranscribeUploadRoute, + effect: 'compute', + callers: ['human'], + scopes: ['audio:transcribe'], + transport: 'upload', + approval: 'never', + limits: { + maxBodyBytes: AUDIO_TRANSCRIPTION_MAX_INPUT_BYTES, + timeoutMs: LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS + } + }, + { + contract: audioTranscribeArtifactRoute, + effect: 'compute', + callers: ['human', 'agent'], + scopes: ['audio:transcribe', 'artifacts:read'], + transport: 'rpc', + approval: 'never', + limits: { + maxBodyBytes: 16 * 1024, + timeoutMs: LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS + } + }, + { + contract: ocrGetRuntimeStatusRoute, + effect: 'read', + callers: ['human', 'agent'], + scopes: ['ocr:read'], + transport: 'rpc', + approval: 'never', + limits: DIAGNOSTIC_LIMITS + }, + { + contract: ocrExtractUploadRoute, + effect: 'compute', + callers: ['human'], + scopes: ['ocr:extract'], + transport: 'upload', + approval: 'never', + limits: { + maxBodyBytes: OCR_EXTRACTION_MAX_INPUT_BYTES, + timeoutMs: LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS + } + }, + { + contract: ocrExtractArtifactRoute, + effect: 'compute', + callers: ['human', 'agent'], + scopes: ['ocr:extract', 'artifacts:read'], + transport: 'rpc', + approval: 'never', + limits: { + maxBodyBytes: 16 * 1024, + timeoutMs: LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS + } + }, + { + contract: ocrClearCacheRoute, + effect: 'local-maintenance', + callers: ['human'], + scopes: ['ocr:manage'], + transport: 'rpc', + approval: 'never', + limits: { + maxBodyBytes: 16 * 1024, + timeoutMs: LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS + } + }, { contract: providersListPublicRoute, effect: 'read', diff --git a/src/main/ocr/ocrRuntimeService.ts b/src/main/ocr/ocrRuntimeService.ts index 5a1adf422..054210dee 100644 --- a/src/main/ocr/ocrRuntimeService.ts +++ b/src/main/ocr/ocrRuntimeService.ts @@ -45,6 +45,13 @@ interface RuntimeResources { documentExtraction: DocumentTextExtractionService } +export class OcrRuntimeBusyError extends Error { + constructor() { + super('OCR cache cannot be cleared while extraction is active') + this.name = 'OcrRuntimeBusyError' + } +} + /** Lazily owns the offline OCR helper, engine, and derived cache for the application lifetime. */ export class OcrRuntimeService { private readonly resolver: OcrRuntimeAssetResolver @@ -108,7 +115,7 @@ export class OcrRuntimeService { processStatus.state === 'busy' || processStatus.state === 'stopping' ) { - throw new Error('OCR cache cannot be cleared while extraction is active') + throw new OcrRuntimeBusyError() } await resources.store.clear() } diff --git a/src/main/ocr/routes.ts b/src/main/ocr/routes.ts index d1cc6da63..0336d0d1c 100644 --- a/src/main/ocr/routes.ts +++ b/src/main/ocr/routes.ts @@ -1,6 +1,7 @@ import { ocrClearCacheRoute, ocrGetRuntimeStatusRoute } from '@shared/contracts/routes' -import type { OcrRuntimeStatus } from '@shared/contracts/routes/ocr.routes' +import type { OcrEngine, OcrRuntimeStatus } from '@shared/contracts/routes/ocr.routes' import { createRouteMap, type DeepchatRouteMap } from '@/routes/routeRegistry' +import type { LightOcrEngineStatus } from './lightOcrProtocol' import type { OcrRuntimeService, OcrRuntimeServiceStatus } from './ocrRuntimeService' export function createOcrRoutes(deps: { @@ -9,7 +10,7 @@ export function createOcrRoutes(deps: { arch?: string }): DeepchatRouteMap { const getStatus = async (): Promise => - toPublicStatus( + toPublicOcrStatus( await deps.runtime.getStatus(), deps.platform ?? process.platform, deps.arch ?? process.arch @@ -36,7 +37,7 @@ export function createOcrRoutes(deps: { ]) } -function toPublicStatus( +export function toPublicOcrStatus( status: OcrRuntimeServiceStatus, platform: string, arch: string @@ -60,24 +61,26 @@ function toPublicStatus( nodeVersion: status.process.nodeVersion, queuedRequests: status.process.queuedRequests, pendingInputBytes: status.process.pendingInputBytes, - engine: status.process.engine - ? { - coreVersion: status.process.engine.coreVersion, - modelBundleId: status.process.engine.modelBundleId, - requestedBackend: status.process.engine.requestedProvider, - strategy: status.process.engine.strategy, - detection: { - providerChain: status.process.engine.detection.actualProviderChain, - precision: status.process.engine.detection.precision - }, - recognition: { - providerChain: status.process.engine.recognition.actualProviderChain, - precision: status.process.engine.recognition.precision - } - } - : null + engine: status.process.engine ? toPublicOcrEngine(status.process.engine) : null } : null, cache: status.cache } } + +export function toPublicOcrEngine(engine: LightOcrEngineStatus): OcrEngine { + return { + coreVersion: engine.coreVersion, + modelBundleId: engine.modelBundleId, + requestedBackend: engine.requestedProvider, + strategy: engine.strategy, + detection: { + providerChain: [...engine.detection.actualProviderChain], + precision: engine.detection.precision + }, + recognition: { + providerChain: [...engine.recognition.actualProviderChain], + precision: engine.recognition.precision + } + } +} diff --git a/src/shared/contracts/localControl.ts b/src/shared/contracts/localControl.ts index 466257cf8..ef2ca7b34 100644 --- a/src/shared/contracts/localControl.ts +++ b/src/shared/contracts/localControl.ts @@ -37,6 +37,7 @@ export const LOCAL_CONTROL_SCOPES = [ 'audio:transcribe', 'ocr:read', 'ocr:extract', + 'ocr:manage', 'sessions:run', 'runs:read', 'runs:cancel', diff --git a/src/shared/contracts/routes.ts b/src/shared/contracts/routes.ts index 5eda1743d..906340978 100644 --- a/src/shared/contracts/routes.ts +++ b/src/shared/contracts/routes.ts @@ -5,6 +5,7 @@ import { artifactsDescribeRoute, artifactsReadRoute } from './routes/artifacts.routes' +import { audioTranscribeArtifactRoute, audioTranscribeUploadRoute } from './routes/audio.routes' import { acpTerminalInputRoute, acpTerminalKillRoute } from './routes/acp-terminal.routes' import { browserAttachCurrentWindowRoute, @@ -287,7 +288,12 @@ import { notificationAcknowledgePresentationRoute, notificationRendererReadyRoute } from './routes/notification.routes' -import { ocrClearCacheRoute, ocrGetRuntimeStatusRoute } from './routes/ocr.routes' +import { + ocrClearCacheRoute, + ocrExtractArtifactRoute, + ocrExtractUploadRoute, + ocrGetRuntimeStatusRoute +} from './routes/ocr.routes' import { onboardingCompleteRoute, onboardingGetStateRoute, @@ -573,6 +579,7 @@ import { export * from './routes/browser.routes' export * from './routes/artifacts.routes' +export * from './routes/audio.routes' export * from './routes/computerUse.routes' export * from './routes/acp-terminal.routes' export * from './routes/chat.routes' @@ -941,6 +948,8 @@ const DEEPCHAT_ROUTE_CATALOG_PART_4 = { [imagesGenerateRoute.name]: imagesGenerateRoute, [videosGenerateRoute.name]: videosGenerateRoute, [speechGenerateRoute.name]: speechGenerateRoute, + [audioTranscribeUploadRoute.name]: audioTranscribeUploadRoute, + [audioTranscribeArtifactRoute.name]: audioTranscribeArtifactRoute, [modelsListRuntimeRoute.name]: modelsListRuntimeRoute, [modelsSetBatchStatusRoute.name]: modelsSetBatchStatusRoute, [modelsSetStatusRoute.name]: modelsSetStatusRoute, @@ -1010,6 +1019,8 @@ const DEEPCHAT_ROUTE_CATALOG_PART_5 = { [memoryDeleteDirectiveRoute.name]: memoryDeleteDirectiveRoute, [ocrGetRuntimeStatusRoute.name]: ocrGetRuntimeStatusRoute, [ocrClearCacheRoute.name]: ocrClearCacheRoute, + [ocrExtractUploadRoute.name]: ocrExtractUploadRoute, + [ocrExtractArtifactRoute.name]: ocrExtractArtifactRoute, [skillsListMetadataRoute.name]: skillsListMetadataRoute, [skillsListCatalogRoute.name]: skillsListCatalogRoute, [skillsGetDirectoryRoute.name]: skillsGetDirectoryRoute, diff --git a/src/shared/contracts/routes/audio.routes.ts b/src/shared/contracts/routes/audio.routes.ts new file mode 100644 index 000000000..1d4423c32 --- /dev/null +++ b/src/shared/contracts/routes/audio.routes.ts @@ -0,0 +1,76 @@ +import { z } from 'zod' +import { EntityIdSchema, defineRouteContract } from '../common' +import { ArtifactIdSchema } from './artifacts.routes' + +export const AUDIO_TRANSCRIPTION_MAX_INPUT_BYTES = 25 * 1024 * 1024 +export const AUDIO_TRANSCRIPTION_MAX_TEXT_CHARACTERS = 1_000_000 + +export const AudioInputMimeTypeSchema = z + .string() + .trim() + .min(7) + .max(255) + .transform((value) => value.toLowerCase()) + .refine( + (value) => + /^audio\/[a-z0-9!#$&^_.+-]+(?:;\s*[a-z0-9!#$&^_.+-]+=[a-z0-9!#$&^_.+-]+)*$/.test(value), + { message: 'Invalid audio MIME type' } + ) + +export const AudioInputFilenameSchema = z + .string() + .trim() + .min(1) + .max(255) + .refine((value) => value !== '.' && value !== '..' && !/[\\/\p{Cc}]/u.test(value), { + message: 'Invalid input filename' + }) + +export const AudioTranscriptionUploadInputSchema = z + .object({ + providerId: EntityIdSchema.max(128), + modelId: z.string().min(1).max(256), + mimeType: AudioInputMimeTypeSchema, + filename: AudioInputFilenameSchema.optional() + }) + .strict() + +export const AudioTranscriptionArtifactInputSchema = z + .object({ + providerId: EntityIdSchema.max(128), + modelId: z.string().min(1).max(256), + artifactId: ArtifactIdSchema + }) + .strict() + +export const AudioTranscriptionOutputSchema = z + .object({ + providerId: EntityIdSchema.max(128), + modelId: z.string().min(1).max(256), + text: z.string().max(AUDIO_TRANSCRIPTION_MAX_TEXT_CHARACTERS), + truncated: z.boolean(), + inputBytes: z.number().int().positive().max(AUDIO_TRANSCRIPTION_MAX_INPUT_BYTES), + mimeType: AudioInputMimeTypeSchema, + durationMs: z + .number() + .finite() + .nonnegative() + .max(24 * 60 * 60_000) + }) + .strict() + +export const audioTranscribeUploadRoute = defineRouteContract({ + name: 'audio.transcribeUpload', + input: AudioTranscriptionUploadInputSchema, + output: AudioTranscriptionOutputSchema +}) + +export const audioTranscribeArtifactRoute = defineRouteContract({ + name: 'audio.transcribeArtifact', + input: AudioTranscriptionArtifactInputSchema, + output: AudioTranscriptionOutputSchema +}) + +export type AudioTranscriptionUploadInput = z.infer +export type AudioTranscriptionArtifactInput = z.infer +export type AudioTranscriptionOutput = z.infer diff --git a/src/shared/contracts/routes/ocr.routes.ts b/src/shared/contracts/routes/ocr.routes.ts index d83f57f1e..505499767 100644 --- a/src/shared/contracts/routes/ocr.routes.ts +++ b/src/shared/contracts/routes/ocr.routes.ts @@ -1,5 +1,14 @@ import { z } from 'zod' import { defineRouteContract } from '../common' +import { + ATTACHMENT_OCR_MAX_TEXT_CHARACTERS, + ATTACHMENT_PDF_OCR_MAX_PAGE_SPANS, + ATTACHMENT_PDF_OCR_MAX_TOKENS, + PDF_PAGE_COUNT_SANITY_LIMIT +} from '../../types/attachment' +import { ArtifactIdSchema } from './artifacts.routes' + +export const OCR_EXTRACTION_MAX_INPUT_BYTES = 50 * 1024 * 1024 export const OcrBackendSchema = z.enum(['auto', 'cpu']) export const OcrRecognitionStrategySchema = z.enum(['bounded-960', 'tiled-v1']) @@ -25,19 +34,23 @@ const OcrAvailabilitySchema = z.discriminatedUnion('status', [ }) ]) -const OcrEngineStageSchema = z.object({ - providerChain: z.array(z.string()), - precision: z.string() -}) +const OcrEngineStageSchema = z + .object({ + providerChain: z.array(z.string().min(1).max(256)).max(16), + precision: z.string().min(1).max(128) + }) + .strict() -const OcrEngineSchema = z.object({ - coreVersion: z.string(), - modelBundleId: z.string(), - requestedBackend: OcrBackendSchema, - strategy: OcrRecognitionStrategySchema, - detection: OcrEngineStageSchema, - recognition: OcrEngineStageSchema -}) +export const OcrEngineSchema = z + .object({ + coreVersion: z.string().min(1).max(128), + modelBundleId: z.string().min(1).max(256), + requestedBackend: OcrBackendSchema, + strategy: OcrRecognitionStrategySchema, + detection: OcrEngineStageSchema, + recognition: OcrEngineStageSchema + }) + .strict() const OcrProcessSchema = z.object({ state: z.enum(['idle', 'starting', 'ready', 'busy', 'stopping', 'closed']), @@ -77,4 +90,165 @@ export const ocrClearCacheRoute = defineRouteContract({ }) }) +export const OcrInputMimeTypeSchema = z.enum([ + 'application/pdf', + 'image/bmp', + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/tiff', + 'image/webp' +]) + +const OcrExtractionOptionsSchema = z + .object({ + backend: OcrBackendSchema.default('auto'), + sourcePageCountHint: z.number().int().positive().max(PDF_PAGE_COUNT_SANITY_LIMIT).optional(), + generationTokenLimit: z.number().int().positive().max(ATTACHMENT_PDF_OCR_MAX_TOKENS).optional() + }) + .strict() + +export const OcrExtractUploadInputSchema = OcrExtractionOptionsSchema.extend({ + mimeType: OcrInputMimeTypeSchema +}).strict() + +export const OcrExtractArtifactInputSchema = OcrExtractionOptionsSchema.extend({ + artifactId: ArtifactIdSchema +}).strict() + +const OcrPublicTimingSchema = z + .number() + .finite() + .nonnegative() + .max(24 * 60 * 60_000) + +export const OcrBenchmarkSchema = z + .object({ + state: z.enum(['hit', 'miss-warm', 'cold-runtime']), + runtimeStateBefore: z.enum([ + 'not-started', + 'idle', + 'starting', + 'ready', + 'busy', + 'stopping', + 'closed' + ]), + runtimeWasReady: z.boolean(), + inputBytes: z.number().int().positive().max(OCR_EXTRACTION_MAX_INPUT_BYTES), + durationMs: OcrPublicTimingSchema, + appVersion: z.string().min(1).max(128), + protocolVersion: z.literal(1), + surfaceVersion: z.literal(1) + }) + .strict() + .superRefine((benchmark, context) => { + if (benchmark.runtimeWasReady !== (benchmark.runtimeStateBefore === 'ready')) { + context.addIssue({ + code: 'custom', + message: 'OCR runtime readiness does not match its pre-extraction state', + path: ['runtimeWasReady'] + }) + } + const warmRuntime = + benchmark.runtimeStateBefore === 'ready' || benchmark.runtimeStateBefore === 'busy' + if ( + (benchmark.state === 'miss-warm' && !warmRuntime) || + (benchmark.state === 'cold-runtime' && warmRuntime) + ) { + context.addIssue({ + code: 'custom', + message: 'OCR benchmark classification does not match its pre-extraction state', + path: ['state'] + }) + } + }) + +const OcrExtractionCommonSchema = z.object({ + text: z.string().max(ATTACHMENT_OCR_MAX_TEXT_CHARACTERS), + tokenCount: z.number().int().nonnegative().max(ATTACHMENT_PDF_OCR_MAX_TOKENS), + truncated: z.boolean(), + engine: OcrEngineSchema, + cacheHit: z.boolean(), + benchmark: OcrBenchmarkSchema +}) + +const OcrImageExtractionOutputSchema = OcrExtractionCommonSchema.extend({ + kind: z.literal('image'), + mimeType: OcrInputMimeTypeSchema.exclude(['application/pdf']), + imageWidth: z.number().int().positive().max(16_384), + imageHeight: z.number().int().positive().max(16_384), + strategy: OcrRecognitionStrategySchema, + timingMs: z + .object({ + snapshot: OcrPublicTimingSchema, + preprocessing: OcrPublicTimingSchema, + recognition: OcrPublicTimingSchema, + total: OcrPublicTimingSchema + }) + .strict() +}).strict() + +const OcrDocumentPageSpanSchema = z + .object({ + pageNumber: z.number().int().positive().max(PDF_PAGE_COUNT_SANITY_LIMIT), + start: z.number().int().nonnegative().max(ATTACHMENT_OCR_MAX_TEXT_CHARACTERS), + end: z.number().int().nonnegative().max(ATTACHMENT_OCR_MAX_TEXT_CHARACTERS), + complete: z.boolean() + }) + .strict() + +const OcrDocumentExtractionOutputSchema = OcrExtractionCommonSchema.extend({ + kind: z.literal('document'), + mimeType: z.literal('application/pdf'), + pageSpans: z.array(OcrDocumentPageSpanSchema).max(ATTACHMENT_PDF_OCR_MAX_PAGE_SPANS), + artifactTermination: z.enum(['request_complete', 'stopped_by_output_limit', 'resource_limited']), + generationOutputLimitReached: z.boolean(), + generationTokenLimit: z.number().int().positive().max(ATTACHMENT_PDF_OCR_MAX_TOKENS), + emittedPages: z.number().int().nonnegative().max(ATTACHMENT_PDF_OCR_MAX_PAGE_SPANS), + sourcePageCountHint: z.number().int().positive().max(PDF_PAGE_COUNT_SANITY_LIMIT).optional(), + resourceLimit: z + .object({ + code: z.literal('resource_limit_exceeded'), + message: z.string().max(2_048) + }) + .strict() + .optional(), + timingMs: z + .object({ + snapshot: OcrPublicTimingSchema, + recognition: OcrPublicTimingSchema, + total: OcrPublicTimingSchema + }) + .strict() +}).strict() + +export const OcrExtractionOutputSchema = z + .discriminatedUnion('kind', [OcrImageExtractionOutputSchema, OcrDocumentExtractionOutputSchema]) + .superRefine((output, context) => { + if (output.cacheHit !== (output.benchmark.state === 'hit')) { + context.addIssue({ + code: 'custom', + message: 'OCR cache result does not match its benchmark classification', + path: ['benchmark', 'state'] + }) + } + }) + +export const ocrExtractUploadRoute = defineRouteContract({ + name: 'ocr.extractUpload', + input: OcrExtractUploadInputSchema, + output: OcrExtractionOutputSchema +}) + +export const ocrExtractArtifactRoute = defineRouteContract({ + name: 'ocr.extractArtifact', + input: OcrExtractArtifactInputSchema, + output: OcrExtractionOutputSchema +}) + export type OcrRuntimeStatus = z.infer +export type OcrEngine = z.infer +export type OcrExtractUploadInput = z.infer +export type OcrExtractArtifactInput = z.infer +export type OcrExtractionOutput = z.infer diff --git a/test/main/cli/args.test.ts b/test/main/cli/args.test.ts index 5ff22d30d..5e301df66 100644 --- a/test/main/cli/args.test.ts +++ b/test/main/cli/args.test.ts @@ -331,4 +331,133 @@ describe('CLI argument grammar', () => { expect(formatCliHelp({ domain: 'video', verb: 'generate' })).toContain('--watermark ') expect(formatCliHelp({ domain: 'audio', verb: 'speak' })).toContain('--voice ') }) + + it('selects upload and artifact contracts for audio transcription', () => { + expect( + parseCliArguments( + [ + 'audio', + 'transcribe', + '--provider', + 'provider-1', + '--model', + 'whisper-1', + '--file', + './meeting.MP3' + ], + {} + ) + ).toMatchObject({ + operation: 'upload', + inputPath: './meeting.MP3', + uploadMaxBytes: 25 * 1024 * 1024, + contract: { name: 'audio.transcribeUpload' }, + params: { + providerId: 'provider-1', + modelId: 'whisper-1', + mimeType: 'audio/mpeg', + filename: 'meeting.MP3' + } + }) + + expect( + parseCliArguments( + [ + 'audio', + 'transcribe', + '--provider', + 'provider-1', + '--model', + 'whisper-1', + '--artifact', + 'artifact_identifier_123' + ], + {} + ) + ).toMatchObject({ + operation: 'rpc', + contract: { name: 'audio.transcribeArtifact' }, + params: { + providerId: 'provider-1', + modelId: 'whisper-1', + artifactId: 'artifact_identifier_123' + } + }) + }) + + it('maps OCR input modes and bounded PDF options', () => { + expect( + parseCliArguments( + [ + 'ocr', + 'extract', + '--file', + './scan.pdf', + '--backend', + 'cpu', + '--page-count', + '12', + '--max-tokens', + '4096' + ], + {} + ) + ).toMatchObject({ + operation: 'upload', + inputPath: './scan.pdf', + uploadMaxBytes: 50 * 1024 * 1024, + contract: { name: 'ocr.extractUpload' }, + params: { + mimeType: 'application/pdf', + backend: 'cpu', + sourcePageCountHint: 12, + generationTokenLimit: 4096 + } + }) + expect( + parseCliArguments(['ocr', 'extract', '--artifact', 'artifact_identifier_123'], {}) + ).toMatchObject({ + operation: 'rpc', + contract: { name: 'ocr.extractArtifact' }, + params: { artifactId: 'artifact_identifier_123' } + }) + expect(parseCliArguments(['ocr', 'status'], {}).contract?.name).toBe('ocr.getRuntimeStatus') + expect(parseCliArguments(['ocr', 'clear-cache'], {})).toMatchObject({ + contract: { name: 'ocr.clearCache' }, + timeoutMs: 1_800_000 + }) + }) + + it('rejects ambiguous or unverifiable file-input options', () => { + expect(() => + parseCliArguments( + ['ocr', 'extract', '--file', './scan.png', '--artifact', 'artifact_identifier_123'], + {} + ) + ).toThrow('exactly one of --file or --artifact') + expect(() => parseCliArguments(['ocr', 'extract', '--file', './scan.unknown'], {})).toThrow( + 'provide --mime' + ) + expect(() => + parseCliArguments(['ocr', 'extract', '--file', './scan.png', '--mime', ' '], {}) + ).toThrow('--mime must not be empty') + expect(() => + parseCliArguments( + ['ocr', 'extract', '--artifact', 'artifact_identifier_123', '--mime', 'image/png'], + {} + ) + ).toThrow('--mime is only valid together with --file') + expect(() => + parseCliArguments(['ocr', 'extract', '--file', './scan.pdf', '--max-tokens', '16001'], {}) + ).toThrow('--max-tokens must not exceed 16000') + expect(() => + parseCliArguments(['ocr', 'extract', '--file', './scan.png', '--page-count', '1'], {}) + ).toThrow('only valid for PDF input') + }) + + it('keeps transcription and OCR options discoverable', () => { + expect(formatCliHelp({ domain: 'audio', verb: 'transcribe' })).toContain('--mime ') + expect(formatCliHelp({ domain: 'ocr', verb: 'extract' })).toContain('--page-count ') + expect(formatCliHelp()).toContain('ocr clear-cache') + }) }) diff --git a/test/main/cli/client.test.ts b/test/main/cli/client.test.ts index 0f5591f58..4263b2cd3 100644 --- a/test/main/cli/client.test.ts +++ b/test/main/cli/client.test.ts @@ -8,7 +8,8 @@ import type { DeepchatRouteName } from '@shared/contracts/routes' import type { JsonValue } from '@shared/contracts/json' import { LOCAL_CONTROL_AGENT_TOKEN_ENV, - LocalControlRpcResponseSchema + LocalControlRpcResponseSchema, + type LocalControlDescriptor } from '@shared/contracts/localControl' import { createCliRoutes } from '@/cli/routes' import { CliServer } from '@/cli/server' @@ -18,6 +19,16 @@ import { runCli } from '../../../src/cli/run' const servers: CliServer[] = [] const temporaryDirectories: string[] = [] +const testDescriptor: LocalControlDescriptor = { + protocolVersion: 1, + surfaceVersion: 1, + appVersion: '9.8.7', + endpoint: { kind: 'unix', path: '/tmp/deepchat-test.sock' }, + pid: 1, + token: 'h'.repeat(43), + startedAt: 1 +} + function captureOutput(): { stream: NodeJS.WriteStream; read(): string } { let value = '' return { @@ -399,4 +410,89 @@ describe('bundled CLI client', () => { expect(records).toHaveLength(1) expect(records[0]).toMatchObject({ ok: false, error: { code: 'internal_error' } }) }) + + it('uploads human audio input with only typed metadata in the RPC envelope', async () => { + const stdout = captureOutput() + const stderr = captureOutput() + const invokeUpload = vi.fn(async (invocation) => + LocalControlRpcResponseSchema.parse({ + protocolVersion: 1, + surfaceVersion: 1, + id: invocation.id, + ok: true, + result: { + providerId: 'provider-1', + modelId: 'whisper-1', + text: 'meeting transcript', + truncated: false, + inputBytes: 10, + mimeType: 'audio/mpeg', + durationMs: 25 + } + }) + ) + + await expect( + runCli( + [ + 'audio', + 'transcribe', + '--provider', + 'provider-1', + '--model', + 'whisper-1', + '--file', + '/private/input/meeting.mp3' + ], + { + env: {}, + stdout: stdout.stream, + stderr: stderr.stream, + randomId: () => 'request-1', + loadDescriptor: async () => testDescriptor, + invokeUpload + } + ) + ).resolves.toBe(0) + + expect(stdout.read()).toBe('meeting transcript\n') + expect(stderr.read()).toBe('') + expect(invokeUpload).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'audio.transcribeUpload', + params: { + providerId: 'provider-1', + modelId: 'whisper-1', + mimeType: 'audio/mpeg', + filename: 'meeting.mp3' + }, + filePath: '/private/input/meeting.mp3', + maxBytes: 25 * 1024 * 1024 + }) + ) + }) + + it('rejects Agent --file input before the upload transport can open it', async () => { + const stdout = captureOutput() + const stderr = captureOutput() + const invokeUpload = vi.fn() + + await expect( + runCli(['ocr', 'extract', '--file', '/private/input/scan.png', '--json'], { + env: { [LOCAL_CONTROL_AGENT_TOKEN_ENV]: 'a'.repeat(43) }, + stdout: stdout.stream, + stderr: stderr.stream, + randomId: () => 'request-1', + loadDescriptor: async () => testDescriptor, + invokeUpload + }) + ).resolves.toBe(4) + + expect(LocalControlRpcResponseSchema.parse(JSON.parse(stdout.read()))).toMatchObject({ + ok: false, + error: { code: 'permission_denied' } + }) + expect(stderr.read()).toBe('') + expect(invokeUpload).not.toHaveBeenCalled() + }) }) diff --git a/test/main/cli/inputCapabilityServices.test.ts b/test/main/cli/inputCapabilityServices.test.ts new file mode 100644 index 000000000..ce0c32054 --- /dev/null +++ b/test/main/cli/inputCapabilityServices.test.ts @@ -0,0 +1,427 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + AUDIO_TRANSCRIPTION_MAX_TEXT_CHARACTERS, + audioTranscribeArtifactRoute, + audioTranscribeUploadRoute, + ocrExtractUploadRoute +} from '@shared/contracts/routes' +import { ArtifactSpool } from '@/cli/artifactSpool' +import { + CliAudioTranscriptionService, + type CliAudioTranscriptionServiceOptions +} from '@/cli/audioTranscriptionService' +import { CliOcrService, type CliOcrServiceOptions } from '@/cli/ocrService' +import type { CliRouteCaller } from '@/routes/routeRegistry' +import type { OcrRuntimeServiceStatus } from '@/ocr/ocrRuntimeService' +import { OcrRuntimeBusyError } from '@/ocr/ocrRuntimeService' + +const temporaryDirectories: string[] = [] +const spools: ArtifactSpool[] = [] + +const caller: CliRouteCaller = { + kind: 'cli', + principal: 'human', + connectionId: 'connection-1', + scopes: ['audio:transcribe', 'ocr:extract', 'artifacts:read'] +} + +const engine = { + coreVersion: '0.5.5', + modelBundleId: 'bundle-v1', + requestedProvider: 'auto' as const, + strategy: 'bounded-960' as const, + detection: { + actualProviderChain: ['coreml', 'cpu'], + precision: 'fp16', + qualificationId: 'private-detection-id' + }, + recognition: { + actualProviderChain: ['cpu'], + precision: 'fp32', + qualificationId: 'private-recognition-id' + } +} + +const readyStatus: OcrRuntimeServiceStatus = { + availability: { + status: 'available', + assets: { + nodeExecutable: '/private/node', + helperEntryPath: '/private/helper.js', + facadeDir: '/private/facade', + runtimeDir: '/private/runtime', + bundlePath: '/private/bundle', + nativePackageDir: '/private/native', + nativePayloadEncoding: 'gzip-base64-v1', + nativePackage: '@arcships/light-ocr-test', + lightOcrVersion: '0.5.5', + bundleId: 'bundle-v1' + } + }, + process: { + state: 'ready', + pid: 123, + nodeVersion: 'v24.0.0', + queuedRequests: 0, + pendingInputBytes: 0, + stderrBytesCaptured: 0, + engine + }, + cache: { + mode: 'persistent', + entryCount: 1, + logicalBytes: 100, + maxBytes: 1024 + } +} + +afterEach(async () => { + await Promise.allSettled(spools.splice(0).map((spool) => spool.close())) + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })) + ) +}) + +async function createService( + overrides: { + status?: OcrRuntimeServiceStatus + transcript?: string + } = {} +) { + const root = await mkdtemp(path.join(os.tmpdir(), 'deepchat-cli-input-')) + temporaryDirectories.push(root) + const artifactSpool = new ArtifactSpool({ directory: path.join(root, 'artifacts') }) + spools.push(artifactSpool) + await artifactSpool.initialize() + + const providerSettings: CliAudioTranscriptionServiceOptions['providerSettings'] = { + getProviderById: vi.fn(() => ({ + id: 'provider-1', + name: 'Provider One', + apiType: 'openai-compatible', + apiKey: 'secret-key', + baseUrl: 'https://private.example', + enable: true + })), + getModelStatus: vi.fn(() => true), + isKnownModel: vi.fn(() => true) + } + const providerRuntime: CliAudioTranscriptionServiceOptions['providerRuntime'] = { + transcribeAudioStandalone: vi.fn(async () => overrides.transcript ?? ' transcript text ') + } + const ocrRuntime: CliOcrServiceOptions['ocrRuntime'] = { + clearCache: vi.fn(async () => undefined), + getStatus: vi.fn(async () => overrides.status ?? readyStatus), + extract: vi.fn(async () => ({ + text: 'recognized image text', + tokenCount: 3, + truncated: false, + mimeType: 'image/png', + imageWidth: 10, + imageHeight: 20, + strategy: 'bounded-960', + engine, + cacheHit: false, + timingMs: { snapshot: 1, preprocessing: 2, recognition: 3, total: 6 } + })), + extractDocument: vi.fn(async () => { + const text = '## Page 1\n\nrecognized PDF text' + return { + text, + tokenCount: 7, + pageSpans: [{ pageNumber: 1, start: 0, end: text.length, complete: true }], + artifactTermination: 'request_complete', + generationOutputLimitReached: false, + generationTokenLimit: 16_000, + emittedPages: 1, + sourcePageCountHint: 1, + engine, + cacheHit: true, + timingMs: { snapshot: 1, recognition: 4, total: 5 } + } + }) + } + return { + root, + artifactSpool, + providerRuntime, + ocrRuntime, + audioService: new CliAudioTranscriptionService({ + providerSettings, + providerRuntime, + artifactSpool, + now: () => 100 + }), + ocrService: new CliOcrService({ + appVersion: '1.2.3', + ocrRuntime, + artifactSpool, + now: () => 100 + }) + } +} + +describe('CLI audio transcription and OCR services', () => { + it('transcribes owned audio artifacts without exposing their file path', async () => { + const { root, artifactSpool, providerRuntime, audioService } = await createService() + const artifact = await artifactSpool.write({ + caller, + requestId: 'request-1', + mimeType: 'audio/wav', + suggestedFilename: 'sample.wav', + data: Buffer.from('audio bytes') + }) + + const result = await audioService.dispatchRpc( + audioTranscribeArtifactRoute.name, + { providerId: 'provider-1', modelId: 'model-1', artifactId: artifact.id }, + caller, + new AbortController().signal + ) + + expect(result).toMatchObject({ + providerId: 'provider-1', + modelId: 'model-1', + text: 'transcript text', + mimeType: 'audio/wav', + inputBytes: 11 + }) + expect(providerRuntime.transcribeAudioStandalone).toHaveBeenCalledWith( + 'provider-1', + 'model-1', + Buffer.from('audio bytes').toString('base64'), + 'audio/wav', + 'sample.wav', + { signal: expect.any(AbortSignal) } + ) + expect(JSON.stringify(result)).not.toContain(root) + }) + + it('preserves cancellation before loading a transcription into memory', async () => { + const { root, providerRuntime, audioService } = await createService() + const filePath = path.join(root, 'sample.wav') + await writeFile(filePath, 'audio bytes') + const controller = new AbortController() + controller.abort() + + await expect( + audioService.dispatchUpload( + audioTranscribeUploadRoute.name, + { + providerId: 'provider-1', + modelId: 'model-1', + mimeType: 'audio/wav', + filename: 'sample.wav' + }, + { path: filePath, size: 11 }, + caller, + controller.signal + ) + ).rejects.toMatchObject({ code: 'cancelled' }) + expect(providerRuntime.transcribeAudioStandalone).not.toHaveBeenCalled() + }) + + it('bounds transcription text without splitting a surrogate pair', async () => { + const prefix = 'a'.repeat(AUDIO_TRANSCRIPTION_MAX_TEXT_CHARACTERS - 1) + const { root, audioService } = await createService({ transcript: `${prefix}😀tail` }) + const filePath = path.join(root, 'sample.wav') + await writeFile(filePath, 'audio bytes') + + const result = await audioService.dispatchUpload( + audioTranscribeUploadRoute.name, + { + providerId: 'provider-1', + modelId: 'model-1', + mimeType: 'audio/wav', + filename: 'sample.wav' + }, + { path: filePath, size: 11 }, + caller, + new AbortController().signal + ) + + expect(result).toMatchObject({ truncated: true, text: prefix }) + }) + + it('extracts image text on the background queue and emits public benchmark data', async () => { + const { root, ocrRuntime, ocrService } = await createService() + const filePath = path.join(root, 'image.png') + const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + await writeFile(filePath, bytes) + + const result = await ocrService.dispatchUpload( + ocrExtractUploadRoute.name, + { mimeType: 'image/png', backend: 'auto' }, + { path: filePath, size: bytes.length }, + caller, + new AbortController().signal + ) + + expect(result).toMatchObject({ + kind: 'image', + text: 'recognized image text', + benchmark: { + state: 'miss-warm', + runtimeStateBefore: 'ready', + runtimeWasReady: true, + inputBytes: bytes.length, + appVersion: '1.2.3', + protocolVersion: 1, + surfaceVersion: 1 + }, + engine: { + requestedBackend: 'auto', + detection: { providerChain: ['coreml', 'cpu'] } + } + }) + expect(ocrRuntime.extract).toHaveBeenCalledWith( + expect.objectContaining({ + filePath, + backend: 'auto', + priority: 'background', + signal: expect.any(AbortSignal) + }) + ) + expect(JSON.stringify(result)).not.toContain('qualificationId') + expect(JSON.stringify(result)).not.toContain('/private/') + }) + + it('routes PDF input separately and rejects a mismatched declared image type', async () => { + const { root, ocrRuntime, ocrService } = await createService() + const filePath = path.join(root, 'document.pdf') + const bytes = Buffer.from('%PDF-1.7\nbody') + await writeFile(filePath, bytes) + + const result = await ocrService.dispatchUpload( + ocrExtractUploadRoute.name, + { + mimeType: 'application/pdf', + backend: 'cpu', + sourcePageCountHint: 1, + generationTokenLimit: 100 + }, + { path: filePath, size: bytes.length }, + caller, + new AbortController().signal + ) + + expect(result).toMatchObject({ + kind: 'document', + text: '## Page 1\n\nrecognized PDF text', + cacheHit: true, + benchmark: { state: 'hit' } + }) + expect(ocrRuntime.extractDocument).toHaveBeenCalledWith( + expect.objectContaining({ + backend: 'cpu', + sourcePageCountHint: 1, + generationTokenLimit: 100, + priority: 'background' + }) + ) + + const firstDocumentResult = await vi.mocked(ocrRuntime.extractDocument).mock.results[0].value + vi.mocked(ocrRuntime.extractDocument).mockResolvedValueOnce({ + ...firstDocumentResult, + artifactTermination: 'resource_limited', + resourceLimit: { + code: 'resource_limit_exceeded', + message: 'private helper message', + detail: '/private/runtime/model' + } + }) + const limited = await ocrService.dispatchUpload( + ocrExtractUploadRoute.name, + { mimeType: 'application/pdf', backend: 'auto' }, + { path: filePath, size: bytes.length }, + caller, + new AbortController().signal + ) + expect(limited).toMatchObject({ + truncated: true, + resourceLimit: { + code: 'resource_limit_exceeded', + message: 'OCR document processing reached a resource limit' + } + }) + expect(JSON.stringify(limited)).not.toContain('/private/') + expect(JSON.stringify(limited)).not.toContain('detail') + + await expect( + ocrService.dispatchUpload( + ocrExtractUploadRoute.name, + { mimeType: 'image/png', backend: 'auto' }, + { path: filePath, size: bytes.length }, + caller, + new AbortController().signal + ) + ).rejects.toMatchObject({ code: 'invalid_request' }) + expect(ocrRuntime.extract).not.toHaveBeenCalled() + }) + + it('maps active-extraction cache maintenance to a stable conflict', async () => { + const { ocrRuntime, ocrService } = await createService() + vi.mocked(ocrRuntime.clearCache).mockRejectedValueOnce(new OcrRuntimeBusyError()) + + await expect( + ocrService.dispatchRpc('ocr.clearCache', {}, caller, new AbortController().signal) + ).rejects.toMatchObject({ code: 'conflict', httpStatus: 409 }) + }) + + it('distinguishes cold-runtime misses from offline unavailability', async () => { + const coldStatus: OcrRuntimeServiceStatus = { + ...readyStatus, + process: null, + cache: null + } + const cold = await createService({ status: coldStatus }) + const filePath = path.join(cold.root, 'cold.png') + const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + await writeFile(filePath, bytes) + + await expect( + cold.ocrService.dispatchUpload( + ocrExtractUploadRoute.name, + { mimeType: 'image/png', backend: 'auto' }, + { path: filePath, size: bytes.length }, + caller, + new AbortController().signal + ) + ).resolves.toMatchObject({ + benchmark: { + state: 'cold-runtime', + runtimeStateBefore: 'not-started', + runtimeWasReady: false + } + }) + + const offlineStatus: OcrRuntimeServiceStatus = { + availability: { + status: 'unavailable', + reason: 'assets_missing', + lightOcrVersion: '0.5.5', + bundleId: 'bundle-v1' + }, + process: null, + cache: null + } + const offline = await createService({ status: offlineStatus }) + await expect( + offline.ocrService.dispatchUpload( + ocrExtractUploadRoute.name, + { mimeType: 'image/png', backend: 'auto' }, + { path: '/not/inspected/while-offline.png', size: 8 }, + caller, + new AbortController().signal + ) + ).rejects.toMatchObject({ + code: 'unavailable', + httpStatus: 503, + options: { details: { reason: 'assets_missing' } } + }) + expect(offline.ocrRuntime.extract).not.toHaveBeenCalled() + }) +}) diff --git a/test/main/cli/server.test.ts b/test/main/cli/server.test.ts index c7daa2c45..c80717a83 100644 --- a/test/main/cli/server.test.ts +++ b/test/main/cli/server.test.ts @@ -113,6 +113,7 @@ function uploadRequest( token?: string body: Buffer includeContentLength?: boolean + signal?: AbortSignal } ): Promise { const envelope = Buffer.from( @@ -141,6 +142,7 @@ function uploadRequest( descriptor.endpoint.kind === 'unix' ? descriptor.endpoint.path : descriptor.endpoint.name, path: '/v1/upload', method: 'POST', + signal: input.signal, headers }, (response) => { @@ -419,6 +421,25 @@ describe('CLI local transport', () => { expect(await readdir(path.join(userDataPath, 'local-control', 'tmp'))).toEqual([]) }) + it('releases cancelled upload requests when a domain adapter ignores the signal', async () => { + const controller = new AbortController() + const { descriptor, userDataPath, server, dispatchUpload } = await createTestServer({ + surface: createUploadSurface(16), + dispatchUpload: async () => await new Promise(() => undefined) + }) + + const response = uploadRequest(descriptor, { + body: Buffer.from('audio-input'), + signal: controller.signal + }) + await vi.waitFor(() => expect(dispatchUpload).toHaveBeenCalledOnce()) + controller.abort() + + await expect(response).rejects.toBeDefined() + await vi.waitFor(() => expect(server.getStatus().pendingRequests).toBe(0)) + expect(await readdir(path.join(userDataPath, 'local-control', 'tmp'))).toEqual([]) + }) + it('denies Agent upload callers before reading their body', async () => { const agentToken = 'a'.repeat(43) const { descriptor, userDataPath, dispatchUpload } = await createTestServer({ diff --git a/test/main/cli/surface.test.ts b/test/main/cli/surface.test.ts index e6d273b9b..326c1c9c8 100644 --- a/test/main/cli/surface.test.ts +++ b/test/main/cli/surface.test.ts @@ -10,12 +10,18 @@ describe('CLI surface V1', () => { 'artifacts.delete', 'artifacts.describe', 'artifacts.read', + 'audio.transcribeArtifact', + 'audio.transcribeUpload', 'cli.capabilities', 'cli.doctor', 'cli.status', 'cli.version', 'images.generate', 'models.invoke', + 'ocr.clearCache', + 'ocr.extractArtifact', + 'ocr.extractUpload', + 'ocr.getRuntimeStatus', 'providers.listPublic', 'speech.generate', 'videos.generate' @@ -38,6 +44,19 @@ describe('CLI surface V1', () => { expect.objectContaining({ method: 'artifacts.delete', effect: 'local-maintenance' }), expect.objectContaining({ method: 'artifacts.describe', effect: 'read' }), expect.objectContaining({ method: 'artifacts.read', effect: 'read', transport: 'download' }), + expect.objectContaining({ + method: 'audio.transcribeArtifact', + effect: 'compute', + transport: 'rpc', + callers: ['human', 'agent'], + scopes: ['audio:transcribe', 'artifacts:read'] + }), + expect.objectContaining({ + method: 'audio.transcribeUpload', + effect: 'compute', + transport: 'upload', + callers: ['human'] + }), expect.objectContaining({ method: 'cli.capabilities', effect: 'read' }), expect.objectContaining({ method: 'cli.doctor', effect: 'read' }), expect.objectContaining({ method: 'cli.status', effect: 'read' }), @@ -52,6 +71,26 @@ describe('CLI surface V1', () => { effect: 'compute', transport: 'stream' }), + expect.objectContaining({ + method: 'ocr.clearCache', + effect: 'local-maintenance', + approval: 'never', + callers: ['human'] + }), + expect.objectContaining({ + method: 'ocr.extractArtifact', + effect: 'compute', + transport: 'rpc', + callers: ['human', 'agent'], + scopes: ['ocr:extract', 'artifacts:read'] + }), + expect.objectContaining({ + method: 'ocr.extractUpload', + effect: 'compute', + transport: 'upload', + callers: ['human'] + }), + expect.objectContaining({ method: 'ocr.getRuntimeStatus', effect: 'read' }), expect.objectContaining({ method: 'providers.listPublic', effect: 'read' }), expect.objectContaining({ method: 'speech.generate', From 0c9c3784ea1b3af193c912d4e15b0417a832dbf6 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 13:38:56 +0800 Subject: [PATCH 12/51] refactor(approval): extract shared broker --- .../architecture/local-control-plane/tasks.md | 4 +- src/main/app/composition.ts | 7 +- src/main/approval/approvalBroker.ts | 542 ++++++++++++++++++ src/main/approval/index.ts | 14 + src/main/tool/permission/index.ts | 1 + .../tool/permission/toolPermissionBroker.ts | 417 +++++--------- test/main/approval/approvalBroker.test.ts | 207 +++++++ test/main/tool/toolPermissionBroker.test.ts | 19 + 8 files changed, 933 insertions(+), 278 deletions(-) create mode 100644 src/main/approval/approvalBroker.ts create mode 100644 src/main/approval/index.ts create mode 100644 test/main/approval/approvalBroker.test.ts diff --git a/docs/architecture/local-control-plane/tasks.md b/docs/architecture/local-control-plane/tasks.md index 657a8c681..4a3d50340 100644 --- a/docs/architecture/local-control-plane/tasks.md +++ b/docs/architecture/local-control-plane/tasks.md @@ -47,8 +47,8 @@ ## Effects and Approval -- [ ] Extract generic canonicalization/pending/timeout/consume mechanics into `ApprovalBroker`. -- [ ] Preserve MCP, Agent pre-check, and live-delegation behavior through `ToolPermissionBroker`. +- [x] Extract generic canonicalization/pending/timeout/consume mechanics into `ApprovalBroker`. +- [x] Preserve MCP, Agent pre-check, and live-delegation behavior through `ToolPermissionBroker`. - [ ] Add `CliMutationGuard` with unique live-request-bound approvals and no replay token. - [ ] Add targeted approval events and renderer-only `approvals.resolve` IPC. - [ ] Implement effect/caller/operation policy, scopes, quotas, rate limits, and redacted audit. diff --git a/src/main/app/composition.ts b/src/main/app/composition.ts index 18a875b22..53d7dbf3c 100644 --- a/src/main/app/composition.ts +++ b/src/main/app/composition.ts @@ -105,6 +105,7 @@ import { createPlatformRoutes } from '../platform/routes' import { createHookRoutes } from '../hook/routes' import { createAppSettingsRoutes } from './settingsRoutes' import { createAppRoutes } from './routes' +import { ApprovalBroker } from '@/approval' import { CommandPermissionService, FilePermissionService, @@ -334,6 +335,7 @@ export async function createMainProcessControl(dependencies: { let commandPermissionService: CommandPermissionService let filePermissionService: FilePermissionService let settingsPermissionService: SettingsPermissionService + let approvalBroker: ApprovalBroker let toolPermissionBroker: ToolPermissionBroker let legacyChatImportService: LegacyChatImportService let usageStatsService: UsageStatsService @@ -697,7 +699,8 @@ export async function createMainProcessControl(dependencies: { commandPermissionService = commandPermissionHandler filePermissionService = new FilePermissionService() settingsPermissionService = new SettingsPermissionService() - toolPermissionBroker = new ToolPermissionBroker() + approvalBroker = new ApprovalBroker({ log: logger }) + toolPermissionBroker = new ToolPermissionBroker({ approvalBroker }) const liveDelegationConsent = new LiveDelegationConsentAuthority() deviceService = new DeviceService() const loggingService = new LoggingService( @@ -2694,7 +2697,7 @@ export async function createMainProcessControl(dependencies: { commandPermissionService.clearAll() filePermissionService.clearAll() settingsPermissionService.clearAll() - toolPermissionBroker.clear() + approvalBroker.clear() dependencies.mcpAppSandboxRegistry.clear() }, confirmShutdown: async () => await knowledgeService.confirmShutdown(), diff --git a/src/main/approval/approvalBroker.ts b/src/main/approval/approvalBroker.ts new file mode 100644 index 000000000..69269baf2 --- /dev/null +++ b/src/main/approval/approvalBroker.ts @@ -0,0 +1,542 @@ +import { createHash, randomUUID } from 'node:crypto' +import type { JsonValue } from '@shared/contracts/json' + +const MAX_ARGUMENT_BYTES = 1024 * 1024 +const MAX_REDACTED_DISPLAY_BYTES = 16 * 1024 +const MAX_ARGUMENT_PREVIEW_BYTES = 16 * 1024 +const MAX_ARGUMENT_DEPTH = 64 +const MAX_ARGUMENT_KEYS = 10_000 +const MAX_DOMAIN_BYTES = 128 +const MAX_SCOPE_KEY_BYTES = 512 +const MAX_OPERATION_BYTES = 256 +const MAX_EFFECT_BYTES = 128 +const MAX_BINDING_KEY_BYTES = 64 * 1024 +const DEFAULT_MAX_PENDING_PER_SCOPE = 64 +const DEFAULT_REQUEST_TIMEOUT_MS = 2 * 60_000 + +export type ApprovalDecision = + | Readonly<{ allowed: true }> + | Readonly<{ allowed: false; reason: 'denied' | 'cancelled' | 'timeout' }> + +export type ApprovalResolution = Readonly<{ + requestId: string + scopeKey: string + decision: 'approved' | 'denied' | 'cancelled' +}> + +export type ApprovalBinding = Readonly<{ + domain: string + scopeKey: string + operation: string + effect: string + bindingKey: string + arguments: unknown + redactedDisplayData?: JsonValue + metadata: TMetadata +}> + +export type ApprovalCreateOptions = Readonly<{ + deduplicatePending?: boolean + includeArgumentsPreview?: boolean + consumeOnApprove?: boolean + timeoutMs?: number + signal?: AbortSignal +}> + +export type ApprovalSnapshot = Readonly<{ + requestId: string + domain: string + scopeKey: string + operation: string + effect: string + argumentsHash: string + argumentsPreview?: string + redactedDisplayData?: JsonValue + status: 'pending' | 'approved' + expiresAt: number + metadata: TMetadata +}> + +export type ApprovalPublicSnapshot = Omit, 'metadata'> + +export type ApprovalMatch = Readonly<{ + requestId?: string + domain: string + scopeKey: string + operation: string + effect: string + bindingKey: string + arguments: unknown +}> + +export type ApprovalEvent = + | Readonly<{ type: 'created'; approval: ApprovalPublicSnapshot }> + | Readonly<{ + type: 'resolved' + approval: ApprovalPublicSnapshot + decision: ApprovalDecision + }> + | Readonly<{ + type: 'removed' + approval: ApprovalPublicSnapshot + reason: 'consumed' | 'denied' | 'cancelled' | 'timeout' | 'cleared' + }> + +export type ApprovalBrokerOptions = Readonly<{ + defaultTimeoutMs?: number + maxPendingPerScope?: number + now?: () => number + createRequestId?: () => string + log?: Pick +}> + +type PendingApproval = { + requestId: string + domain: string + scopeKey: string + operation: string + effect: string + bindingKey: string + argumentsHash: string + argumentsPreview?: string + redactedDisplayData?: JsonValue + metadata: unknown + status: 'pending' | 'approved' + consumeOnApprove: boolean + expiresAt: number + timeout: NodeJS.Timeout + settlers: Set<(decision: ApprovalDecision) => void> + abortCleanups: Set<() => void> +} + +type CanonicalizeState = { + keys: number + seen: WeakSet +} + +export class ApprovalCapacityError extends Error { + constructor(readonly scopeKey: string) { + super('Too many pending approval requests for scope') + this.name = 'ApprovalCapacityError' + } +} + +function canonicalize(value: unknown, state: CanonicalizeState, depth = 0): unknown { + if (depth > MAX_ARGUMENT_DEPTH) { + throw new Error('Approval arguments exceed the depth limit') + } + if (Array.isArray(value)) { + if (state.seen.has(value)) throw new Error('Approval arguments must not contain cycles') + state.seen.add(value) + const output = value.map((entry) => canonicalize(entry, state, depth + 1)) + state.seen.delete(value) + return output + } + + if (value && typeof value === 'object') { + if (state.seen.has(value)) throw new Error('Approval arguments must not contain cycles') + state.seen.add(value) + const output = Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([key, entry]) => { + state.keys += 1 + if (state.keys > MAX_ARGUMENT_KEYS) { + throw new Error('Approval arguments exceed the key limit') + } + return [key, canonicalize(entry, state, depth + 1)] + }) + ) + state.seen.delete(value) + return output + } + + if ( + value !== null && + typeof value !== 'string' && + typeof value !== 'number' && + typeof value !== 'boolean' && + value !== undefined + ) { + throw new Error('Approval arguments must be JSON-compatible') + } + if (typeof value === 'number' && !Number.isFinite(value)) { + throw new Error('Approval arguments must contain only finite numbers') + } + return value +} + +function serializeArguments(value: unknown): { + hash: string + preview: string +} { + const serialized = + JSON.stringify(canonicalize(value, { keys: 0, seen: new WeakSet() })) ?? 'null' + const bytes = Buffer.byteLength(serialized, 'utf8') + if (bytes > MAX_ARGUMENT_BYTES) { + throw new Error(`Approval arguments exceed the ${MAX_ARGUMENT_BYTES}-byte limit`) + } + + return { + hash: createHash('sha256').update(serialized).digest('hex'), + preview: + bytes <= MAX_ARGUMENT_PREVIEW_BYTES + ? serialized + : `${Buffer.from(serialized).subarray(0, MAX_ARGUMENT_PREVIEW_BYTES).toString('utf8')}…` + } +} + +function canonicalizeRedactedDisplayData(value: JsonValue | undefined): JsonValue | undefined { + if (value === undefined) return undefined + const canonical = canonicalize(value, { keys: 0, seen: new WeakSet() }) + const serialized = JSON.stringify(canonical) + if ( + serialized === undefined || + Buffer.byteLength(serialized, 'utf8') > MAX_REDACTED_DISPLAY_BYTES + ) { + throw new Error('Redacted approval display data exceeds its byte limit') + } + return JSON.parse(serialized) as JsonValue +} + +function cloneJsonValue(value: JsonValue): JsonValue { + return structuredClone(value) +} + +function positiveSafeInteger(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive safe integer`) + } + return value +} + +function boundedString(value: string, name: string, maxBytes: number): string { + if (!value.trim()) throw new Error(`${name} must not be empty`) + if (Buffer.byteLength(value, 'utf8') > maxBytes) { + throw new Error(`${name} exceeds its byte limit`) + } + return value +} + +export function hashApprovalArguments(value: unknown): string { + return serializeArguments(value).hash +} + +export class ApprovalBroker { + private readonly defaultTimeoutMs: number + private readonly maxPendingPerScope: number + private readonly now: () => number + private readonly createRequestId: () => string + private readonly log: Pick + private readonly pending = new Map() + private readonly listeners = new Set<(event: ApprovalEvent) => void>() + + constructor(options: ApprovalBrokerOptions = {}) { + this.defaultTimeoutMs = positiveSafeInteger( + options.defaultTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, + 'defaultTimeoutMs' + ) + this.maxPendingPerScope = positiveSafeInteger( + options.maxPendingPerScope ?? DEFAULT_MAX_PENDING_PER_SCOPE, + 'maxPendingPerScope' + ) + this.now = options.now ?? Date.now + this.createRequestId = options.createRequestId ?? randomUUID + this.log = options.log ?? console + } + + create( + binding: ApprovalBinding, + options: ApprovalCreateOptions = {} + ): ApprovalSnapshot { + options.signal?.throwIfAborted() + this.pruneExpired() + const serialized = serializeArguments(binding.arguments) + const domain = boundedString(binding.domain, 'Approval domain', MAX_DOMAIN_BYTES) + const scopeKey = boundedString(binding.scopeKey, 'Approval scope key', MAX_SCOPE_KEY_BYTES) + const operation = boundedString(binding.operation, 'Approval operation', MAX_OPERATION_BYTES) + const effect = boundedString(binding.effect, 'Approval effect', MAX_EFFECT_BYTES) + const bindingKey = boundedString( + binding.bindingKey, + 'Approval binding key', + MAX_BINDING_KEY_BYTES + ) + + if (options.deduplicatePending) { + const existing = Array.from(this.pending.values()).find( + (entry) => + entry.status === 'pending' && + entry.domain === domain && + entry.scopeKey === scopeKey && + entry.operation === operation && + entry.effect === effect && + entry.bindingKey === bindingKey && + entry.argumentsHash === serialized.hash + ) + if (existing) { + this.attachAbort(existing, options.signal) + options.signal?.throwIfAborted() + return this.toSnapshot(existing) as ApprovalSnapshot + } + } + + const scopePending = Array.from(this.pending.values()).filter( + (entry) => entry.scopeKey === scopeKey + ).length + if (scopePending >= this.maxPendingPerScope) throw new ApprovalCapacityError(scopeKey) + + const timeoutMs = positiveSafeInteger( + options.timeoutMs ?? this.defaultTimeoutMs, + 'approval timeoutMs' + ) + const now = this.now() + if (!Number.isSafeInteger(now) || now < 0 || now > Number.MAX_SAFE_INTEGER - timeoutMs) { + throw new Error('Approval clock is outside the supported range') + } + const requestId = this.allocateRequestId() + const pending: PendingApproval = { + requestId, + domain, + scopeKey, + operation, + effect, + bindingKey, + argumentsHash: serialized.hash, + ...(options.includeArgumentsPreview ? { argumentsPreview: serialized.preview } : {}), + ...(binding.redactedDisplayData !== undefined + ? { redactedDisplayData: canonicalizeRedactedDisplayData(binding.redactedDisplayData) } + : {}), + metadata: binding.metadata, + status: 'pending', + consumeOnApprove: options.consumeOnApprove ?? false, + expiresAt: now + timeoutMs, + settlers: new Set(), + abortCleanups: new Set(), + timeout: setTimeout(() => this.expire(requestId), timeoutMs) + } + pending.timeout.unref() + this.pending.set(requestId, pending) + this.attachAbort(pending, options.signal) + options.signal?.throwIfAborted() + this.emit({ type: 'created', approval: this.toPublicSnapshot(pending) }) + return this.toSnapshot(pending) as ApprovalSnapshot + } + + async wait(requestId: string, signal?: AbortSignal): Promise { + this.pruneExpired() + const pending = this.pending.get(requestId) + if (!pending) return { allowed: false, reason: 'cancelled' } + if (signal?.aborted) { + this.resolve({ requestId, scopeKey: pending.scopeKey, decision: 'cancelled' }) + return { allowed: false, reason: 'cancelled' } + } + if (pending.status === 'approved') { + if (pending.consumeOnApprove) this.deletePending(pending, 'consumed') + return { allowed: true } + } + + return await new Promise((resolve) => { + pending.settlers.add(resolve) + this.attachAbort(pending, signal) + }) + } + + resolve(resolution: ApprovalResolution): boolean { + this.pruneExpired() + const pending = this.pending.get(resolution.requestId) + if (!pending || pending.scopeKey !== resolution.scopeKey) return false + + if (resolution.decision === 'approved') { + if (pending.status !== 'pending') return false + pending.status = 'approved' + const decision = { allowed: true } as const + const hadWaiters = pending.settlers.size > 0 + this.settle(pending, decision) + this.emit({ + type: 'resolved', + approval: this.toPublicSnapshot(pending), + decision + }) + if (pending.consumeOnApprove && hadWaiters) this.deletePending(pending, 'consumed') + return true + } + + const decision: ApprovalDecision = { + allowed: false, + reason: resolution.decision === 'denied' ? 'denied' : 'cancelled' + } + this.settle(pending, decision) + this.emit({ + type: 'resolved', + approval: this.toPublicSnapshot(pending), + decision + }) + this.deletePending(pending, decision.reason) + return true + } + + consumeApproved(match: ApprovalMatch): boolean { + this.pruneExpired() + const argumentsHash = hashApprovalArguments(match.arguments) + const approved = Array.from(this.pending.values()).find( + (entry) => + entry.status === 'approved' && + (match.requestId === undefined || entry.requestId === match.requestId) && + entry.domain === match.domain && + entry.scopeKey === match.scopeKey && + entry.operation === match.operation && + entry.effect === match.effect && + entry.bindingKey === match.bindingKey && + entry.argumentsHash === argumentsHash + ) + if (!approved) return false + this.deletePending(approved, 'consumed') + return true + } + + cancelScope(scopeKey: string): void { + for (const pending of Array.from(this.pending.values())) { + if (pending.scopeKey === scopeKey) { + this.resolve({ + requestId: pending.requestId, + scopeKey, + decision: 'cancelled' + }) + } + } + } + + clearDomain(domain: string): void { + this.clearMatching((pending) => pending.domain === domain) + } + + clear(): void { + this.clearMatching(() => true) + } + + subscribe(listener: (event: ApprovalEvent) => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + private clearMatching(predicate: (pending: PendingApproval) => boolean): void { + for (const pending of Array.from(this.pending.values())) { + if (!predicate(pending)) continue + const decision = { allowed: false, reason: 'cancelled' } as const + this.settle(pending, decision) + this.emit({ + type: 'resolved', + approval: this.toPublicSnapshot(pending), + decision + }) + this.deletePending(pending, 'cleared') + } + } + + private allocateRequestId(): string { + for (let attempt = 0; attempt < 8; attempt += 1) { + const requestId = this.createRequestId() + if (!/^[A-Za-z0-9_-]{16,128}$/.test(requestId)) { + throw new Error('Approval request ID generator returned an invalid identifier') + } + if (!this.pending.has(requestId)) return requestId + } + throw new Error('Unable to allocate a unique approval request ID') + } + + private expire(requestId: string): void { + const pending = this.pending.get(requestId) + if (!pending) return + if (pending.status === 'pending') { + const decision = { allowed: false, reason: 'timeout' } as const + this.settle(pending, decision) + this.emit({ + type: 'resolved', + approval: this.toPublicSnapshot(pending), + decision + }) + } + this.deletePending(pending, 'timeout') + } + + private pruneExpired(): void { + const now = this.now() + for (const pending of Array.from(this.pending.values())) { + if (pending.expiresAt <= now) this.expire(pending.requestId) + } + } + + private attachAbort(pending: PendingApproval, signal?: AbortSignal): void { + if (!signal) return + const onAbort = () => { + if (this.pending.get(pending.requestId) !== pending) return + this.resolve({ + requestId: pending.requestId, + scopeKey: pending.scopeKey, + decision: 'cancelled' + }) + } + signal.addEventListener('abort', onAbort, { once: true }) + pending.abortCleanups.add(() => signal.removeEventListener('abort', onAbort)) + if (signal.aborted) onAbort() + } + + private settle(pending: PendingApproval, decision: ApprovalDecision): void { + for (const settle of pending.settlers) settle(decision) + pending.settlers.clear() + } + + private deletePending( + pending: PendingApproval, + reason: Extract['reason'] + ): void { + if (this.pending.get(pending.requestId) !== pending) return + clearTimeout(pending.timeout) + for (const cleanup of pending.abortCleanups) cleanup() + pending.abortCleanups.clear() + this.pending.delete(pending.requestId) + this.emit({ type: 'removed', approval: this.toPublicSnapshot(pending), reason }) + } + + private toSnapshot(pending: PendingApproval): ApprovalSnapshot { + return { + requestId: pending.requestId, + domain: pending.domain, + scopeKey: pending.scopeKey, + operation: pending.operation, + effect: pending.effect, + argumentsHash: pending.argumentsHash, + ...(pending.argumentsPreview !== undefined + ? { argumentsPreview: pending.argumentsPreview } + : {}), + ...(pending.redactedDisplayData !== undefined + ? { redactedDisplayData: cloneJsonValue(pending.redactedDisplayData) } + : {}), + status: pending.status, + expiresAt: pending.expiresAt, + metadata: pending.metadata + } + } + + private toPublicSnapshot(pending: PendingApproval): ApprovalPublicSnapshot { + const { metadata: _metadata, ...approval } = this.toSnapshot(pending) + return approval + } + + private emit(event: ApprovalEvent): void { + queueMicrotask(() => { + for (const listener of this.listeners) { + try { + listener(event) + } catch (error) { + this.log.warn('[ApprovalBroker] Subscriber failed', { + type: event.type, + domain: event.approval.domain, + operation: event.approval.operation, + failure: { name: error instanceof Error ? error.name : typeof error } + }) + } + } + }) + } +} diff --git a/src/main/approval/index.ts b/src/main/approval/index.ts new file mode 100644 index 000000000..78a8f0454 --- /dev/null +++ b/src/main/approval/index.ts @@ -0,0 +1,14 @@ +export { + ApprovalBroker, + ApprovalCapacityError, + hashApprovalArguments, + type ApprovalBinding, + type ApprovalBrokerOptions, + type ApprovalCreateOptions, + type ApprovalDecision, + type ApprovalEvent, + type ApprovalMatch, + type ApprovalPublicSnapshot, + type ApprovalResolution, + type ApprovalSnapshot +} from './approvalBroker' diff --git a/src/main/tool/permission/index.ts b/src/main/tool/permission/index.ts index 20584d34f..60504bcf6 100644 --- a/src/main/tool/permission/index.ts +++ b/src/main/tool/permission/index.ts @@ -4,6 +4,7 @@ export { FilePermissionService, FilePermissionRequiredError } from './filePermis export { SettingsPermissionService } from './settingsPermissionService' export { ToolPermissionBroker, + type ToolPermissionBrokerOptions, type ToolPermissionContext, type ToolPermissionDecision, type ToolPermissionSource diff --git a/src/main/tool/permission/toolPermissionBroker.ts b/src/main/tool/permission/toolPermissionBroker.ts index 9bd1003b4..971271fbf 100644 --- a/src/main/tool/permission/toolPermissionBroker.ts +++ b/src/main/tool/permission/toolPermissionBroker.ts @@ -1,13 +1,16 @@ import type { PermissionMode } from '@shared/types/agent-interface' import type { ToolPermissionPreCheckResult } from '@shared/types/tool' -import { createHash, randomUUID } from 'node:crypto' +import { + ApprovalBroker, + ApprovalCapacityError, + type ApprovalMatch, + type ApprovalSnapshot +} from '@/approval' -const MAX_ARGUMENT_BYTES = 1024 * 1024 -const MAX_ARGUMENT_PREVIEW_BYTES = 16 * 1024 -const MAX_ARGUMENT_DEPTH = 64 -const MAX_ARGUMENT_KEYS = 10_000 const MAX_PENDING_PER_CONVERSATION = 64 -const DEFAULT_REQUEST_TIMEOUT_MS = 2 * 60 * 1000 +const DEFAULT_REQUEST_TIMEOUT_MS = 2 * 60_000 +const TOOL_APPROVAL_DOMAIN = 'tool-permission' +const TOOL_APPROVAL_OPERATION = 'tool.execute' export type ToolPermissionSource = 'model' | 'mcp-app' @@ -32,8 +35,12 @@ export interface ToolPermissionDecision { reason?: 'denied' | 'cancelled' | 'timeout' } -type PendingPermission = { - requestId: string +export type ToolPermissionBrokerOptions = Readonly<{ + approvalBroker?: ApprovalBroker + timeoutMs?: number +}> + +type ToolApprovalMetadata = Readonly<{ conversationId: string serverId: string configGeneration?: number @@ -41,96 +48,71 @@ type PendingPermission = { serverName: string toolName: string executionId?: string - argumentsHash: string - argumentsPreview: string source: ToolPermissionSource permissionType: 'read' | 'write' approvalMode: 'permission_mode' | 'explicit_user' description?: string - status: 'pending' | 'approved' - expiresAt: number - timeout: NodeJS.Timeout - settlers: Set<(decision: ToolPermissionDecision) => void> - abortCleanups: Set<() => void> -} +}> -type CanonicalizeState = { - keys: number - seen: WeakSet +function toolScopeKey(conversationId: string): string { + return `tool:${conversationId}` } -const canonicalize = (value: unknown, state: CanonicalizeState, depth = 0): unknown => { - if (depth > MAX_ARGUMENT_DEPTH) { - throw new Error('Tool arguments exceed the permission depth limit') - } - if (Array.isArray(value)) { - if (state.seen.has(value)) { - throw new Error('Tool arguments must not contain cycles') - } - state.seen.add(value) - const output = value.map((entry) => canonicalize(entry, state, depth + 1)) - state.seen.delete(value) - return output - } - - if (value && typeof value === 'object') { - if (state.seen.has(value)) { - throw new Error('Tool arguments must not contain cycles') - } - state.seen.add(value) - const output = Object.fromEntries( - Object.entries(value as Record) - .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) - .map(([key, entry]) => { - state.keys += 1 - if (state.keys > MAX_ARGUMENT_KEYS) { - throw new Error('Tool arguments exceed the permission key limit') - } - return [key, canonicalize(entry, state, depth + 1)] - }) - ) - state.seen.delete(value) - return output - } - +function toolBindingKey(context: ToolPermissionContext): string { if ( - value !== null && - typeof value !== 'string' && - typeof value !== 'number' && - typeof value !== 'boolean' && - value !== undefined + context.configGeneration !== undefined && + (!Number.isSafeInteger(context.configGeneration) || context.configGeneration <= 0) ) { - throw new Error('Tool arguments must be JSON-compatible') + throw new Error('Tool permission config generation must be a positive safe integer') } - if (typeof value === 'number' && !Number.isFinite(value)) { - throw new Error('Tool arguments must contain only finite numbers') - } - return value + return JSON.stringify([ + context.serverId, + context.configGeneration === undefined + ? ['config-generation-absent'] + : ['config-generation-present', context.configGeneration], + context.bindingHash === undefined + ? ['binding-hash-absent'] + : ['binding-hash-present', context.bindingHash], + context.toolName, + context.executionId === undefined + ? ['execution-id-absent'] + : ['execution-id-present', context.executionId], + context.source, + context.permissionType, + context.approvalMode ?? 'permission_mode' + ]) } -const serializeArguments = (value: unknown): { hash: string; preview: string } => { - const serialized = - JSON.stringify(canonicalize(value, { keys: 0, seen: new WeakSet() })) ?? 'null' - const bytes = Buffer.byteLength(serialized, 'utf8') - if (bytes > MAX_ARGUMENT_BYTES) { - throw new Error(`Tool arguments exceed the ${MAX_ARGUMENT_BYTES}-byte permission limit`) - } - - const preview = - bytes <= MAX_ARGUMENT_PREVIEW_BYTES - ? serialized - : `${Buffer.from(serialized).subarray(0, MAX_ARGUMENT_PREVIEW_BYTES).toString('utf8')}…` - +function toMetadata(context: ToolPermissionContext): ToolApprovalMetadata { return { - hash: createHash('sha256').update(serialized).digest('hex'), - preview + conversationId: context.conversationId, + serverId: context.serverId, + configGeneration: context.configGeneration, + bindingHash: context.bindingHash, + serverName: context.serverName, + toolName: context.toolName, + executionId: context.executionId, + source: context.source, + permissionType: context.permissionType, + approvalMode: context.approvalMode ?? 'permission_mode', + description: context.description } } export class ToolPermissionBroker { - private readonly pending = new Map() - - constructor(private readonly timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {} + private readonly approvals: ApprovalBroker + private readonly timeoutMs: number + + constructor(options: number | ToolPermissionBrokerOptions = {}) { + const normalized = typeof options === 'number' ? { timeoutMs: options } : options + this.timeoutMs = normalized.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS + this.approvals = + normalized.approvalBroker ?? + new ApprovalBroker({ + defaultTimeoutMs: this.timeoutMs, + maxPendingPerScope: MAX_PENDING_PER_CONVERSATION + }) + } evaluateModel( context: ToolPermissionContext, @@ -140,8 +122,7 @@ export class ToolPermissionBroker { return null } - const pending = this.createPending(context, signal) - return this.toPermissionRequest(pending) + return this.toPermissionRequest(this.createPending(context, signal)) } authorizeExecution( @@ -157,27 +138,7 @@ export class ToolPermissionBroker { return { allowed: true } } - const { hash } = serializeArguments(context.arguments) - const approved = Array.from(this.pending.values()).find( - (entry) => - entry.status === 'approved' && - entry.conversationId === context.conversationId && - entry.serverId === context.serverId && - entry.configGeneration === context.configGeneration && - entry.bindingHash === context.bindingHash && - entry.toolName === context.toolName && - entry.executionId === context.executionId && - entry.argumentsHash === hash && - entry.source === context.source && - entry.permissionType === context.permissionType && - entry.approvalMode === (context.approvalMode ?? 'permission_mode') - ) - - if (approved) { - this.deletePending(approved.requestId) - return { allowed: true } - } - + if (this.approvals.consumeApproved(this.toMatch(context))) return { allowed: true } const pending = this.createPending(context, signal) return { allowed: false, request: this.toPermissionRequest(pending) } } @@ -192,209 +153,117 @@ export class ToolPermissionBroker { ) { return { allowed: true } } - const pending = this.createPending({ ...context, source: 'mcp-app' }) + const appContext = { ...context, source: 'mcp-app' as const } + const pending = this.createPending(appContext) + const decision = this.approvals.wait(pending.requestId) try { onRequest(this.toPermissionRequest(pending)) } catch { - this.deletePending(pending.requestId) - return { allowed: false, reason: 'denied' } + this.approvals.resolve({ + requestId: pending.requestId, + scopeKey: toolScopeKey(context.conversationId), + decision: 'denied' + }) } - return await new Promise((resolve) => { - pending.settlers.add(resolve) - }) + return await decision } approve(requestId: string, conversationId: string): boolean { - const pending = this.pending.get(requestId) - if ( - !pending || - pending.status !== 'pending' || - pending.conversationId !== conversationId || - pending.expiresAt <= Date.now() - ) { - return false - } - - if (pending.source === 'mcp-app') { - this.settleAppPermission(pending, { allowed: true }) - this.deletePending(requestId) - return true - } - - pending.status = 'approved' - return true + return this.approvals.resolve({ + requestId, + scopeKey: toolScopeKey(conversationId), + decision: 'approved' + }) } deny(requestId: string, conversationId: string): boolean { - return this.resolveDenied(requestId, conversationId, 'denied') + return this.approvals.resolve({ + requestId, + scopeKey: toolScopeKey(conversationId), + decision: 'denied' + }) } cancel(requestId: string, conversationId: string): boolean { - return this.resolveDenied(requestId, conversationId, 'cancelled') + return this.approvals.resolve({ + requestId, + scopeKey: toolScopeKey(conversationId), + decision: 'cancelled' + }) } cancelConversation(conversationId: string): void { - for (const pending of this.pending.values()) { - if (pending.conversationId === conversationId) { - this.settleAppPermission(pending, { allowed: false, reason: 'cancelled' }) - this.deletePending(pending.requestId) - } - } + this.approvals.cancelScope(toolScopeKey(conversationId)) } clear(): void { - for (const pending of this.pending.values()) { - this.settleAppPermission(pending, { allowed: false, reason: 'cancelled' }) - this.deletePending(pending.requestId) - } + this.approvals.clearDomain(TOOL_APPROVAL_DOMAIN) } - private createPending(context: ToolPermissionContext, signal?: AbortSignal): PendingPermission { - signal?.throwIfAborted() - this.pruneExpired() - const { hash, preview } = serializeArguments(context.arguments) - const existing = Array.from(this.pending.values()).find( - (entry) => - entry.status === 'pending' && - entry.conversationId === context.conversationId && - entry.serverId === context.serverId && - entry.configGeneration === context.configGeneration && - entry.bindingHash === context.bindingHash && - entry.toolName === context.toolName && - entry.executionId === context.executionId && - entry.argumentsHash === hash && - entry.source === context.source && - entry.permissionType === context.permissionType && - entry.approvalMode === (context.approvalMode ?? 'permission_mode') - ) - if (existing) { - this.attachAbort(existing, signal) - return existing - } - const conversationPending = Array.from(this.pending.values()).filter( - (entry) => entry.conversationId === context.conversationId - ).length - if (conversationPending >= MAX_PENDING_PER_CONVERSATION) { - throw new Error('Too many pending tool permission requests') - } - - const requestId = randomUUID() - const expiresAt = Date.now() + this.timeoutMs - const pending: PendingPermission = { - requestId, - conversationId: context.conversationId, - serverId: context.serverId, - configGeneration: context.configGeneration, - bindingHash: context.bindingHash, - serverName: context.serverName, - toolName: context.toolName, - executionId: context.executionId, - argumentsHash: hash, - argumentsPreview: preview, - source: context.source, - permissionType: context.permissionType, - approvalMode: context.approvalMode ?? 'permission_mode', - description: context.description, - status: 'pending', - expiresAt, - settlers: new Set(), - abortCleanups: new Set(), - timeout: setTimeout(() => { - const current = this.pending.get(requestId) - if (current) { - this.settleAppPermission(current, { allowed: false, reason: 'timeout' }) + private createPending( + context: ToolPermissionContext, + signal?: AbortSignal + ): ApprovalSnapshot { + try { + return this.approvals.create( + { + domain: TOOL_APPROVAL_DOMAIN, + scopeKey: toolScopeKey(context.conversationId), + operation: TOOL_APPROVAL_OPERATION, + effect: context.permissionType, + bindingKey: toolBindingKey(context), + arguments: context.arguments, + metadata: toMetadata(context) + }, + { + deduplicatePending: true, + includeArgumentsPreview: true, + consumeOnApprove: context.source === 'mcp-app', + timeoutMs: this.timeoutMs, + signal } - this.deletePending(requestId) - }, this.timeoutMs) + ) + } catch (error) { + if (error instanceof ApprovalCapacityError) { + throw new Error('Too many pending tool permission requests') + } + throw error } + } - this.pending.set(requestId, pending) - this.attachAbort(pending, signal) - return pending + private toMatch(context: ToolPermissionContext): ApprovalMatch { + return { + domain: TOOL_APPROVAL_DOMAIN, + scopeKey: toolScopeKey(context.conversationId), + operation: TOOL_APPROVAL_OPERATION, + effect: context.permissionType, + bindingKey: toolBindingKey(context), + arguments: context.arguments + } } - private toPermissionRequest(pending: PendingPermission): ToolPermissionPreCheckResult { + private toPermissionRequest( + pending: ApprovalSnapshot + ): ToolPermissionPreCheckResult { + const metadata = pending.metadata return { needsPermission: true, requestId: pending.requestId, - conversationId: pending.conversationId, - toolName: pending.toolName, - serverName: pending.serverName, - permissionType: pending.permissionType, + conversationId: metadata.conversationId, + toolName: metadata.toolName, + serverName: metadata.serverName, + permissionType: metadata.permissionType, description: - pending.description ?? - `components.messageBlockPermissionRequest.description.${pending.permissionType}`, + metadata.description ?? + `components.messageBlockPermissionRequest.description.${metadata.permissionType}`, rememberable: false, - ...(pending.approvalMode === 'explicit_user' ? { requiresUserConfirmation: true } : {}), - source: pending.source, - serverId: pending.serverId, - configGeneration: pending.configGeneration, - bindingHash: pending.bindingHash, + ...(metadata.approvalMode === 'explicit_user' ? { requiresUserConfirmation: true } : {}), + source: metadata.source, + serverId: metadata.serverId, + configGeneration: metadata.configGeneration, + bindingHash: metadata.bindingHash, argumentsHash: pending.argumentsHash, argumentsPreview: pending.argumentsPreview } } - - private resolveDenied( - requestId: string, - conversationId: string, - reason: 'denied' | 'cancelled' - ): boolean { - const pending = this.pending.get(requestId) - if (!pending || pending.conversationId !== conversationId) { - return false - } - - this.settleAppPermission(pending, { allowed: false, reason }) - this.deletePending(requestId) - return true - } - - private deletePending(requestId: string): void { - const pending = this.pending.get(requestId) - if (!pending) { - return - } - clearTimeout(pending.timeout) - for (const cleanup of pending.abortCleanups) { - cleanup() - } - pending.abortCleanups.clear() - this.pending.delete(requestId) - } - - private pruneExpired(): void { - const now = Date.now() - for (const pending of this.pending.values()) { - if (pending.expiresAt <= now) { - this.settleAppPermission(pending, { allowed: false, reason: 'timeout' }) - this.deletePending(pending.requestId) - } - } - } - - private attachAbort(pending: PendingPermission, signal?: AbortSignal): void { - if (!signal) { - return - } - const onAbort = () => { - if (this.pending.get(pending.requestId) !== pending) { - return - } - this.settleAppPermission(pending, { allowed: false, reason: 'cancelled' }) - this.deletePending(pending.requestId) - } - signal.addEventListener('abort', onAbort, { once: true }) - pending.abortCleanups.add(() => signal.removeEventListener('abort', onAbort)) - if (signal.aborted) { - onAbort() - } - } - - private settleAppPermission(pending: PendingPermission, decision: ToolPermissionDecision): void { - for (const settle of pending.settlers) { - settle(decision) - } - pending.settlers.clear() - } } diff --git a/test/main/approval/approvalBroker.test.ts b/test/main/approval/approvalBroker.test.ts new file mode 100644 index 000000000..0bc3a7eef --- /dev/null +++ b/test/main/approval/approvalBroker.test.ts @@ -0,0 +1,207 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ApprovalBroker, ApprovalCapacityError, type ApprovalEvent } from '@/approval' + +const binding = (argumentsValue: unknown, scopeKey = 'scope-1') => ({ + domain: 'test', + scopeKey, + operation: 'resource.update', + effect: 'write', + bindingKey: 'resource-1', + arguments: argumentsValue, + metadata: { privateValue: 'metadata-secret' } +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('ApprovalBroker', () => { + it('deduplicates canonical pending bindings and consumes approval exactly once', () => { + const broker = new ApprovalBroker() + const first = broker.create(binding({ z: 1, a: 2 }), { + deduplicatePending: true, + includeArgumentsPreview: true + }) + const second = broker.create(binding({ a: 2, z: 1 }), { + deduplicatePending: true, + includeArgumentsPreview: true + }) + + expect(second.requestId).toBe(first.requestId) + expect(first.argumentsPreview).toBe('{"a":2,"z":1}') + expect( + broker.resolve({ + requestId: first.requestId, + scopeKey: first.scopeKey, + decision: 'approved' + }) + ).toBe(true) + + const match = { + domain: first.domain, + scopeKey: first.scopeKey, + operation: first.operation, + effect: first.effect, + bindingKey: 'resource-1', + arguments: { a: 2, z: 1 } + } + expect(broker.consumeApproved(match)).toBe(true) + expect(broker.consumeApproved(match)).toBe(false) + }) + + it('keeps identical non-deduplicated approvals isolated', () => { + const broker = new ApprovalBroker() + const first = broker.create(binding({ value: 1 })) + const second = broker.create(binding({ value: 1 })) + + expect(first.requestId).not.toBe(second.requestId) + expect( + broker.resolve({ + requestId: first.requestId, + scopeKey: first.scopeKey, + decision: 'approved' + }) + ).toBe(true) + expect( + broker.consumeApproved({ + requestId: second.requestId, + domain: second.domain, + scopeKey: second.scopeKey, + operation: second.operation, + effect: second.effect, + bindingKey: 'resource-1', + arguments: { value: 1 } + }) + ).toBe(false) + broker.clear() + }) + + it('settles every waiter before removing consume-on-approve entries', async () => { + const broker = new ApprovalBroker() + const first = broker.create(binding({ value: 1 }), { + deduplicatePending: true, + consumeOnApprove: true + }) + const second = broker.create(binding({ value: 1 }), { + deduplicatePending: true, + consumeOnApprove: true + }) + const firstDecision = broker.wait(first.requestId) + const secondDecision = broker.wait(second.requestId) + + expect( + broker.resolve({ + requestId: first.requestId, + scopeKey: first.scopeKey, + decision: 'approved' + }) + ).toBe(true) + await expect(firstDecision).resolves.toEqual({ allowed: true }) + await expect(secondDecision).resolves.toEqual({ allowed: true }) + expect( + broker.resolve({ + requestId: first.requestId, + scopeKey: first.scopeKey, + decision: 'approved' + }) + ).toBe(false) + }) + + it('settles timeout and abort without leaving resolvable requests', async () => { + vi.useFakeTimers() + const broker = new ApprovalBroker({ defaultTimeoutMs: 50 }) + const timed = broker.create(binding({ value: 1 })) + const timedDecision = broker.wait(timed.requestId) + await vi.advanceTimersByTimeAsync(50) + await expect(timedDecision).resolves.toEqual({ allowed: false, reason: 'timeout' }) + + const controller = new AbortController() + const aborted = broker.create(binding({ value: 2 })) + const abortedDecision = broker.wait(aborted.requestId, controller.signal) + controller.abort() + await expect(abortedDecision).resolves.toEqual({ allowed: false, reason: 'cancelled' }) + expect( + broker.resolve({ + requestId: aborted.requestId, + scopeKey: aborted.scopeKey, + decision: 'approved' + }) + ).toBe(false) + }) + + it('cancels only the selected scope and enforces its pending limit', async () => { + const broker = new ApprovalBroker({ maxPendingPerScope: 1 }) + const first = broker.create(binding({ value: 1 }, 'scope-a')) + const other = broker.create(binding({ value: 1 }, 'scope-b')) + const firstDecision = broker.wait(first.requestId) + + expect(() => broker.create(binding({ value: 2 }, 'scope-a'))).toThrow(ApprovalCapacityError) + broker.cancelScope('scope-a') + await expect(firstDecision).resolves.toEqual({ allowed: false, reason: 'cancelled' }) + expect( + broker.resolve({ + requestId: other.requestId, + scopeKey: other.scopeKey, + decision: 'approved' + }) + ).toBe(true) + broker.clear() + }) + + it('clears one adapter domain without cancelling another', async () => { + const broker = new ApprovalBroker() + const tool = broker.create(binding({ value: 1 })) + const cli = broker.create({ + ...binding({ value: 1 }), + domain: 'cli-mutation', + bindingKey: 'cli-request-1' + }) + const toolDecision = broker.wait(tool.requestId) + const cliDecision = broker.wait(cli.requestId) + + broker.clearDomain('test') + await expect(toolDecision).resolves.toEqual({ allowed: false, reason: 'cancelled' }) + expect( + broker.resolve({ + requestId: cli.requestId, + scopeKey: cli.scopeKey, + decision: 'approved' + }) + ).toBe(true) + await expect(cliDecision).resolves.toEqual({ allowed: true }) + broker.clear() + }) + + it('publishes only explicitly redacted display data', async () => { + const broker = new ApprovalBroker() + const events: ApprovalEvent[] = [] + broker.subscribe((event) => events.push(event)) + const pending = broker.create({ + ...binding({ credential: 'argument-secret' }), + redactedDisplayData: { title: 'Update provider credential', providerId: 'provider-1' } + }) + await Promise.resolve() + + const serializedEvent = JSON.stringify(events[0]) + expect(serializedEvent).toContain('Update provider credential') + expect(serializedEvent).not.toContain('argument-secret') + expect(serializedEvent).not.toContain('metadata-secret') + expect(events[0]).toMatchObject({ + type: 'created', + approval: { + requestId: pending.requestId, + argumentsHash: expect.stringMatching(/^[a-f0-9]{64}$/) + } + }) + expect(events[0]?.approval).not.toHaveProperty('argumentsPreview') + broker.clear() + }) + + it('rejects cyclic arguments before allocating a request', () => { + const broker = new ApprovalBroker() + const cyclic: Record = {} + cyclic.self = cyclic + + expect(() => broker.create(binding(cyclic))).toThrow('must not contain cycles') + }) +}) diff --git a/test/main/tool/toolPermissionBroker.test.ts b/test/main/tool/toolPermissionBroker.test.ts index 19d6f37c7..7bc00a35c 100644 --- a/test/main/tool/toolPermissionBroker.test.ts +++ b/test/main/tool/toolPermissionBroker.test.ts @@ -24,6 +24,25 @@ describe('ToolPermissionBroker', () => { await expect(second).resolves.toEqual({ allowed: true }) }) + it('does not lose an MCP App decision resolved inside the request callback', async () => { + const broker = new ToolPermissionBroker() + const context = { + conversationId: 'conversation', + serverId: 'server', + serverName: 'fixture', + toolName: 'read', + arguments: { path: '/tmp/example' }, + permissionType: 'read' as const, + permissionMode: 'default' as const + } + + await expect( + broker.requestAppDecision(context, (request) => { + expect(broker.approve(request.requestId!, context.conversationId)).toBe(true) + }) + ).resolves.toEqual({ allowed: true }) + }) + it('does not reuse a model approval for changed arguments or an MCP App source', () => { const broker = new ToolPermissionBroker() const base = { From c65857b604b41f58af62f2cdec4a5b69c7ebca19 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 14:07:59 +0800 Subject: [PATCH 13/51] feat(cli): enforce mutation approvals --- .../architecture/local-control-plane/tasks.md | 10 +- src/main/app/composition.ts | 87 +++++- src/main/approval/index.ts | 1 + src/main/approval/routes.ts | 24 ++ src/main/cli/auditLog.ts | 161 ++++++++++ src/main/cli/index.ts | 12 + src/main/cli/mutationGuard.ts | 225 ++++++++++++++ src/main/cli/policy.ts | 279 ++++++++++++++++++ src/main/cli/routes.ts | 4 +- src/main/cli/server.ts | 30 +- src/main/cli/surface.ts | 54 ++++ .../electronWindowNotificationTargets.ts | 17 +- src/renderer/api/ApprovalClient.ts | 27 ++ .../src/apps/chat-main/ChatMainApp.vue | 2 + .../src/components/cli/CliApprovalDialog.vue | 71 +++++ src/renderer/src/stores/cliApproval.ts | 63 ++++ src/shared/contracts/events.ts | 4 + .../contracts/events/approvals.events.ts | 28 ++ src/shared/contracts/routes.ts | 3 + .../contracts/routes/approvals.routes.ts | 19 ++ test/main/approval/routes.test.ts | 43 +++ test/main/cli/auditLog.test.ts | 123 ++++++++ test/main/cli/mutationGuard.test.ts | 225 ++++++++++++++ test/main/cli/policy.test.ts | 203 +++++++++++++ test/main/cli/server.test.ts | 24 +- test/main/cli/surface.test.ts | 1 + test/renderer/stores/cliApproval.test.ts | 164 ++++++++++ 27 files changed, 1887 insertions(+), 17 deletions(-) create mode 100644 src/main/approval/routes.ts create mode 100644 src/main/cli/auditLog.ts create mode 100644 src/main/cli/mutationGuard.ts create mode 100644 src/main/cli/policy.ts create mode 100644 src/renderer/api/ApprovalClient.ts create mode 100644 src/renderer/src/components/cli/CliApprovalDialog.vue create mode 100644 src/renderer/src/stores/cliApproval.ts create mode 100644 src/shared/contracts/events/approvals.events.ts create mode 100644 src/shared/contracts/routes/approvals.routes.ts create mode 100644 test/main/approval/routes.test.ts create mode 100644 test/main/cli/auditLog.test.ts create mode 100644 test/main/cli/mutationGuard.test.ts create mode 100644 test/main/cli/policy.test.ts create mode 100644 test/renderer/stores/cliApproval.test.ts diff --git a/docs/architecture/local-control-plane/tasks.md b/docs/architecture/local-control-plane/tasks.md index 4a3d50340..b820530f3 100644 --- a/docs/architecture/local-control-plane/tasks.md +++ b/docs/architecture/local-control-plane/tasks.md @@ -41,7 +41,7 @@ - [x] Add explicit upload and owned-artifact extraction contracts and handlers. - [x] Preserve automatic-attachment-setting independence and background priority. - [x] Enforce bounded text output and exclude layout/batch/model administration. -- [ ] Classify cache clear as audited human-only `local-maintenance` without approval. +- [x] Classify cache clear as audited human-only `local-maintenance` without approval. - [x] Report cache hit, warm-runtime miss, cold-runtime, and offline metrics accurately. - [ ] Add OCR caller, input, cache, runtime-state, output-bound, and benchmark tests. @@ -49,10 +49,10 @@ - [x] Extract generic canonicalization/pending/timeout/consume mechanics into `ApprovalBroker`. - [x] Preserve MCP, Agent pre-check, and live-delegation behavior through `ToolPermissionBroker`. -- [ ] Add `CliMutationGuard` with unique live-request-bound approvals and no replay token. -- [ ] Add targeted approval events and renderer-only `approvals.resolve` IPC. -- [ ] Implement effect/caller/operation policy, scopes, quotas, rate limits, and redacted audit. -- [ ] Add concurrent-identical-call, timeout, abort, cancellation, redaction, and compatibility tests. +- [x] Add `CliMutationGuard` with unique live-request-bound approvals and no replay token. +- [x] Add targeted approval events and renderer-only `approvals.resolve` IPC. +- [x] Implement effect/caller/operation policy, scopes, quotas, rate limits, and redacted audit. +- [x] Add concurrent-identical-call, timeout, abort, cancellation, redaction, and compatibility tests. ## Administration Surface diff --git a/src/main/app/composition.ts b/src/main/app/composition.ts index 53d7dbf3c..4f04ca15a 100644 --- a/src/main/app/composition.ts +++ b/src/main/app/composition.ts @@ -1,6 +1,11 @@ import logger from '@shared/logger' import { projectEnvironmentsChangedEvent } from '@shared/contracts/events/project.events' -import { liveDelegationChangedEvent, sessionsUpdatedEvent } from '@shared/contracts/events' +import { + approvalClosedEvent, + approvalRequestedEvent, + liveDelegationChangedEvent, + sessionsUpdatedEvent +} from '@shared/contracts/events' import { performance } from 'node:perf_hooks' import path from 'path' import { DialogService } from '../desktop/dialog' @@ -105,7 +110,7 @@ import { createPlatformRoutes } from '../platform/routes' import { createHookRoutes } from '../hook/routes' import { createAppSettingsRoutes } from './settingsRoutes' import { createAppRoutes } from './routes' -import { ApprovalBroker } from '@/approval' +import { ApprovalBroker, createApprovalRoutes } from '@/approval' import { CommandPermissionService, FilePermissionService, @@ -211,8 +216,11 @@ import { createNodeScheduler } from '@/routes/scheduler' import { ArtifactSpool, CliAudioTranscriptionService, + CliAuditLog, CliComputeService, + CliMutationGuard, CliOcrService, + CliRequestPolicy, CliServer, createArtifactRoutes, createCliComputeRoutes, @@ -351,6 +359,8 @@ export async function createMainProcessControl(dependencies: { let cliComputeService: CliComputeService let cliAudioTranscriptionService: CliAudioTranscriptionService let cliOcrService: CliOcrService + let cliMutationGuard: CliMutationGuard + let cliRequestPolicy: CliRequestPolicy let hasInitialized = false let databaseMaintenanceState: 'running' | 'maintenance' | 'failed' = 'running' let appLifecycleState: 'starting' | 'running' | 'stopping' | 'stopped' = 'starting' @@ -375,6 +385,9 @@ export async function createMainProcessControl(dependencies: { directory: path.join(app.getPath('userData'), 'local-control', 'artifacts'), log: logger }) + const cliAuditLog = new CliAuditLog({ + directory: path.join(app.getPath('userData'), 'local-control') + }) const cliServer = new CliServer({ userDataPath: app.getPath('userData'), appVersion: app.getVersion(), @@ -414,6 +427,10 @@ export async function createMainProcessControl(dependencies: { } throw new Error(`CLI upload service is not ready for ${method}`) }, + authorize: async (input) => { + if (!cliRequestPolicy) throw new Error('CLI request policy is not ready') + return await cliRequestPolicy.authorize(input) + }, artifactSpool, log: logger }) @@ -423,10 +440,14 @@ export async function createMainProcessControl(dependencies: { semanticNotificationScheduler ) let handleSemanticRendererUnavailable = (_webContentsId: number): void => undefined + let handleApprovalRendererUnavailable = (_webContentsId: number): void => undefined const semanticNotificationTargets = new ElectronWindowNotificationTargets( windowPresenter, () => tabPresenter, - (webContentsId) => handleSemanticRendererUnavailable(webContentsId) + (webContentsId) => { + handleSemanticRendererUnavailable(webContentsId) + handleApprovalRendererUnavailable(webContentsId) + } ) const semanticNotificationDiagnostics = new AggregatedWindowNotificationDiagnostics({ scheduler: semanticNotificationScheduler, @@ -701,6 +722,54 @@ export async function createMainProcessControl(dependencies: { settingsPermissionService = new SettingsPermissionService() approvalBroker = new ApprovalBroker({ log: logger }) toolPermissionBroker = new ToolPermissionBroker({ approvalBroker }) + cliMutationGuard = new CliMutationGuard(approvalBroker, { + getTarget: async () => { + const focused = await semanticNotificationTargets.getFocusedTarget() + const target = + focused?.kind === 'main' + ? focused + : (await semanticNotificationTargets.getExistingTargets()).find( + (candidate) => candidate.kind === 'main' + ) + return target ? { windowId: target.windowId, webContentsId: target.webContentsId } : null + }, + present: async (target, payload) => { + const readyTarget = await semanticNotificationTargets.getTargetForWindow(target.windowId) + if ( + !readyTarget || + readyTarget.kind !== 'main' || + readyTarget.webContentsId !== target.webContentsId + ) { + return false + } + windowPresenter.show(target.windowId, true) + return await semanticNotificationTargets.sendDeepchatEvent( + readyTarget, + approvalRequestedEvent.name, + payload + ) + }, + close: async (target, payload) => { + const readyTarget = await semanticNotificationTargets.getTargetByWebContents( + target.webContentsId + ) + if (!readyTarget || readyTarget.kind !== 'main' || readyTarget.windowId !== target.windowId) { + return + } + await semanticNotificationTargets.sendDeepchatEvent( + readyTarget, + approvalClosedEvent.name, + payload + ) + } + }) + handleApprovalRendererUnavailable = (webContentsId) => { + cliMutationGuard.cancelRenderer(webContentsId) + } + cliRequestPolicy = new CliRequestPolicy({ + mutationGuard: cliMutationGuard, + audit: (record) => cliAuditLog.record(record) + }) const liveDelegationConsent = new LiveDelegationConsentAuthority() deviceService = new DeviceService() const loggingService = new LoggingService( @@ -1941,6 +2010,8 @@ export async function createMainProcessControl(dependencies: { async function destroy(): Promise { await runDestroyStep('cliServer.stop', () => cliServer.stop()) + await runDestroyStep('cliMutationGuard.clear', () => cliMutationGuard.clear()) + await runDestroyStep('cliAuditLog.close', () => cliAuditLog.close()) await runDestroyStep('artifactSpool.close', () => artifactSpool.close()) await runDestroyStep('providerCatalog.unsubscribe', () => unsubscribeProviderDbCatalog()) await runDestroyStep('liveDelegationService.stop', () => liveDelegationService.stop()) @@ -2302,8 +2373,13 @@ export async function createMainProcessControl(dependencies: { const cliRoutes = createCliRoutes({ appVersion: app.getVersion(), getStatus: () => cliServer.getStatus(), - hasTrustedRenderer: () => - windowPresenter.getAllWindows().some((window) => !window.isDestroyed()) + hasTrustedRenderer: async () => + (await semanticNotificationTargets.getExistingTargets()).some( + (target) => target.kind === 'main' + ) + }) + const approvalRoutes = createApprovalRoutes({ + resolve: (input, caller) => cliMutationGuard.resolve(input, caller) }) const artifactRoutes = createArtifactRoutes(artifactSpool) const cliComputeRoutes = createCliComputeRoutes(cliComputeService) @@ -2341,6 +2417,7 @@ export async function createMainProcessControl(dependencies: { notificationRoutes, appSettingsRoutes, appRoutes, + approvalRoutes, cliRoutes, artifactRoutes, cliComputeRoutes diff --git a/src/main/approval/index.ts b/src/main/approval/index.ts index 78a8f0454..3dc2d43d1 100644 --- a/src/main/approval/index.ts +++ b/src/main/approval/index.ts @@ -12,3 +12,4 @@ export { type ApprovalResolution, type ApprovalSnapshot } from './approvalBroker' +export { createApprovalRoutes, type ApprovalRoutesDependencies } from './routes' diff --git a/src/main/approval/routes.ts b/src/main/approval/routes.ts new file mode 100644 index 000000000..59d9afdaa --- /dev/null +++ b/src/main/approval/routes.ts @@ -0,0 +1,24 @@ +import { approvalsResolveRoute } from '@shared/contracts/routes' +import { createRouteMap, requireRendererCaller } from '@/routes/routeRegistry' + +export type ApprovalRoutesDependencies = Readonly<{ + resolve( + input: { requestId: string; decision: 'approved' | 'denied' }, + caller: ReturnType + ): boolean +}> + +export function createApprovalRoutes(dependencies: ApprovalRoutesDependencies) { + return createRouteMap([ + [ + approvalsResolveRoute.name, + async (rawInput, context) => { + const input = approvalsResolveRoute.input.parse(rawInput) + const caller = requireRendererCaller(context) + return approvalsResolveRoute.output.parse({ + accepted: dependencies.resolve(input, caller) + }) + } + ] + ]) +} diff --git a/src/main/cli/auditLog.ts b/src/main/cli/auditLog.ts new file mode 100644 index 000000000..7dcf2db06 --- /dev/null +++ b/src/main/cli/auditLog.ts @@ -0,0 +1,161 @@ +import { constants } from 'node:fs' +import { chmod, lstat, mkdir, open, rename, unlink, type FileHandle } from 'node:fs/promises' +import path from 'node:path' +import type { CliPolicyAuditRecord } from './policy' + +const DEFAULT_MAX_AUDIT_BYTES = 10 * 1024 * 1024 + +export type CliAuditLogOptions = Readonly<{ + directory: string + maxBytes?: number +}> + +async function removeIfPresent(filePath: string): Promise { + try { + await unlink(filePath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } +} + +function positiveSafeInteger(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} must be positive`) + return value +} + +export class CliAuditLog { + private readonly filePath: string + private readonly rotatedPath: string + private readonly maxBytes: number + private handle: FileHandle | undefined + private size = 0 + private accepting = true + private tail = Promise.resolve() + private closePromise: Promise | undefined + + constructor(private readonly options: CliAuditLogOptions) { + this.maxBytes = positiveSafeInteger(options.maxBytes ?? DEFAULT_MAX_AUDIT_BYTES, 'maxBytes') + this.filePath = path.join(options.directory, 'audit.jsonl') + this.rotatedPath = path.join(options.directory, 'audit.1.jsonl') + } + + record(record: CliPolicyAuditRecord): Promise { + if (!this.accepting) return Promise.reject(new Error('CLI audit log is closed')) + const serialized = Buffer.from(`${JSON.stringify(record)}\n`, 'utf8') + if (serialized.length > this.maxBytes) { + return Promise.reject(new Error('CLI audit record exceeds the audit file limit')) + } + + const write = this.tail.catch(() => undefined).then(() => this.append(serialized)) + this.tail = write.catch(() => undefined) + return write + } + + async close(): Promise { + if (this.closePromise) return await this.closePromise + this.accepting = false + this.closePromise = (async () => { + await this.tail + await this.closeHandle() + })() + return await this.closePromise + } + + private async append(serialized: Buffer): Promise { + await this.ensureOpen() + if (this.size + serialized.length > this.maxBytes) { + await this.rotate() + } + if (!this.handle) throw new Error('CLI audit log is unavailable') + let offset = 0 + while (offset < serialized.length) { + const { bytesWritten } = await this.handle.write( + serialized, + offset, + serialized.length - offset + ) + if (bytesWritten === 0) throw new Error('CLI audit write made no progress') + offset += bytesWritten + } + this.size += serialized.length + } + + private async ensureOpen(): Promise { + if (this.handle) return + await mkdir(this.options.directory, { recursive: true, mode: 0o700 }) + const directoryStats = await lstat(this.options.directory) + if (!directoryStats.isDirectory()) throw new Error('CLI audit directory is not a directory') + if (process.platform !== 'win32') { + if (typeof process.getuid === 'function' && directoryStats.uid !== process.getuid()) { + throw new Error('CLI audit directory is not owned by the current user') + } + await chmod(this.options.directory, 0o700) + } + const handle = await this.openAuditFile() + try { + if (process.platform !== 'win32') await handle.chmod(0o600) + const stats = await handle.stat() + if (!stats.isFile()) throw new Error('CLI audit path is not a regular file') + if (process.platform !== 'win32') { + if (typeof process.getuid === 'function' && stats.uid !== process.getuid()) { + throw new Error('CLI audit file is not owned by the current user') + } + if (stats.nlink !== 1) throw new Error('CLI audit file must not have multiple links') + } + this.handle = handle + this.size = stats.size + } catch (error) { + await handle.close().catch(() => undefined) + throw error + } + } + + private async openAuditFile(): Promise { + const noFollow = process.platform === 'win32' ? 0 : constants.O_NOFOLLOW + const existingFlags = constants.O_APPEND | constants.O_WRONLY | noFollow + const createFlags = + constants.O_APPEND | constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY + + for (let attempt = 0; attempt < 8; attempt += 1) { + if (process.platform === 'win32') { + try { + const stats = await lstat(this.filePath) + if (!stats.isFile()) throw new Error('CLI audit path is not a regular file') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + } + try { + return await open(this.filePath, existingFlags) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + + try { + return await open(this.filePath, createFlags, 0o600) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + } + } + + throw new Error('CLI audit file changed repeatedly while opening') + } + + private async rotate(): Promise { + await this.closeHandle() + await removeIfPresent(this.rotatedPath) + try { + await rename(this.filePath, this.rotatedPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + await this.ensureOpen() + } + + private async closeHandle(): Promise { + const handle = this.handle + this.handle = undefined + this.size = 0 + if (handle) await handle.close() + } +} diff --git a/src/main/cli/index.ts b/src/main/cli/index.ts index 3c2df8c01..5546ceefc 100644 --- a/src/main/cli/index.ts +++ b/src/main/cli/index.ts @@ -1,4 +1,5 @@ export { CliServer, type CliServerDependencies } from './server' +export { CliAuditLog, type CliAuditLogOptions } from './auditLog' export { ArtifactSpool, type ArtifactSpoolOptions } from './artifactSpool' export { createArtifactRoutes } from './artifactRoutes' export { CliComputeService, createCliComputeRoutes } from './computeService' @@ -9,3 +10,14 @@ export { export { CliOcrService, type CliOcrServiceOptions } from './ocrService' export { createCliRoutes, type CliRuntimeStatus } from './routes' export { CLI_SURFACE_V1, getCliSurfaceEntry, listCliSurfaceCapabilities } from './surface' +export { + CliMutationGuard, + type CliApprovalPresentationPort, + type CliApprovalTarget +} from './mutationGuard' +export { + CliRequestPolicy, + type CliPolicyAuditRecord, + type CliRequestAdmission, + type CliRequestPolicyInput +} from './policy' diff --git a/src/main/cli/mutationGuard.ts b/src/main/cli/mutationGuard.ts new file mode 100644 index 000000000..41bc9e6a1 --- /dev/null +++ b/src/main/cli/mutationGuard.ts @@ -0,0 +1,225 @@ +import { randomUUID } from 'node:crypto' +import type { DeepchatEventPayload } from '@shared/contracts/events' +import type { LocalControlEffect, LocalControlPrincipal } from '@shared/contracts/localControl' +import type { JsonValue } from '@shared/contracts/json' +import type { RendererRouteCaller } from '@/routes/routeRegistry' +import { ApprovalBroker, ApprovalCapacityError, type ApprovalDecision } from '@/approval' +import { CliRequestError } from './errors' + +const CLI_APPROVAL_DOMAIN = 'cli-mutation' +const DEFAULT_APPROVAL_TIMEOUT_MS = 2 * 60_000 + +export type CliApprovalTarget = Readonly<{ + windowId: number + webContentsId: number +}> + +export type CliApprovalPresentationPort = Readonly<{ + getTarget(): Promise + present( + target: CliApprovalTarget, + payload: DeepchatEventPayload<'approvals.requested'> + ): Promise + close(target: CliApprovalTarget, payload: DeepchatEventPayload<'approvals.closed'>): Promise +}> + +export type CliMutationApprovalInput = Readonly<{ + operation: string + effect: LocalControlEffect + principal: LocalControlPrincipal + connectionId: string + clientRequestId: string + arguments: unknown + displayData?: JsonValue + signal: AbortSignal + timeoutMs?: number +}> + +export type CliMutationApproval = Readonly<{ + approvalRequestId: string +}> + +type PendingTarget = Readonly<{ + scopeKey: string + target: CliApprovalTarget +}> + +function decisionCloseReason( + decision: ApprovalDecision +): DeepchatEventPayload<'approvals.closed'>['reason'] { + if (decision.allowed) return 'approved' + return decision.reason +} + +function signalReason(signal: AbortSignal): Error { + return signal.reason instanceof CliRequestError + ? signal.reason + : new CliRequestError('cancelled', 'Request was cancelled') +} + +function throwIfSignalAborted(signal: AbortSignal): void { + if (signal.aborted) throw signalReason(signal) +} + +export class CliMutationGuard { + private readonly pendingTargets = new Map() + private readonly rendererUnavailableRequests = new Set() + + constructor( + private readonly approvals: ApprovalBroker, + private readonly presentation: CliApprovalPresentationPort + ) {} + + async authorize(input: CliMutationApprovalInput): Promise { + throwIfSignalAborted(input.signal) + const target = await this.presentation.getTarget() + throwIfSignalAborted(input.signal) + if (!target) { + throw new CliRequestError('unavailable', 'No trusted renderer is available for approval', { + httpStatus: 503, + retriable: true + }) + } + + const executionId = randomUUID() + const scopeKey = `cli:${input.connectionId}:${executionId}` + let approvalRequestId: string | undefined + let closeReason: DeepchatEventPayload<'approvals.closed'>['reason'] = 'cancelled' + + try { + const pending = this.approvals.create( + { + domain: CLI_APPROVAL_DOMAIN, + scopeKey, + operation: input.operation, + effect: input.effect, + bindingKey: executionId, + arguments: input.arguments, + ...(input.displayData !== undefined ? { redactedDisplayData: input.displayData } : {}), + metadata: { + targetWebContentsId: target.webContentsId, + clientRequestId: input.clientRequestId + } + }, + { + consumeOnApprove: true, + signal: input.signal, + timeoutMs: input.timeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS + } + ) + approvalRequestId = pending.requestId + this.pendingTargets.set(pending.requestId, { scopeKey, target }) + + let presented: boolean + try { + presented = await this.presentation.present(target, { + requestId: pending.requestId, + operation: input.operation, + effect: input.effect, + principal: input.principal, + expiresAt: pending.expiresAt, + ...(pending.redactedDisplayData !== undefined + ? { displayData: pending.redactedDisplayData } + : {}) + }) + } catch { + presented = false + } + if (!presented) { + closeReason = 'unavailable' + this.approvals.resolve({ + requestId: pending.requestId, + scopeKey, + decision: 'cancelled' + }) + throw new CliRequestError('unavailable', 'Approval renderer became unavailable', { + httpStatus: 503, + retriable: true + }) + } + + const decision = await this.approvals.wait(pending.requestId, input.signal) + closeReason = decisionCloseReason(decision) + if (decision.allowed) { + return { approvalRequestId: pending.requestId } + } + if (decision.reason === 'timeout') { + throw new CliRequestError('approval_timeout', 'Approval request timed out', { + httpStatus: 408, + retriable: true + }) + } + if ( + decision.reason === 'cancelled' && + this.rendererUnavailableRequests.has(pending.requestId) + ) { + closeReason = 'unavailable' + throw new CliRequestError('unavailable', 'Approval renderer became unavailable', { + httpStatus: 503, + retriable: true + }) + } + if (decision.reason === 'cancelled' && input.signal.aborted) { + throw signalReason(input.signal) + } + if (decision.reason === 'cancelled') { + throw new CliRequestError('cancelled', 'Approval request was cancelled', { + retriable: true + }) + } + throw new CliRequestError('approval_denied', 'Approval request was denied', { + httpStatus: 403 + }) + } catch (error) { + if (input.signal.aborted && !(error instanceof CliRequestError)) { + throw signalReason(input.signal) + } + if (error instanceof ApprovalCapacityError) { + throw new CliRequestError('rate_limited', 'Too many pending approval requests', { + httpStatus: 429, + retriable: true + }) + } + throw error + } finally { + if (approvalRequestId) { + this.pendingTargets.delete(approvalRequestId) + this.rendererUnavailableRequests.delete(approvalRequestId) + await this.presentation + .close(target, { requestId: approvalRequestId, reason: closeReason }) + .catch(() => undefined) + } + } + } + + resolve( + input: { requestId: string; decision: 'approved' | 'denied' }, + caller: RendererRouteCaller + ): boolean { + const pending = this.pendingTargets.get(input.requestId) + if (!pending || pending.target.webContentsId !== caller.webContentsId) return false + return this.approvals.resolve({ + requestId: input.requestId, + scopeKey: pending.scopeKey, + decision: input.decision + }) + } + + cancelRenderer(webContentsId: number): void { + for (const [requestId, pending] of this.pendingTargets) { + if (pending.target.webContentsId !== webContentsId) continue + this.rendererUnavailableRequests.add(requestId) + this.approvals.resolve({ + requestId, + scopeKey: pending.scopeKey, + decision: 'cancelled' + }) + } + } + + clear(): void { + this.approvals.clearDomain(CLI_APPROVAL_DOMAIN) + this.pendingTargets.clear() + this.rendererUnavailableRequests.clear() + } +} diff --git a/src/main/cli/policy.ts b/src/main/cli/policy.ts new file mode 100644 index 000000000..8c531ba98 --- /dev/null +++ b/src/main/cli/policy.ts @@ -0,0 +1,279 @@ +import type { JsonValue } from '@shared/contracts/json' +import type { LocalControlEffect } from '@shared/contracts/localControl' +import { hashApprovalArguments } from '@/approval' +import type { CliRouteCaller } from '@/routes/routeRegistry' +import { CliRequestError } from './errors' +import type { CliMutationGuard } from './mutationGuard' +import type { CliSurfaceEntry } from './surface' + +const DEFAULT_AGENT_COMPUTE_LIMIT = 2 +const DEFAULT_AGENT_COMPUTE_STARTS_PER_MINUTE = 20 +const COMPUTE_WINDOW_MS = 60_000 + +export type CliPolicyAuditOutcome = + | 'allowed' + | 'denied' + | 'approved' + | 'approval-denied' + | 'approval-timeout' + | 'cancelled' + | 'unavailable' + | 'rate-limited' + | 'misconfigured' + +export type CliPolicyAuditRecord = Readonly<{ + timestamp: number + principal: CliRouteCaller['principal'] + connectionId: string + conversationId?: string + operation: string + effect: LocalControlEffect + outcome: CliPolicyAuditOutcome + requestId: string + approvalRequestId?: string + redactedArgumentsHash: string +}> + +export type CliRequestPolicyInput = Readonly<{ + entry: CliSurfaceEntry + input: unknown + caller: CliRouteCaller + requestId: string + signal: AbortSignal +}> + +export type CliRequestAdmission = Readonly<{ + release(): void +}> + +export type CliRequestPolicyOptions = Readonly<{ + mutationGuard: CliMutationGuard + audit(record: CliPolicyAuditRecord): void | Promise + agentApprovalOperations?: ReadonlySet + agentComputeLimit?: number + agentComputeStartsPerMinute?: number + now?: () => number +}> + +type EffectDecision = 'allow' | 'deny' | 'approval' + +function resolveEffectDecision( + effect: LocalControlEffect, + caller: CliRouteCaller, + operation: string, + agentApprovalOperations: ReadonlySet +): EffectDecision { + if (caller.principal === 'human') { + return effect === 'read' || + effect === 'compute' || + effect === 'local-maintenance' || + effect === 'preference-write' + ? 'allow' + : 'approval' + } + + if (effect === 'read' || effect === 'compute') return 'allow' + if ( + (effect === 'preference-write' || effect === 'security-config' || effect === 'supply-chain') && + agentApprovalOperations.has(operation) + ) { + return 'approval' + } + return 'deny' +} + +function positiveInteger(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} must be positive`) + return value +} + +function emptyRelease(): CliRequestAdmission { + return { release: () => undefined } +} + +function auditProjection(entry: CliSurfaceEntry, input: unknown): JsonValue { + return entry.auditProjection?.(input) ?? {} +} + +export class CliRequestPolicy { + private readonly now: () => number + private readonly agentApprovalOperations: ReadonlySet + private readonly agentComputeLimit: number + private readonly agentComputeStartsPerMinute: number + private readonly activeAgentCompute = new Map() + private readonly agentComputeStarts = new Map() + private lastComputePruneAt = 0 + + constructor(private readonly options: CliRequestPolicyOptions) { + this.now = options.now ?? Date.now + this.agentApprovalOperations = new Set(options.agentApprovalOperations ?? []) + this.agentComputeLimit = positiveInteger( + options.agentComputeLimit ?? DEFAULT_AGENT_COMPUTE_LIMIT, + 'agentComputeLimit' + ) + this.agentComputeStartsPerMinute = positiveInteger( + options.agentComputeStartsPerMinute ?? DEFAULT_AGENT_COMPUTE_STARTS_PER_MINUTE, + 'agentComputeStartsPerMinute' + ) + } + + async authorize(input: CliRequestPolicyInput): Promise { + const redactedArgumentsHash = hashApprovalArguments({ + operation: input.entry.contract.name, + arguments: auditProjection(input.entry, input.input) + }) + const audit = async ( + outcome: CliPolicyAuditOutcome, + approvalRequestId?: string + ): Promise => { + await this.options.audit({ + timestamp: this.now(), + principal: input.caller.principal, + connectionId: input.caller.connectionId, + ...(input.caller.principal === 'agent' + ? { conversationId: input.caller.conversationId } + : {}), + operation: input.entry.contract.name, + effect: input.entry.effect, + outcome, + requestId: input.requestId, + ...(approvalRequestId ? { approvalRequestId } : {}), + redactedArgumentsHash + }) + } + + if ( + !input.entry.callers.includes(input.caller.principal) || + !input.entry.scopes.every((scope) => input.caller.scopes.includes(scope)) + ) { + await audit('denied') + throw new CliRequestError('permission_denied', 'Caller lacks access to this operation', { + httpStatus: 403 + }) + } + + const effectDecision = resolveEffectDecision( + input.entry.effect, + input.caller, + input.entry.contract.name, + this.agentApprovalOperations + ) + if (effectDecision === 'deny') { + await audit('denied') + throw new CliRequestError('permission_denied', 'Operation is denied for this caller', { + httpStatus: 403 + }) + } + + if (effectDecision === 'approval') { + if (input.entry.approval !== 'policy' || !input.entry.approvalDisplay) { + await audit('misconfigured') + throw new CliRequestError('internal_error', 'CLI approval policy is misconfigured', { + httpStatus: 500 + }) + } + let approvalRequestId: string + try { + const approval = await this.options.mutationGuard.authorize({ + operation: input.entry.contract.name, + effect: input.entry.effect, + principal: input.caller.principal, + connectionId: input.caller.connectionId, + clientRequestId: input.requestId, + arguments: input.input, + displayData: input.entry.approvalDisplay(input.input), + signal: input.signal + }) + approvalRequestId = approval.approvalRequestId + } catch (error) { + await audit(this.toApprovalAuditOutcome(error)) + throw error + } + await audit('approved', approvalRequestId) + } + + let admission: CliRequestAdmission + try { + admission = this.admitCompute(input) + } catch (error) { + if (error instanceof CliRequestError && error.code === 'rate_limited') { + await audit('rate-limited') + } + throw error + } + try { + if (effectDecision === 'allow') await audit('allowed') + return admission + } catch (error) { + admission.release() + throw error + } + } + + private admitCompute(input: CliRequestPolicyInput): CliRequestAdmission { + if (input.entry.effect !== 'compute' || input.caller.principal !== 'agent') { + return emptyRelease() + } + + const owner = input.caller.conversationId + const active = this.activeAgentCompute.get(owner) ?? 0 + const now = this.now() + this.pruneComputeStarts(now) + const recentStarts = (this.agentComputeStarts.get(owner) ?? []).filter( + (timestamp) => timestamp > now - COMPUTE_WINDOW_MS + ) + if ( + active >= this.agentComputeLimit || + recentStarts.length >= this.agentComputeStartsPerMinute + ) { + if (recentStarts.length > 0) this.agentComputeStarts.set(owner, recentStarts) + else this.agentComputeStarts.delete(owner) + throw new CliRequestError('rate_limited', 'Agent compute capacity is full', { + httpStatus: 429, + retriable: true + }) + } + + recentStarts.push(now) + this.agentComputeStarts.set(owner, recentStarts) + this.activeAgentCompute.set(owner, active + 1) + let released = false + return { + release: () => { + if (released) return + released = true + const remaining = Math.max(0, (this.activeAgentCompute.get(owner) ?? 1) - 1) + if (remaining > 0) this.activeAgentCompute.set(owner, remaining) + else this.activeAgentCompute.delete(owner) + } + } + } + + private pruneComputeStarts(now: number): void { + if (now - this.lastComputePruneAt < COMPUTE_WINDOW_MS) return + this.lastComputePruneAt = now + for (const [owner, starts] of this.agentComputeStarts) { + if ((this.activeAgentCompute.get(owner) ?? 0) > 0) continue + const recent = starts.filter((timestamp) => timestamp > now - COMPUTE_WINDOW_MS) + if (recent.length > 0) this.agentComputeStarts.set(owner, recent) + else this.agentComputeStarts.delete(owner) + } + } + + private toApprovalAuditOutcome(error: unknown): CliPolicyAuditOutcome { + if (!(error instanceof CliRequestError)) return 'unavailable' + switch (error.code) { + case 'approval_denied': + return 'approval-denied' + case 'approval_timeout': + return 'approval-timeout' + case 'cancelled': + case 'timeout': + return 'cancelled' + case 'rate_limited': + return 'rate-limited' + default: + return 'unavailable' + } + } +} diff --git a/src/main/cli/routes.ts b/src/main/cli/routes.ts index 225ca6c29..791a93f59 100644 --- a/src/main/cli/routes.ts +++ b/src/main/cli/routes.ts @@ -25,7 +25,7 @@ export type CliRuntimeStatus = Readonly<{ export function createCliRoutes(deps: { appVersion: string getStatus(): CliRuntimeStatus - hasTrustedRenderer(): boolean + hasTrustedRenderer(): boolean | Promise }): DeepchatRouteMap { return createRouteMap([ [ @@ -64,7 +64,7 @@ export function createCliRoutes(deps: { cliDoctorRoute.input.parse(rawInput) const status = deps.getStatus() const capabilities = listCliSurfaceCapabilities() - const hasTrustedRenderer = deps.hasTrustedRenderer() + const hasTrustedRenderer = await deps.hasTrustedRenderer() const checks = [ { id: 'transport' as const, diff --git a/src/main/cli/server.ts b/src/main/cli/server.ts index 52a2902cd..8fa386254 100644 --- a/src/main/cli/server.ts +++ b/src/main/cli/server.ts @@ -29,6 +29,7 @@ import { type LocalControlStreamRecord } from '@shared/contracts/localControl' import type { CliRouteCaller } from '@/routes/routeRegistry' +import type { CliRequestAdmission, CliRequestPolicyInput } from './policy' import { parseBoundedJsonBody, parseBoundedJsonBytes, readBoundedRequestBody } from './body' import { cleanupLocalControlLayout, @@ -52,6 +53,7 @@ const MAX_PENDING_PER_CONNECTION = 8 const MAX_IN_MEMORY_BODY_BYTES = 256 * 1024 const SHUTDOWN_GRACE_MS = 2_000 const UNKNOWN_REQUEST_ID = 'unknown' +const emptyAdmission: CliRequestAdmission = Object.freeze({ release: () => undefined }) const AgentCliTokenSchema = z .object({ @@ -94,6 +96,7 @@ export type CliServerDependencies = Readonly<{ caller: CliRouteCaller, signal: AbortSignal ): Promise + authorize?(input: CliRequestPolicyInput): Promise surface?: ReadonlyMap resolveAgentToken?(token: string): AgentCliToken | null artifactSpool?: ArtifactSpool @@ -615,8 +618,6 @@ export class CliServer { httpStatus: 413 }) } - this.assertSurfaceAccess(entry, caller) - const parsedInput = entry.contract.input.safeParse(rpcRequest.params) if (!parsedInput.success) { throw new CliRequestError('invalid_request', 'Request does not match the route contract') @@ -632,8 +633,18 @@ export class CliServer { ) }, entry.limits.timeoutMs) timeout.unref() + let admission: CliRequestAdmission | undefined let rawOutput: unknown try { + admission = await this.authorizeRequest({ + entry, + input, + caller, + requestId, + signal: controller.signal + }) + this.assertSurfaceAccess(entry, caller) + if (controller.signal.aborted) throw requestAbortError(controller.signal) if (requestTransport === 'stream') { await this.dispatchStreamResponse( response, @@ -686,6 +697,7 @@ export class CliServer { ) } } finally { + admission?.release() clearTimeout(timeout) } if (controller.signal.aborted) { @@ -730,6 +742,10 @@ export class CliServer { } } + private async authorizeRequest(input: CliRequestPolicyInput): Promise { + return this.dependencies.authorize ? await this.dependencies.authorize(input) : emptyAdmission + } + private parseRouteOutput( entry: CliSurfaceEntry, rawOutput: unknown, @@ -936,9 +952,18 @@ export class CliServer { ) }, entry.limits.timeoutMs) timeout.unref() + let admission: CliRequestAdmission | undefined try { + admission = await this.authorizeRequest({ + entry, + input: { id: parsedId.data }, + caller, + requestId: randomUUID(), + signal: controller.signal + }) this.assertSurfaceAccess(entry, caller) + if (controller.signal.aborted) throw requestAbortError(controller.signal) if (!this.dependencies.artifactSpool) { throw new CliRequestError('unavailable', 'Artifact service is unavailable', { httpStatus: 503, @@ -978,6 +1003,7 @@ export class CliServer { } if (!response.destroyed) response.destroy() } finally { + admission?.release() clearTimeout(timeout) request.off('aborted', abort) response.off('close', abortOnIncompleteResponse) diff --git a/src/main/cli/surface.ts b/src/main/cli/surface.ts index 1c83ff4a5..594aa94bf 100644 --- a/src/main/cli/surface.ts +++ b/src/main/cli/surface.ts @@ -1,4 +1,5 @@ import type { RouteContract } from '@shared/contracts/contract' +import type { JsonValue } from '@shared/contracts/json' import { AUDIO_TRANSCRIPTION_MAX_INPUT_BYTES, OCR_EXTRACTION_MAX_INPUT_BYTES, @@ -44,9 +45,29 @@ export type CliSurfaceEntry = Readonly<{ scopes: readonly LocalControlScope[] transport: LocalControlTransport approval: LocalControlApprovalMode + auditProjection?: (input: unknown) => JsonValue + approvalDisplay?: (input: unknown) => JsonValue limits: CliRouteLimits }> +function selectAuditFields(input: unknown, fields: readonly string[]): Record { + if (!input || typeof input !== 'object' || Array.isArray(input)) return {} + const source = input as Record + const selected: Record = {} + for (const field of fields) { + const value = source[field] + if ( + value === null || + typeof value === 'string' || + (typeof value === 'number' && Number.isFinite(value)) || + typeof value === 'boolean' + ) { + selected[field] = value + } + } + return selected +} + const DIAGNOSTIC_LIMITS = { maxBodyBytes: 16 * 1024, timeoutMs: 5_000 @@ -69,6 +90,7 @@ const mediaEntry = (contract: RouteContract): CliSurfaceEntry => ({ scopes: ['media:generate'], transport: 'stream', approval: 'never', + auditProjection: (input) => selectAuditFields(input, ['providerId', 'modelId']), limits: { maxBodyBytes: 512 * 1024, timeoutMs: LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS @@ -83,6 +105,19 @@ const CLI_SURFACE_V1_ENTRIES = [ scopes: ['models:invoke'], transport: 'stream', approval: 'never', + auditProjection: (input) => { + const selected = selectAuditFields(input, [ + 'providerId', + 'modelId', + 'temperature', + 'maxTokens' + ]) + const messages = + input && typeof input === 'object' && !Array.isArray(input) + ? (input as Record).messages + : undefined + return { ...selected, messageCount: Array.isArray(messages) ? messages.length : 0 } + }, limits: { maxBodyBytes: 5 * 1024 * 1024, timeoutMs: LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS } }, mediaEntry(imagesGenerateRoute), @@ -95,6 +130,7 @@ const CLI_SURFACE_V1_ENTRIES = [ scopes: ['audio:transcribe'], transport: 'upload', approval: 'never', + auditProjection: (input) => selectAuditFields(input, ['providerId', 'modelId', 'mimeType']), limits: { maxBodyBytes: AUDIO_TRANSCRIPTION_MAX_INPUT_BYTES, timeoutMs: LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS @@ -107,6 +143,7 @@ const CLI_SURFACE_V1_ENTRIES = [ scopes: ['audio:transcribe', 'artifacts:read'], transport: 'rpc', approval: 'never', + auditProjection: (input) => selectAuditFields(input, ['providerId', 'modelId', 'artifactId']), limits: { maxBodyBytes: 16 * 1024, timeoutMs: LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS @@ -128,6 +165,13 @@ const CLI_SURFACE_V1_ENTRIES = [ scopes: ['ocr:extract'], transport: 'upload', approval: 'never', + auditProjection: (input) => + selectAuditFields(input, [ + 'mimeType', + 'backend', + 'sourcePageCountHint', + 'generationTokenLimit' + ]), limits: { maxBodyBytes: OCR_EXTRACTION_MAX_INPUT_BYTES, timeoutMs: LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS @@ -140,6 +184,13 @@ const CLI_SURFACE_V1_ENTRIES = [ scopes: ['ocr:extract', 'artifacts:read'], transport: 'rpc', approval: 'never', + auditProjection: (input) => + selectAuditFields(input, [ + 'artifactId', + 'backend', + 'sourcePageCountHint', + 'generationTokenLimit' + ]), limits: { maxBodyBytes: 16 * 1024, timeoutMs: LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS @@ -173,6 +224,7 @@ const CLI_SURFACE_V1_ENTRIES = [ scopes: ['artifacts:read'], transport: 'rpc', approval: 'never', + auditProjection: (input) => selectAuditFields(input, ['id']), limits: DIAGNOSTIC_LIMITS }, { @@ -182,6 +234,7 @@ const CLI_SURFACE_V1_ENTRIES = [ scopes: ['artifacts:read'], transport: 'download', approval: 'never', + auditProjection: (input) => selectAuditFields(input, ['id']), limits: { maxBodyBytes: 1, timeoutMs: 5 * 60_000 } }, { @@ -191,6 +244,7 @@ const CLI_SURFACE_V1_ENTRIES = [ scopes: ['artifacts:manage'], transport: 'rpc', approval: 'never', + auditProjection: (input) => selectAuditFields(input, ['id']), limits: DIAGNOSTIC_LIMITS }, diagnosticEntry(cliStatusRoute), diff --git a/src/main/notifications/electronWindowNotificationTargets.ts b/src/main/notifications/electronWindowNotificationTargets.ts index 0666af90a..66288d5ea 100644 --- a/src/main/notifications/electronWindowNotificationTargets.ts +++ b/src/main/notifications/electronWindowNotificationTargets.ts @@ -1,6 +1,11 @@ import { BrowserWindow, webContents as electronWebContents, type WebContents } from 'electron' import { DEEPCHAT_EVENT_CHANNEL } from '@shared/contracts/channels' -import { createDeepchatEventEnvelope, semanticNotificationEvent } from '@shared/contracts/events' +import { + createDeepchatEventEnvelope, + semanticNotificationEvent, + type DeepchatEventName, + type DeepchatEventPayload +} from '@shared/contracts/events' import type { ITabPresenter, IWindowPresenter } from '@shared/types/desktop' import type { SemanticNotificationDelivery } from '@shared/notifications' import type { @@ -122,6 +127,14 @@ export class ElectronWindowNotificationTargets implements WindowNotificationTarg async send( target: NotificationWindowTarget, delivery: SemanticNotificationDelivery + ): Promise { + return await this.sendDeepchatEvent(target, semanticNotificationEvent.name, delivery) + } + + async sendDeepchatEvent( + target: NotificationWindowTarget, + eventName: T, + payload: DeepchatEventPayload ): Promise { const current = await this.getTargetByWebContents(target.webContentsId) if (!current || current.kind !== target.kind || current.windowId !== target.windowId) { @@ -131,7 +144,7 @@ export class ElectronWindowNotificationTargets implements WindowNotificationTarg return await this.windows.sendToWebContents( target.webContentsId, DEEPCHAT_EVENT_CHANNEL, - createDeepchatEventEnvelope(semanticNotificationEvent.name, delivery) + createDeepchatEventEnvelope(eventName, payload) ) } diff --git a/src/renderer/api/ApprovalClient.ts b/src/renderer/api/ApprovalClient.ts new file mode 100644 index 000000000..5bca75b3d --- /dev/null +++ b/src/renderer/api/ApprovalClient.ts @@ -0,0 +1,27 @@ +import type { DeepchatBridge } from '@shared/contracts/bridge' +import { + approvalClosedEvent, + approvalRequestedEvent, + type DeepchatEventPayload +} from '@shared/contracts/events' +import { approvalsResolveRoute } from '@shared/contracts/routes' +import { getDeepchatBridge } from './core' + +export function createApprovalClient(bridge: DeepchatBridge = getDeepchatBridge()) { + return { + onRequested( + listener: (payload: DeepchatEventPayload) => void + ) { + return bridge.on(approvalRequestedEvent.name, listener) + }, + onClosed(listener: (payload: DeepchatEventPayload) => void) { + return bridge.on(approvalClosedEvent.name, listener) + }, + async resolve(requestId: string, decision: 'approved' | 'denied'): Promise { + const result = await bridge.invoke(approvalsResolveRoute.name, { requestId, decision }) + return result.accepted + } + } +} + +export type ApprovalClient = ReturnType diff --git a/src/renderer/src/apps/chat-main/ChatMainApp.vue b/src/renderer/src/apps/chat-main/ChatMainApp.vue index cff7eb35b..0cef5c77a 100644 --- a/src/renderer/src/apps/chat-main/ChatMainApp.vue +++ b/src/renderer/src/apps/chat-main/ChatMainApp.vue @@ -25,6 +25,7 @@ import MessageDialog from '@/components/ui/MessageDialog.vue' import McpSamplingDialog from '@/components/mcp/McpSamplingDialog.vue' import McpElicitationDialog from '@/components/mcp/McpElicitationDialog.vue' import McpAppConsentDialog from '@/components/mcp/McpAppConsentDialog.vue' +import CliApprovalDialog from '@/components/cli/CliApprovalDialog.vue' import { initAppStores, useMcpInstallDeeplinkHandler } from '@/lib/storeInitializer' import { ensureIconsLoaded } from '@/lib/iconLoader' import { useFontManager } from '@/composables/useFontManager' @@ -553,6 +554,7 @@ onBeforeUnmount(() => { + diff --git a/src/renderer/src/components/cli/CliApprovalDialog.vue b/src/renderer/src/components/cli/CliApprovalDialog.vue new file mode 100644 index 000000000..843ae07df --- /dev/null +++ b/src/renderer/src/components/cli/CliApprovalDialog.vue @@ -0,0 +1,71 @@ + + + diff --git a/src/renderer/src/stores/cliApproval.ts b/src/renderer/src/stores/cliApproval.ts new file mode 100644 index 000000000..cf8139829 --- /dev/null +++ b/src/renderer/src/stores/cliApproval.ts @@ -0,0 +1,63 @@ +import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue' +import { defineStore } from 'pinia' +import { createApprovalClient } from '@api/ApprovalClient' +import type { DeepchatEventPayload } from '@shared/contracts/events' + +const MAX_PENDING_CLI_APPROVALS = 32 + +type ApprovalRequest = DeepchatEventPayload<'approvals.requested'> + +export const useCliApprovalStore = defineStore('cliApproval', () => { + const client = createApprovalClient() + const queue = shallowRef([]) + const isSubmitting = ref(false) + const cleanups: Array<() => void> = [] + const request = computed(() => queue.value[0] ?? null) + const isOpen = computed(() => request.value !== null) + + const remove = (requestId: string) => { + queue.value = queue.value.filter((entry) => entry.requestId !== requestId) + } + + const submit = async (decision: 'approved' | 'denied') => { + const current = request.value + if (!current || isSubmitting.value) return + isSubmitting.value = true + try { + await client.resolve(current.requestId, decision) + remove(current.requestId) + } catch (error) { + console.error('[CLI Approval] Failed to resolve approval:', error) + } finally { + isSubmitting.value = false + } + } + + onMounted(() => { + cleanups.push( + client.onRequested((next) => { + if (queue.value.some((entry) => entry.requestId === next.requestId)) return + if (queue.value.length >= MAX_PENDING_CLI_APPROVALS) { + void client.resolve(next.requestId, 'denied').catch((error) => { + console.error('[CLI Approval] Failed to reject queued approval:', error) + }) + return + } + queue.value = [...queue.value, next] + }), + client.onClosed(({ requestId }) => remove(requestId)) + ) + }) + + onUnmounted(() => { + while (cleanups.length > 0) cleanups.pop()?.() + }) + + return { + request, + isOpen, + isSubmitting, + approve: () => submit('approved'), + deny: () => submit('denied') + } +}) diff --git a/src/shared/contracts/events.ts b/src/shared/contracts/events.ts index aa916415d..9e069248b 100644 --- a/src/shared/contracts/events.ts +++ b/src/shared/contracts/events.ts @@ -7,6 +7,7 @@ import { acpTerminalOutputEvent, acpTerminalStartedEvent } from './events/acp-terminal.events' +import { approvalClosedEvent, approvalRequestedEvent } from './events/approvals.events' import { appRuntimeGuidedOnboardingStartRequestedEvent, appRuntimeMcpInstallRequestedEvent, @@ -143,6 +144,7 @@ import { liveDelegationChangedEvent } from './events/orchestration.events' export * from './events/browser.events' export * from './events/computerUse.events' export * from './events/acp-terminal.events' +export * from './events/approvals.events' export * from './events/app-runtime.events' export * from './events/chat.events' export * from './events/config.events' @@ -169,6 +171,8 @@ export * from './events/window.events' export * from './events/workspace.events' export const DEEPCHAT_EVENT_CATALOG = { + [approvalRequestedEvent.name]: approvalRequestedEvent, + [approvalClosedEvent.name]: approvalClosedEvent, [windowStateChangedEvent.name]: windowStateChangedEvent, [workspaceInvalidatedEvent.name]: workspaceInvalidatedEvent, [workspaceWatchStatusChangedEvent.name]: workspaceWatchStatusChangedEvent, diff --git a/src/shared/contracts/events/approvals.events.ts b/src/shared/contracts/events/approvals.events.ts new file mode 100644 index 000000000..1ada13b0f --- /dev/null +++ b/src/shared/contracts/events/approvals.events.ts @@ -0,0 +1,28 @@ +import { z } from 'zod' +import { JsonValueSchema, TimestampMsSchema, defineEventContract } from '../common' +import { LocalControlEffectSchema, LocalControlMethodSchema } from '../localControl' +import { ApprovalRequestIdSchema } from '../routes/approvals.routes' + +export const approvalRequestedEvent = defineEventContract({ + name: 'approvals.requested', + payload: z + .object({ + requestId: ApprovalRequestIdSchema, + operation: LocalControlMethodSchema, + effect: LocalControlEffectSchema, + principal: z.enum(['human', 'agent']), + expiresAt: TimestampMsSchema, + displayData: JsonValueSchema.optional() + }) + .strict() +}) + +export const approvalClosedEvent = defineEventContract({ + name: 'approvals.closed', + payload: z + .object({ + requestId: ApprovalRequestIdSchema, + reason: z.enum(['approved', 'denied', 'cancelled', 'timeout', 'unavailable']) + }) + .strict() +}) diff --git a/src/shared/contracts/routes.ts b/src/shared/contracts/routes.ts index 906340978..ff8369351 100644 --- a/src/shared/contracts/routes.ts +++ b/src/shared/contracts/routes.ts @@ -1,5 +1,6 @@ import type { z } from 'zod' import type { RouteContract } from './common' +import { approvalsResolveRoute } from './routes/approvals.routes' import { artifactsDeleteRoute, artifactsDescribeRoute, @@ -578,6 +579,7 @@ import { } from './routes/orchestration.routes' export * from './routes/browser.routes' +export * from './routes/approvals.routes' export * from './routes/artifacts.routes' export * from './routes/audio.routes' export * from './routes/computerUse.routes' @@ -625,6 +627,7 @@ export * from './routes/orchestration.routes' // TS 的类型序列化上限触发 TS7056。拆成多块后每块单独序列化,合并类型只保存引用, // 既绕过上限又保留逐路由精确的输入/输出类型。新增路由追加到任意一块即可,保持各块体量适中。 const DEEPCHAT_ROUTE_CATALOG_PART_1 = { + [approvalsResolveRoute.name]: approvalsResolveRoute, [acpTerminalInputRoute.name]: acpTerminalInputRoute, [acpTerminalKillRoute.name]: acpTerminalKillRoute, [shortcutRegisterRoute.name]: shortcutRegisterRoute, diff --git a/src/shared/contracts/routes/approvals.routes.ts b/src/shared/contracts/routes/approvals.routes.ts new file mode 100644 index 000000000..3282aa6fd --- /dev/null +++ b/src/shared/contracts/routes/approvals.routes.ts @@ -0,0 +1,19 @@ +import { z } from 'zod' +import { defineRouteContract } from '../contract' + +export const ApprovalRequestIdSchema = z + .string() + .min(16) + .max(128) + .regex(/^[A-Za-z0-9_-]+$/) + +export const approvalsResolveRoute = defineRouteContract({ + name: 'approvals.resolve', + input: z + .object({ + requestId: ApprovalRequestIdSchema, + decision: z.enum(['approved', 'denied']) + }) + .strict(), + output: z.object({ accepted: z.boolean() }).strict() +}) diff --git a/test/main/approval/routes.test.ts b/test/main/approval/routes.test.ts new file mode 100644 index 000000000..eeb41771b --- /dev/null +++ b/test/main/approval/routes.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest' +import { approvalsResolveRoute } from '@shared/contracts/routes' +import { createApprovalRoutes } from '@/approval' + +describe('approval routes', () => { + it('passes a validated decision and renderer identity to the resolver', async () => { + const resolve = vi.fn(() => true) + const routes = createApprovalRoutes({ resolve }) + const handler = routes.get(approvalsResolveRoute.name)! + + await expect( + handler( + { requestId: 'approval-request-1234', decision: 'approved' }, + { caller: { kind: 'renderer', webContentsId: 12, windowId: 3 } } + ) + ).resolves.toEqual({ accepted: true }) + expect(resolve).toHaveBeenCalledWith( + { requestId: 'approval-request-1234', decision: 'approved' }, + { kind: 'renderer', webContentsId: 12, windowId: 3 } + ) + }) + + it('rejects CLI callers before resolution', async () => { + const resolve = vi.fn(() => true) + const routes = createApprovalRoutes({ resolve }) + const handler = routes.get(approvalsResolveRoute.name)! + + await expect( + handler( + { requestId: 'approval-request-1234', decision: 'denied' }, + { + caller: { + kind: 'cli', + principal: 'human', + connectionId: 'connection-1', + scopes: [] + } + } + ) + ).rejects.toThrow('Route requires a renderer caller') + expect(resolve).not.toHaveBeenCalled() + }) +}) diff --git a/test/main/cli/auditLog.test.ts b/test/main/cli/auditLog.test.ts new file mode 100644 index 000000000..0ce1e7681 --- /dev/null +++ b/test/main/cli/auditLog.test.ts @@ -0,0 +1,123 @@ +import { link, mkdtemp, mkdir, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CliAuditLog } from '@/cli/auditLog' +import type { CliPolicyAuditRecord } from '@/cli/policy' + +vi.unmock('fs') +vi.unmock('node:fs') +vi.unmock('fs/promises') +vi.unmock('node:fs/promises') +vi.unmock('path') +vi.unmock('node:path') + +const directories: string[] = [] + +function auditRecord(requestId: string): CliPolicyAuditRecord { + return { + timestamp: 1_788_000_000_000, + principal: 'human', + connectionId: 'connection-1', + operation: 'settings.set', + effect: 'preference-write', + outcome: 'allowed', + requestId, + redactedArgumentsHash: 'a'.repeat(64) + } +} + +async function createDirectory(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), 'deepchat-cli-audit-')) + directories.push(directory) + return directory +} + +async function readRecords(filePath: string): Promise { + return (await readFile(filePath, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as CliPolicyAuditRecord) +} + +afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true, maxRetries: 3 })) + ) +}) + +describe('CliAuditLog', () => { + it('serializes concurrent records in call order and creates private paths', async () => { + const directory = path.join(await createDirectory(), 'local-control') + const log = new CliAuditLog({ directory }) + + await Promise.all(Array.from({ length: 20 }, (_, index) => log.record(auditRecord(`${index}`)))) + await log.close() + + const records = await readRecords(path.join(directory, 'audit.jsonl')) + expect(records.map((record) => record.requestId)).toEqual( + Array.from({ length: 20 }, (_, index) => `${index}`) + ) + if (process.platform !== 'win32') { + expect((await stat(directory)).mode & 0o777).toBe(0o700) + expect((await stat(path.join(directory, 'audit.jsonl'))).mode & 0o777).toBe(0o600) + } + }) + + it('keeps only the two newest bounded audit segments', async () => { + const directory = path.join(await createDirectory(), 'local-control') + const recordBytes = Buffer.byteLength(`${JSON.stringify(auditRecord('1'))}\n`, 'utf8') + const log = new CliAuditLog({ directory, maxBytes: recordBytes + 1 }) + + await log.record(auditRecord('1')) + await log.record(auditRecord('2')) + await log.record(auditRecord('3')) + await log.close() + + expect(await readRecords(path.join(directory, 'audit.1.jsonl'))).toMatchObject([ + { requestId: '2' } + ]) + expect(await readRecords(path.join(directory, 'audit.jsonl'))).toMatchObject([ + { requestId: '3' } + ]) + }) + + it('rejects records after close', async () => { + const directory = path.join(await createDirectory(), 'local-control') + const log = new CliAuditLog({ directory }) + await log.close() + + await expect(log.record(auditRecord('late'))).rejects.toThrow('audit log is closed') + }) + + it.skipIf(process.platform === 'win32')('refuses to follow a symlinked audit file', async () => { + const root = await createDirectory() + const directory = path.join(root, 'local-control') + const target = path.join(root, 'target.jsonl') + await mkdir(directory) + await symlink(target, path.join(directory, 'audit.jsonl')) + const log = new CliAuditLog({ directory }) + + await expect(log.record(auditRecord('1'))).rejects.toMatchObject({ code: 'ELOOP' }) + await log.close() + }) + + it.skipIf(process.platform === 'win32')( + 'refuses an audit file with multiple hard links', + async () => { + const root = await createDirectory() + const directory = path.join(root, 'local-control') + const target = path.join(root, 'target.jsonl') + await mkdir(directory) + await writeFile(target, '') + await link(target, path.join(directory, 'audit.jsonl')) + const log = new CliAuditLog({ directory }) + + await expect(log.record(auditRecord('1'))).rejects.toThrow('must not have multiple links') + await log.close() + expect(await readFile(target, 'utf8')).toBe('') + } + ) +}) diff --git a/test/main/cli/mutationGuard.test.ts b/test/main/cli/mutationGuard.test.ts new file mode 100644 index 000000000..91d712967 --- /dev/null +++ b/test/main/cli/mutationGuard.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it, vi } from 'vitest' +import { ApprovalBroker } from '@/approval' +import { CliMutationGuard, type CliApprovalPresentationPort } from '@/cli/mutationGuard' +import { CliRequestError } from '@/cli/errors' + +const rendererCaller = (webContentsId: number) => ({ + kind: 'renderer' as const, + webContentsId, + windowId: 7 +}) + +function createHarness() { + const requests: Array[1]> = [] + const close = vi.fn(async () => undefined) + const presentation: CliApprovalPresentationPort = { + getTarget: vi.fn(async () => ({ windowId: 7, webContentsId: 70 })), + present: vi.fn(async (_target, payload) => { + requests.push(payload) + return true + }), + close + } + const approvals = new ApprovalBroker() + const guard = new CliMutationGuard(approvals, presentation) + const authorize = (signal = new AbortController().signal) => + guard.authorize({ + operation: 'skills.installFromUrl', + effect: 'supply-chain', + principal: 'human', + connectionId: 'connection-1', + clientRequestId: 'client-1', + arguments: { url: 'https://example.com/skill.git' }, + displayData: { host: 'example.com' }, + signal + }) + + return { approvals, authorize, close, guard, presentation, requests } +} + +async function waitForRequests(requests: unknown[], count: number): Promise { + for (let attempt = 0; attempt < 20 && requests.length < count; attempt += 1) { + await Promise.resolve() + } + expect(requests).toHaveLength(count) +} + +describe('CliMutationGuard', () => { + it('resumes only the exact request resolved by its target renderer', async () => { + const harness = createHarness() + const pending = harness.authorize() + await waitForRequests(harness.requests, 1) + const requestId = harness.requests[0].requestId + expect(JSON.stringify(harness.requests[0])).not.toContain('https://example.com/skill.git') + + expect(harness.guard.resolve({ requestId, decision: 'approved' }, rendererCaller(71))).toBe( + false + ) + expect(harness.guard.resolve({ requestId, decision: 'approved' }, rendererCaller(70))).toBe( + true + ) + await expect(pending).resolves.toEqual({ approvalRequestId: requestId }) + expect(harness.guard.resolve({ requestId, decision: 'approved' }, rendererCaller(70))).toBe( + false + ) + expect(harness.close).toHaveBeenCalledWith( + { windowId: 7, webContentsId: 70 }, + { requestId, reason: 'approved' } + ) + }) + + it('publishes the broker-normalized copy of redacted display data', async () => { + const harness = createHarness() + const displayData = { nested: { label: 'safe' } } + const pending = harness.guard.authorize({ + operation: 'skills.installFromUrl', + effect: 'supply-chain', + principal: 'human', + connectionId: 'connection-1', + clientRequestId: 'client-1', + arguments: { url: 'https://example.com/skill.git' }, + displayData, + signal: new AbortController().signal + }) + await waitForRequests(harness.requests, 1) + displayData.nested.label = 'mutated-after-create' + const requestId = harness.requests[0].requestId + + expect(harness.requests[0].displayData).toEqual({ nested: { label: 'safe' } }) + harness.guard.resolve({ requestId, decision: 'denied' }, rendererCaller(70)) + await expect(pending).rejects.toMatchObject({ code: 'approval_denied' }) + }) + + it('does not deduplicate identical concurrent mutations', async () => { + const harness = createHarness() + const first = harness.authorize() + const second = harness.authorize() + await waitForRequests(harness.requests, 2) + const firstId = harness.requests[0].requestId + const secondId = harness.requests[1].requestId + + expect(firstId).not.toBe(secondId) + expect( + harness.guard.resolve({ requestId: firstId, decision: 'approved' }, rendererCaller(70)) + ).toBe(true) + await expect(first).resolves.toEqual({ approvalRequestId: firstId }) + let secondSettled = false + void second.then( + () => { + secondSettled = true + }, + () => { + secondSettled = true + } + ) + await Promise.resolve() + expect(secondSettled).toBe(false) + + expect( + harness.guard.resolve({ requestId: secondId, decision: 'denied' }, rendererCaller(70)) + ).toBe(true) + await expect(second).rejects.toMatchObject({ code: 'approval_denied' }) + }) + + it('fails closed when no trusted renderer is available', async () => { + const harness = createHarness() + vi.mocked(harness.presentation.getTarget).mockResolvedValueOnce(null) + + await expect(harness.authorize()).rejects.toMatchObject({ + code: 'unavailable', + httpStatus: 503 + }) + expect(harness.presentation.present).not.toHaveBeenCalled() + }) + + it('normalizes a pre-aborted signal to the CLI cancellation contract', async () => { + const harness = createHarness() + const controller = new AbortController() + controller.abort() + + await expect(harness.authorize(controller.signal)).rejects.toMatchObject({ + code: 'cancelled' + }) + expect(harness.presentation.getTarget).not.toHaveBeenCalled() + }) + + it('fails closed when targeted event delivery fails', async () => { + const harness = createHarness() + vi.mocked(harness.presentation.present).mockResolvedValueOnce(false) + + await expect(harness.authorize()).rejects.toMatchObject({ code: 'unavailable' }) + expect(harness.close).toHaveBeenCalledWith( + { windowId: 7, webContentsId: 70 }, + expect.objectContaining({ reason: 'unavailable' }) + ) + }) + + it('expires an unanswered request without leaving a replayable approval', async () => { + vi.useFakeTimers() + try { + const harness = createHarness() + const pending = harness.guard.authorize({ + operation: 'skills.installFromUrl', + effect: 'supply-chain', + principal: 'human', + connectionId: 'connection-1', + clientRequestId: 'client-1', + arguments: { url: 'https://example.com/skill.git' }, + displayData: { host: 'example.com' }, + signal: new AbortController().signal, + timeoutMs: 10 + }) + await waitForRequests(harness.requests, 1) + const rejection = expect(pending).rejects.toMatchObject({ code: 'approval_timeout' }) + await vi.advanceTimersByTimeAsync(10) + + await rejection + expect( + harness.guard.resolve( + { requestId: harness.requests[0].requestId, decision: 'approved' }, + rendererCaller(70) + ) + ).toBe(false) + } finally { + vi.useRealTimers() + } + }) + + it('cancels the pending approval when the HTTP request aborts', async () => { + const harness = createHarness() + const controller = new AbortController() + const pending = harness.authorize(controller.signal) + await waitForRequests(harness.requests, 1) + controller.abort(new CliRequestError('cancelled', 'Request was cancelled')) + + await expect(pending).rejects.toMatchObject({ code: 'cancelled' }) + expect(harness.close).toHaveBeenCalledWith( + { windowId: 7, webContentsId: 70 }, + { requestId: harness.requests[0].requestId, reason: 'cancelled' } + ) + }) + + it('cancels every request owned by a renderer that becomes unavailable', async () => { + const harness = createHarness() + const pending = harness.authorize() + await waitForRequests(harness.requests, 1) + + harness.guard.cancelRenderer(70) + + await expect(pending).rejects.toMatchObject({ code: 'unavailable' }) + }) + + it('reports broker shutdown as cancellation rather than a user denial', async () => { + const harness = createHarness() + const pending = harness.authorize() + await waitForRequests(harness.requests, 1) + + harness.guard.clear() + + await expect(pending).rejects.toMatchObject({ code: 'cancelled', retriable: true }) + expect(harness.close).toHaveBeenCalledWith( + { windowId: 7, webContentsId: 70 }, + { requestId: harness.requests[0].requestId, reason: 'cancelled' } + ) + }) +}) diff --git a/test/main/cli/policy.test.ts b/test/main/cli/policy.test.ts new file mode 100644 index 000000000..c7cb96e10 --- /dev/null +++ b/test/main/cli/policy.test.ts @@ -0,0 +1,203 @@ +import { z } from 'zod' +import { describe, expect, it, vi } from 'vitest' +import { defineRouteContract } from '@shared/contracts/contract' +import type { LocalControlEffect } from '@shared/contracts/localControl' +import type { CliRouteCaller } from '@/routes/routeRegistry' +import { CliRequestPolicy, type CliPolicyAuditRecord } from '@/cli/policy' +import type { CliMutationGuard } from '@/cli/mutationGuard' +import type { CliSurfaceEntry } from '@/cli/surface' + +const testRoute = defineRouteContract({ + name: 'settings.testMutation', + input: z.object({ secret: z.string().optional() }), + output: z.object({ ok: z.boolean() }) +}) + +const humanCaller: CliRouteCaller = { + kind: 'cli', + principal: 'human', + connectionId: 'human-connection', + scopes: ['settings:write'] +} + +const agentCaller: CliRouteCaller = { + kind: 'cli', + principal: 'agent', + connectionId: 'agent-connection', + conversationId: 'conversation-1', + expiresAt: Date.now() + 60_000, + scopes: ['settings:write'] +} + +function entry(effect: LocalControlEffect, approval: 'never' | 'policy' = 'never') { + return { + contract: testRoute, + effect, + callers: ['human', 'agent'], + scopes: ['settings:write'], + transport: 'rpc', + approval, + auditProjection: () => ({ target: 'safe-setting' }), + ...(approval === 'policy' ? { approvalDisplay: () => ({ target: 'safe-setting' }) } : {}), + limits: { maxBodyBytes: 1024, timeoutMs: 5_000 } + } satisfies CliSurfaceEntry +} + +function createHarness( + options: { + allowlisted?: boolean + agentComputeLimit?: number + agentComputeStartsPerMinute?: number + audit?: (record: CliPolicyAuditRecord) => void | Promise + } = {} +) { + const auditRecords: CliPolicyAuditRecord[] = [] + const authorize = vi.fn(async () => ({ approvalRequestId: 'approval-request-1234' })) + const mutationGuard = { authorize } as unknown as CliMutationGuard + const policy = new CliRequestPolicy({ + mutationGuard, + audit: options.audit ?? ((record) => auditRecords.push(record)), + agentApprovalOperations: options.allowlisted ? new Set([testRoute.name]) : new Set(), + agentComputeLimit: options.agentComputeLimit, + agentComputeStartsPerMinute: options.agentComputeStartsPerMinute + }) + const invoke = ( + effect: LocalControlEffect, + caller: CliRouteCaller, + approval: 'never' | 'policy' = 'never' + ) => + policy.authorize({ + entry: entry(effect, approval), + input: { secret: 'must-not-appear-in-audit' }, + caller, + requestId: 'request-1', + signal: new AbortController().signal + }) + + return { auditRecords, authorize, invoke, policy } +} + +describe('CliRequestPolicy', () => { + it.each(['read', 'compute', 'local-maintenance', 'preference-write'] as const)( + 'allows the human %s effect without approval', + async (effect) => { + const harness = createHarness() + await expect(harness.invoke(effect, humanCaller)).resolves.toBeDefined() + expect(harness.authorize).not.toHaveBeenCalled() + expect(harness.auditRecords.at(-1)?.outcome).toBe('allowed') + } + ) + + it.each([ + 'security-config', + 'execution-config', + 'supply-chain', + 'credential', + 'destructive' + ] as const)('requires renderer approval for the human %s effect', async (effect) => { + const harness = createHarness() + await expect(harness.invoke(effect, humanCaller, 'policy')).resolves.toBeDefined() + expect(harness.authorize).toHaveBeenCalledOnce() + expect(harness.auditRecords.at(-1)).toMatchObject({ + outcome: 'approved', + approvalRequestId: 'approval-request-1234' + }) + }) + + it('denies agent maintenance and destructive effects', async () => { + const harness = createHarness() + await expect(harness.invoke('local-maintenance', agentCaller)).rejects.toMatchObject({ + code: 'permission_denied' + }) + await expect(harness.invoke('destructive', agentCaller, 'policy')).rejects.toMatchObject({ + code: 'permission_denied' + }) + expect(harness.authorize).not.toHaveBeenCalled() + expect(harness.auditRecords.map((record) => record.outcome)).toEqual(['denied', 'denied']) + }) + + it('requires an explicit operation allowlist before an agent may request approval', async () => { + const denied = createHarness() + await expect(denied.invoke('supply-chain', agentCaller, 'policy')).rejects.toMatchObject({ + code: 'permission_denied' + }) + + const allowed = createHarness({ allowlisted: true }) + await expect(allowed.invoke('supply-chain', agentCaller, 'policy')).resolves.toBeDefined() + expect(allowed.authorize).toHaveBeenCalledOnce() + }) + + it('fails closed when an approval effect lacks approval surface metadata', async () => { + const harness = createHarness() + + await expect(harness.invoke('credential', humanCaller)).rejects.toMatchObject({ + code: 'internal_error' + }) + expect(harness.authorize).not.toHaveBeenCalled() + expect(harness.auditRecords.at(-1)?.outcome).toBe('misconfigured') + }) + + it('audits and denies callers or scopes excluded by the surface entry', async () => { + const harness = createHarness() + await expect( + harness.policy.authorize({ + entry: { ...entry('read'), callers: ['human'] }, + input: {}, + caller: agentCaller, + requestId: 'request-caller', + signal: new AbortController().signal + }) + ).rejects.toMatchObject({ code: 'permission_denied' }) + await expect( + harness.policy.authorize({ + entry: entry('read'), + input: {}, + caller: { ...humanCaller, scopes: [] }, + requestId: 'request-scope', + signal: new AbortController().signal + }) + ).rejects.toMatchObject({ code: 'permission_denied' }) + + expect(harness.auditRecords.map((record) => record.outcome)).toEqual(['denied', 'denied']) + expect(harness.authorize).not.toHaveBeenCalled() + }) + + it('keeps raw secrets and prompts out of the structured audit record', async () => { + const harness = createHarness() + await harness.invoke('read', humanCaller) + + expect(JSON.stringify(harness.auditRecords)).not.toContain('must-not-appear-in-audit') + expect(harness.auditRecords[0].redactedArgumentsHash).toMatch(/^[a-f0-9]{64}$/) + }) + + it('limits concurrent and burst agent compute per conversation and releases idempotently', async () => { + const harness = createHarness({ agentComputeLimit: 1, agentComputeStartsPerMinute: 2 }) + const first = await harness.invoke('compute', agentCaller) + await expect(harness.invoke('compute', agentCaller)).rejects.toMatchObject({ + code: 'rate_limited' + }) + first.release() + first.release() + + const second = await harness.invoke('compute', agentCaller) + second.release() + await expect(harness.invoke('compute', agentCaller)).rejects.toMatchObject({ + code: 'rate_limited' + }) + }) + + it('fails closed and releases compute admission when the audit sink fails', async () => { + let shouldFail = true + const harness = createHarness({ + agentComputeLimit: 1, + audit: async () => { + if (shouldFail) throw new Error('audit unavailable') + } + }) + + await expect(harness.invoke('compute', agentCaller)).rejects.toThrow('audit unavailable') + shouldFail = false + const admission = await harness.invoke('compute', agentCaller) + admission.release() + }) +}) diff --git a/test/main/cli/server.test.ts b/test/main/cli/server.test.ts index c80717a83..9bd217336 100644 --- a/test/main/cli/server.test.ts +++ b/test/main/cli/server.test.ts @@ -19,6 +19,7 @@ import { } from '@shared/contracts/localControl' import { createCliRoutes } from '@/cli/routes' import { CliServer, type AgentCliToken, type CliUploadedInputFile } from '@/cli/server' +import type { CliRequestAdmission, CliRequestPolicyInput } from '@/cli/policy' import type { CliSurfaceEntry } from '@/cli/surface' import type { CliRouteCaller } from '@/routes/routeRegistry' import { invokeLocalControlStream } from '../../../src/cli/transport' @@ -211,6 +212,7 @@ async function createTestServer( caller: CliRouteCaller, signal: AbortSignal ) => Promise + authorize?: (input: CliRequestPolicyInput) => Promise } = {} ): Promise<{ userDataPath: string @@ -218,6 +220,7 @@ async function createTestServer( descriptor: LocalControlDescriptor dispatch: ReturnType dispatchUpload: ReturnType + authorize: ReturnType }> { const userDataPath = await createTemporaryDirectory() let server: CliServer @@ -235,6 +238,7 @@ async function createTestServer( } ) const dispatchUpload = vi.fn(options.dispatchUpload ?? (async () => ({}))) + const authorize = vi.fn(options.authorize ?? (async () => ({ release: () => undefined }))) server = new CliServer({ userDataPath, appVersion: '1.2.3', @@ -256,12 +260,13 @@ async function createTestServer( : {}), resolveAgentToken: options.resolveAgentToken, dispatchUpload, + ...(options.authorize ? { authorize } : {}), surface: options.surface, log: { warn: vi.fn(), error: vi.fn() } }) servers.push(server) const descriptor = await server.start() - return { userDataPath, server, descriptor, dispatch, dispatchUpload } + return { userDataPath, server, descriptor, dispatch, dispatchUpload, authorize } } afterEach(async () => { @@ -366,6 +371,23 @@ describe('CLI local transport', () => { }) }) + it('releases policy admission after a route contract failure', async () => { + const release = vi.fn() + const { descriptor, authorize } = await createTestServer({ + authorize: async () => ({ release }), + dispatchOutput: () => ({ appVersion: '' }) + }) + + const response = await rpcRequest(descriptor, {}) + + expect(response).toMatchObject({ + status: 500, + body: { ok: false, error: { code: 'internal_error' } } + }) + expect(authorize).toHaveBeenCalledOnce() + expect(release).toHaveBeenCalledOnce() + }) + it('requires Content-Length and never accepts implicit chunked RPC input', async () => { const { descriptor, dispatch } = await createTestServer() diff --git a/test/main/cli/surface.test.ts b/test/main/cli/surface.test.ts index 326c1c9c8..06bf2c4d9 100644 --- a/test/main/cli/surface.test.ts +++ b/test/main/cli/surface.test.ts @@ -37,6 +37,7 @@ describe('CLI surface V1', () => { expect(getCliSurfaceEntry('settings.getSnapshot')).toBeUndefined() expect(getCliSurfaceEntry('mcp.callTool')).toBeUndefined() expect(getCliSurfaceEntry('databaseSecurity.disable')).toBeUndefined() + expect(getCliSurfaceEntry('approvals.resolve')).toBeUndefined() }) it('publishes stable sorted capability metadata', () => { diff --git a/test/renderer/stores/cliApproval.test.ts b/test/renderer/stores/cliApproval.test.ts new file mode 100644 index 000000000..7e8986feb --- /dev/null +++ b/test/renderer/stores/cliApproval.test.ts @@ -0,0 +1,164 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { defineComponent } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('useCliApprovalStore', () => { + beforeEach(() => { + vi.resetModules() + }) + + it('queues targeted requests, resolves once, and removes closed requests', async () => { + let requested: ((payload: Record) => void) | undefined + let closed: ((payload: { requestId: string }) => void) | undefined + const resolve = vi.fn(async () => false) + vi.doMock('@api/ApprovalClient', () => ({ + createApprovalClient: () => ({ + onRequested: (listener: typeof requested) => { + requested = listener + return vi.fn() + }, + onClosed: (listener: typeof closed) => { + closed = listener + return vi.fn() + }, + resolve + }) + })) + vi.doMock('pinia', async () => { + const actual = await vi.importActual('pinia') + return { ...actual, defineStore: (_id: string, setup: () => unknown) => setup } + }) + + const { useCliApprovalStore } = await import('@/stores/cliApproval') + let store: ReturnType | undefined + const Harness = defineComponent({ + setup() { + store = useCliApprovalStore() + return () => null + } + }) + const wrapper = mount(Harness) + + requested?.({ + requestId: 'approval-request-1234', + operation: 'skills.installFromUrl', + effect: 'supply-chain', + principal: 'human', + expiresAt: Date.now() + 60_000, + displayData: { host: 'example.com' } + }) + requested?.({ + requestId: 'approval-request-5678', + operation: 'mcp.addPublic', + effect: 'security-config', + principal: 'human', + expiresAt: Date.now() + 60_000 + }) + await flushPromises() + + expect(store?.request.value?.requestId).toBe('approval-request-1234') + await store?.approve() + expect(resolve).toHaveBeenCalledWith('approval-request-1234', 'approved') + expect(store?.request.value?.requestId).toBe('approval-request-5678') + + closed?.({ requestId: 'approval-request-5678' }) + await flushPromises() + expect(store?.isOpen.value).toBe(false) + wrapper.unmount() + }) + + it('keeps a request visible when renderer resolution fails so the user can retry', async () => { + let requested: ((payload: Record) => void) | undefined + const resolve = vi + .fn() + .mockRejectedValueOnce(new Error('IPC unavailable')) + .mockResolvedValue(true) + vi.doMock('@api/ApprovalClient', () => ({ + createApprovalClient: () => ({ + onRequested: (listener: typeof requested) => { + requested = listener + return vi.fn() + }, + onClosed: () => vi.fn(), + resolve + }) + })) + vi.doMock('pinia', async () => { + const actual = await vi.importActual('pinia') + return { ...actual, defineStore: (_id: string, setup: () => unknown) => setup } + }) + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + const { useCliApprovalStore } = await import('@/stores/cliApproval') + let store: ReturnType | undefined + const wrapper = mount( + defineComponent({ + setup() { + store = useCliApprovalStore() + return () => null + } + }) + ) + requested?.({ + requestId: 'approval-request-1234', + operation: 'providers.setCredential', + effect: 'credential', + principal: 'human', + expiresAt: Date.now() + 60_000 + }) + await flushPromises() + + await store?.deny() + expect(store?.request.value?.requestId).toBe('approval-request-1234') + await store?.deny() + expect(store?.isOpen.value).toBe(false) + + wrapper.unmount() + error.mockRestore() + }) + + it('fails closed when the bounded renderer queue is full', async () => { + let requested: ((payload: Record) => void) | undefined + const resolve = vi.fn(async () => true) + vi.doMock('@api/ApprovalClient', () => ({ + createApprovalClient: () => ({ + onRequested: (listener: typeof requested) => { + requested = listener + return vi.fn() + }, + onClosed: () => vi.fn(), + resolve + }) + })) + vi.doMock('pinia', async () => { + const actual = await vi.importActual('pinia') + return { ...actual, defineStore: (_id: string, setup: () => unknown) => setup } + }) + + const { useCliApprovalStore } = await import('@/stores/cliApproval') + let store: ReturnType | undefined + const wrapper = mount( + defineComponent({ + setup() { + store = useCliApprovalStore() + return () => null + } + }) + ) + for (let index = 0; index < 33; index += 1) { + requested?.({ + requestId: `approval-request-${String(index).padStart(4, '0')}`, + operation: 'skills.installFromUrl', + effect: 'supply-chain', + principal: 'human', + expiresAt: Date.now() + 60_000 + }) + } + await flushPromises() + + expect(resolve).toHaveBeenCalledOnce() + expect(resolve).toHaveBeenCalledWith('approval-request-0032', 'denied') + expect(store?.request.value?.requestId).toBe('approval-request-0000') + wrapper.unmount() + }) +}) From 9503237e9a89d15629ce7b11001cf547ee0f6146 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 14:19:53 +0800 Subject: [PATCH 14/51] feat(cli): add public settings controls --- docs/architecture/local-control-plane/spec.md | 4 +- .../architecture/local-control-plane/tasks.md | 2 +- src/cli/args.ts | 72 ++++++++++- src/cli/format.ts | 14 ++- src/main/app/settingsRoutes.ts | 48 ++++--- src/main/cli/policy.ts | 32 +++-- src/main/cli/surface.ts | 119 +++++++++++++++++- src/shared/contracts/routes.ts | 4 + src/shared/contracts/routes/cli.routes.ts | 2 +- .../contracts/routes/settings.routes.ts | 16 +++ test/main/cli/args.test.ts | 32 +++++ test/main/cli/policy.test.ts | 86 +++++++++++++ test/main/cli/surface.test.ts | 88 ++++++++++--- test/main/routes/dispatcher.test.ts | 48 +++++++ 14 files changed, 514 insertions(+), 53 deletions(-) diff --git a/docs/architecture/local-control-plane/spec.md b/docs/architecture/local-control-plane/spec.md index d73d493a6..170ed1490 100644 --- a/docs/architecture/local-control-plane/spec.md +++ b/docs/architecture/local-control-plane/spec.md @@ -208,7 +208,9 @@ by renderer IPC. A surface entry adds only transport and policy metadata: ```ts type CliSurfaceEntry = { contract: RouteContract - effect: CliEffect | ((input: unknown) => CliEffect) + effect: + | CliEffect + | { possible: readonly CliEffect[]; resolve(input: unknown): CliEffect } callers: readonly CliPrincipal[] requiredScopes: readonly CliScope[] transport: 'rpc' | 'stream' | 'upload' diff --git a/docs/architecture/local-control-plane/tasks.md b/docs/architecture/local-control-plane/tasks.md index b820530f3..c8012dc64 100644 --- a/docs/architecture/local-control-plane/tasks.md +++ b/docs/architecture/local-control-plane/tasks.md @@ -56,7 +56,7 @@ ## Administration Surface -- [ ] Add public/redacted settings reads and allowlisted per-effect updates. +- [x] Add public/redacted settings reads and allowlisted per-effect updates. - [ ] Add public/redacted provider/model reads and separated credential mutations. - [ ] Add reviewed Skill list/enable/install/uninstall operations. - [ ] Add reviewed MCP list/add/update/remove/enable/start/stop operations. diff --git a/src/cli/args.ts b/src/cli/args.ts index 634a6d059..3c640dd07 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -29,7 +29,11 @@ import { ocrExtractUploadRoute, ocrGetRuntimeStatusRoute } from '@shared/contracts/routes/ocr.routes' -import type { JsonValue } from '@shared/contracts/json' +import { + settingsGetPublicRoute, + settingsUpdatePublicRoute +} from '@shared/contracts/routes/settings.routes' +import { JsonValueSchema, type JsonValue } from '@shared/contracts/json' import { LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS } from '@shared/contracts/localControl' import { ATTACHMENT_PDF_OCR_MAX_TOKENS, @@ -64,6 +68,8 @@ export type CliRpcContract = | typeof ocrExtractArtifactRoute | typeof ocrClearCacheRoute | typeof providersListPublicRoute + | typeof settingsGetPublicRoute + | typeof settingsUpdatePublicRoute export type CliCommandOperation = 'rpc' | 'stream' | 'upload' | 'download' @@ -99,7 +105,9 @@ const COMMANDS = new Map([ ['ocr status', ocrGetRuntimeStatusRoute], ['ocr extract', ocrExtractUploadRoute], ['ocr clear-cache', ocrClearCacheRoute], - ['provider list', providersListPublicRoute] + ['provider list', providersListPublicRoute], + ['settings get', settingsGetPublicRoute], + ['settings set', settingsUpdatePublicRoute] ]) function parseBoolean(value: string, source: string): boolean { @@ -128,10 +136,32 @@ function parseNumberInRange( return parsed } -type DomainOptionValue = string | number | boolean +type DomainOptionValue = JsonValue type DomainValueParser = (value: string) => DomainOptionValue const stringOption: DomainValueParser = (value) => value +const settingValueOption: DomainValueParser = (value) => { + const candidate = + value === 'true' || + value === 'false' || + value === 'null' || + /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?$/.test(value) || + value.startsWith('"') + ? (() => { + try { + return JSON.parse(value) as unknown + } catch { + throw new CliUsageError('--value contains invalid JSON') + } + })() + : value + const parsed = JsonValueSchema.safeParse(candidate) + if (!parsed.success) throw new CliUsageError('--value must be a JSON scalar') + if (parsed.data !== null && typeof parsed.data === 'object') { + throw new CliUsageError('--value must be a JSON scalar') + } + return parsed.data +} const VALUE_DOMAIN_OPTIONS: Readonly> = { id: (value) => { const parsed = ArtifactIdSchema.safeParse(value) @@ -172,6 +202,9 @@ const VALUE_DOMAIN_OPTIONS: Readonly> = { voice: stringOption, speed: (value) => parseNumberInRange(value, '--speed', 0.25, 4), instructions: stringOption, + key: stringOption, + keys: stringOption, + value: settingValueOption, backend: stringOption, 'page-count': (value) => parseNumberInRange(value, '--page-count', 1, PDF_PAGE_COUNT_SANITY_LIMIT, true) @@ -225,7 +258,9 @@ const COMMAND_DOMAIN_OPTIONS = new Map>([ ['ocr extract', new Set(['file', 'artifact', 'mime', 'backend', 'page-count', 'max-tokens'])], ['ocr status', new Set()], ['ocr clear-cache', new Set()], - ['provider list', new Set(['enabled-only'])] + ['provider list', new Set(['enabled-only'])], + ['settings get', new Set(['keys'])], + ['settings set', new Set(['key', 'value'])] ]) const AUDIO_MIME_BY_EXTENSION: Readonly> = { @@ -451,6 +486,13 @@ export function parseCliArguments( const instructions = getString('instructions') const backend = getString('backend') const sourcePageCountHint = getNumber('page-count') + const settingKey = getString('key') + const settingKeys = getString('keys') + const settingValue = domainValues.get('value') + const parsedSettingKeys = settingKeys + ?.split(',') + .map((key) => key.trim()) + .filter(Boolean) const isArtifactCommand = domain === 'artifact' if (!helpRequested && isArtifactCommand && !artifactId) { @@ -478,6 +520,8 @@ export function parseCliArguments( const isOcrExtract = commandKey === 'ocr extract' const isMediaGenerate = isImageGenerate || isVideoGenerate || isSpeechGenerate const isProviderList = commandKey === 'provider list' + const isSettingsGet = commandKey === 'settings get' + const isSettingsSet = commandKey === 'settings set' const allowedDomainOptions = COMMAND_DOMAIN_OPTIONS.get(commandKey) ?? new Set() const invalidDomainOption = Array.from(domainOptions).find( (option) => !allowedDomainOptions.has(option) @@ -527,9 +571,21 @@ export function parseCliArguments( if (isOcrExtract && maxTokens !== undefined && maxTokens > ATTACHMENT_PDF_OCR_MAX_TOKENS) { throw new CliUsageError(`--max-tokens must not exceed ${ATTACHMENT_PDF_OCR_MAX_TOKENS}`) } + if (!helpRequested && isSettingsSet && (!settingKey || !domainOptions.has('value'))) { + throw new CliUsageError('deepchat settings set requires --key and --value') + } + if (!helpRequested && isSettingsGet && settingKeys !== undefined && !parsedSettingKeys?.length) { + throw new CliUsageError('--keys must contain at least one setting key') + } let params: JsonValue = artifactId ? { id: artifactId } : {} if (isProviderList) params = { enabledOnly } + if (isSettingsGet) { + params = parsedSettingKeys ? { keys: parsedSettingKeys } : {} + } + if (isSettingsSet && settingKey && domainOptions.has('value')) { + params = { changes: [{ key: settingKey, value: settingValue ?? null }] } + } if (isModelInvoke && providerId && modelId) { params = { providerId, @@ -674,7 +730,11 @@ export function formatCliHelp(command?: Pick|--artifact )' : command.domain === 'provider' ? ' [--enabled-only]' - : '' + : command.domain === 'settings' && command.verb === 'get' + ? ' [--keys ]' + : command.domain === 'settings' + ? ' --key --value ' + : '' const commandKey = `${command.domain} ${command.verb}` const optionLines = commandKey === 'model invoke' @@ -747,6 +807,8 @@ export function formatCliHelp(command?: Pick - `${capability.method} ${capability.effect} ${capability.callers.join(',')} ${capability.transport}` + `${capability.method} ${capability.possibleEffects.join(',')} ${capability.callers.join(',')} ${capability.transport}` ) ].join('\n') } @@ -86,6 +86,18 @@ export function formatHumanResult( ]) .join('\n') } + case 'settings.getPublic': { + const result = contract.output.parse(value) + return Object.entries(result.values) + .map(([key, setting]) => `${key} = ${JSON.stringify(setting)}`) + .join('\n') + } + case 'settings.updatePublic': { + const result = contract.output.parse(value) + return result.changedKeys + .map((key) => `${key} = ${JSON.stringify(result.values[key])}`) + .join('\n') + } case 'models.invoke': { return contract.output.parse(value).text } diff --git a/src/main/app/settingsRoutes.ts b/src/main/app/settingsRoutes.ts index 6a3c1e17d..3ebd3422a 100644 --- a/src/main/app/settingsRoutes.ts +++ b/src/main/app/settingsRoutes.ts @@ -3,8 +3,10 @@ import { configGetEntriesRoute, configUpdateEntriesRoute, settingsActivityListRoute, + settingsGetPublicRoute, settingsGetSnapshotRoute, settingsListSystemFontsRoute, + settingsUpdatePublicRoute, settingsUpdateRoute, type ConfigEntryKey, type ConfigEntryValues, @@ -171,6 +173,22 @@ export function createAppSettingsRoutes(deps: { summaryParams: { key: change.key } }) } + const getSnapshot = (keys?: SettingsKey[]) => ({ + version: Date.now(), + values: pickSnapshot(readSnapshot(), keys) + }) + const updateSnapshot = (changes: SettingsChange[]) => { + for (const change of changes) { + applyChange(change) + recordChange(change) + } + const changedKeys = changes.map((change) => change.key) + return { + version: Date.now(), + changedKeys, + values: pickSnapshot(readSnapshot(), changedKeys) + } + } return createRouteMap([ [ @@ -199,10 +217,14 @@ export function createAppSettingsRoutes(deps: { settingsGetSnapshotRoute.name, async (rawInput) => { const input = settingsGetSnapshotRoute.input.parse(rawInput) - return settingsGetSnapshotRoute.output.parse({ - version: Date.now(), - values: pickSnapshot(readSnapshot(), input.keys) - }) + return settingsGetSnapshotRoute.output.parse(getSnapshot(input.keys)) + } + ], + [ + settingsGetPublicRoute.name, + async (rawInput) => { + const input = settingsGetPublicRoute.input.parse(rawInput) + return settingsGetPublicRoute.output.parse(getSnapshot(input.keys)) } ], [ @@ -218,16 +240,14 @@ export function createAppSettingsRoutes(deps: { settingsUpdateRoute.name, async (rawInput) => { const input = settingsUpdateRoute.input.parse(rawInput) - for (const change of input.changes) { - applyChange(change) - recordChange(change) - } - const changedKeys = input.changes.map((change) => change.key) - return settingsUpdateRoute.output.parse({ - version: Date.now(), - changedKeys, - values: pickSnapshot(readSnapshot(), changedKeys) - }) + return settingsUpdateRoute.output.parse(updateSnapshot(input.changes)) + } + ], + [ + settingsUpdatePublicRoute.name, + async (rawInput) => { + const input = settingsUpdatePublicRoute.input.parse(rawInput) + return settingsUpdatePublicRoute.output.parse(updateSnapshot(input.changes)) } ], [ diff --git a/src/main/cli/policy.ts b/src/main/cli/policy.ts index 8c531ba98..7f0a6f5ae 100644 --- a/src/main/cli/policy.ts +++ b/src/main/cli/policy.ts @@ -4,7 +4,7 @@ import { hashApprovalArguments } from '@/approval' import type { CliRouteCaller } from '@/routes/routeRegistry' import { CliRequestError } from './errors' import type { CliMutationGuard } from './mutationGuard' -import type { CliSurfaceEntry } from './surface' +import { resolveCliSurfaceEffect, type CliSurfaceEntry } from './surface' const DEFAULT_AGENT_COMPUTE_LIMIT = 2 const DEFAULT_AGENT_COMPUTE_STARTS_PER_MINUTE = 20 @@ -118,6 +118,18 @@ export class CliRequestPolicy { } async authorize(input: CliRequestPolicyInput): Promise { + let effect: LocalControlEffect + let agentInputAllowed = true + try { + effect = resolveCliSurfaceEffect(input.entry, input.input) + if (input.caller.principal === 'agent') { + agentInputAllowed = input.entry.agentInputAllowed?.(input.input) ?? true + } + } catch { + throw new CliRequestError('internal_error', 'CLI effect policy is misconfigured', { + httpStatus: 500 + }) + } const redactedArgumentsHash = hashApprovalArguments({ operation: input.entry.contract.name, arguments: auditProjection(input.entry, input.input) @@ -134,7 +146,7 @@ export class CliRequestPolicy { ? { conversationId: input.caller.conversationId } : {}), operation: input.entry.contract.name, - effect: input.entry.effect, + effect, outcome, requestId: input.requestId, ...(approvalRequestId ? { approvalRequestId } : {}), @@ -144,7 +156,8 @@ export class CliRequestPolicy { if ( !input.entry.callers.includes(input.caller.principal) || - !input.entry.scopes.every((scope) => input.caller.scopes.includes(scope)) + !input.entry.scopes.every((scope) => input.caller.scopes.includes(scope)) || + !agentInputAllowed ) { await audit('denied') throw new CliRequestError('permission_denied', 'Caller lacks access to this operation', { @@ -153,7 +166,7 @@ export class CliRequestPolicy { } const effectDecision = resolveEffectDecision( - input.entry.effect, + effect, input.caller, input.entry.contract.name, this.agentApprovalOperations @@ -176,7 +189,7 @@ export class CliRequestPolicy { try { const approval = await this.options.mutationGuard.authorize({ operation: input.entry.contract.name, - effect: input.entry.effect, + effect, principal: input.caller.principal, connectionId: input.caller.connectionId, clientRequestId: input.requestId, @@ -194,7 +207,7 @@ export class CliRequestPolicy { let admission: CliRequestAdmission try { - admission = this.admitCompute(input) + admission = this.admitCompute(input, effect) } catch (error) { if (error instanceof CliRequestError && error.code === 'rate_limited') { await audit('rate-limited') @@ -210,8 +223,11 @@ export class CliRequestPolicy { } } - private admitCompute(input: CliRequestPolicyInput): CliRequestAdmission { - if (input.entry.effect !== 'compute' || input.caller.principal !== 'agent') { + private admitCompute( + input: CliRequestPolicyInput, + effect: LocalControlEffect + ): CliRequestAdmission { + if (effect !== 'compute' || input.caller.principal !== 'agent') { return emptyRelease() } diff --git a/src/main/cli/surface.ts b/src/main/cli/surface.ts index 594aa94bf..a135b1cb6 100644 --- a/src/main/cli/surface.ts +++ b/src/main/cli/surface.ts @@ -20,6 +20,8 @@ import { ocrGetRuntimeStatusRoute, providersListPublicRoute, speechGenerateRoute, + settingsGetPublicRoute, + settingsUpdatePublicRoute, videosGenerateRoute, type CliCapability } from '@shared/contracts/routes' @@ -32,6 +34,12 @@ import { export type LocalControlTransport = 'rpc' | 'stream' | 'upload' | 'download' export type LocalControlApprovalMode = 'never' | 'policy' +export type CliSurfaceEffect = + | LocalControlEffect + | Readonly<{ + possible: readonly LocalControlEffect[] + resolve(input: unknown): LocalControlEffect + }> export type CliRouteLimits = Readonly<{ maxBodyBytes: number @@ -40,16 +48,92 @@ export type CliRouteLimits = Readonly<{ export type CliSurfaceEntry = Readonly<{ contract: RouteContract - effect: LocalControlEffect + effect: CliSurfaceEffect callers: readonly LocalControlPrincipal[] scopes: readonly LocalControlScope[] transport: LocalControlTransport approval: LocalControlApprovalMode auditProjection?: (input: unknown) => JsonValue approvalDisplay?: (input: unknown) => JsonValue + agentInputAllowed?: (input: unknown) => boolean limits: CliRouteLimits }> +const PREFERENCE_SETTING_KEYS = new Set([ + 'fontSizeLevel', + 'fontFamily', + 'codeFontFamily', + 'artifactsEffectEnabled', + 'autoScrollEnabled', + 'notificationsEnabled', + 'copyWithCotEnabled' +]) + +const EXECUTION_SETTING_KEYS = new Set([ + 'autoCompactionEnabled', + 'autoCompactionTriggerThreshold', + 'autoCompactionRetainRecentPairs', + 'ocrAutoExtractForNonVisionModels', + 'ocrBackend' +]) + +function settingChangeKeys(input: unknown): string[] { + if (!input || typeof input !== 'object' || Array.isArray(input)) return [] + const changes = (input as Record).changes + if (!Array.isArray(changes)) return [] + return changes.flatMap((change) => { + if (!change || typeof change !== 'object' || Array.isArray(change)) return [] + const key = (change as Record).key + return typeof key === 'string' ? [key] : [] + }) +} + +function settingChangesForDisplay(input: unknown): JsonValue { + if (!input || typeof input !== 'object' || Array.isArray(input)) return [] + const changes = (input as Record).changes + if (!Array.isArray(changes)) return [] + return changes.flatMap((change) => { + if (!change || typeof change !== 'object' || Array.isArray(change)) return [] + const { key, value } = change as Record + if ( + typeof key !== 'string' || + !( + value === null || + typeof value === 'string' || + (typeof value === 'number' && Number.isFinite(value)) || + typeof value === 'boolean' + ) + ) { + return [] + } + return [{ key, value }] + }) +} + +function stringArrayField(input: unknown, field: string): string[] { + if (!input || typeof input !== 'object' || Array.isArray(input)) return [] + const value = (input as Record)[field] + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === 'string') + : [] +} + +export function listCliSurfaceEffects(entry: CliSurfaceEntry): readonly LocalControlEffect[] { + return typeof entry.effect === 'string' ? [entry.effect] : entry.effect.possible +} + +export function resolveCliSurfaceEffect( + entry: CliSurfaceEntry, + input: unknown +): LocalControlEffect { + if (typeof entry.effect === 'string') return entry.effect + const resolved = entry.effect.resolve(input) + if (!entry.effect.possible.includes(resolved)) { + throw new Error(`CLI surface effect resolver returned an undeclared effect: ${resolved}`) + } + return resolved +} + function selectAuditFields(input: unknown, fields: readonly string[]): Record { if (!input || typeof input !== 'object' || Array.isArray(input)) return {} const source = input as Record @@ -217,6 +301,37 @@ const CLI_SURFACE_V1_ENTRIES = [ approval: 'never', limits: DIAGNOSTIC_LIMITS }, + { + contract: settingsGetPublicRoute, + effect: 'read', + callers: ['human', 'agent'], + scopes: ['settings:read'], + transport: 'rpc', + approval: 'never', + auditProjection: (input) => ({ keys: stringArrayField(input, 'keys') }), + limits: DIAGNOSTIC_LIMITS + }, + { + contract: settingsUpdatePublicRoute, + effect: { + possible: ['preference-write', 'execution-config', 'security-config'], + resolve: (input) => { + const keys = settingChangeKeys(input) + if (keys.every((key) => PREFERENCE_SETTING_KEYS.has(key))) return 'preference-write' + if (keys.every((key) => EXECUTION_SETTING_KEYS.has(key))) return 'execution-config' + return 'security-config' + } + }, + callers: ['human', 'agent'], + scopes: ['settings:write'], + transport: 'rpc', + approval: 'policy', + auditProjection: (input) => ({ keys: settingChangeKeys(input) }), + approvalDisplay: (input) => ({ changes: settingChangesForDisplay(input) }), + agentInputAllowed: (input) => + settingChangeKeys(input).every((key) => PREFERENCE_SETTING_KEYS.has(key)), + limits: DIAGNOSTIC_LIMITS + }, { contract: artifactsDescribeRoute, effect: 'read', @@ -275,7 +390,7 @@ export function getCliSurfaceEntry(method: string): CliSurfaceEntry | undefined export function listCliSurfaceCapabilities(): CliCapability[] { return Array.from(CLI_SURFACE_V1.values(), (entry) => ({ method: entry.contract.name, - effect: entry.effect, + possibleEffects: [...listCliSurfaceEffects(entry)], callers: [...entry.callers], scopes: [...entry.scopes], transport: entry.transport, diff --git a/src/shared/contracts/routes.ts b/src/shared/contracts/routes.ts index ff8369351..00caa657d 100644 --- a/src/shared/contracts/routes.ts +++ b/src/shared/contracts/routes.ts @@ -407,8 +407,10 @@ import { } from './routes/plugins.routes' import { settingsActivityListRoute, + settingsGetPublicRoute, settingsGetSnapshotRoute, settingsListSystemFontsRoute, + settingsUpdatePublicRoute, settingsUpdateRoute } from './routes/settings.routes' import { @@ -861,8 +863,10 @@ const DEEPCHAT_ROUTE_CATALOG_PART_3 = { [configGetAwsBedrockCredentialRoute.name]: configGetAwsBedrockCredentialRoute, [configSetAwsBedrockCredentialRoute.name]: configSetAwsBedrockCredentialRoute, [settingsGetSnapshotRoute.name]: settingsGetSnapshotRoute, + [settingsGetPublicRoute.name]: settingsGetPublicRoute, [settingsListSystemFontsRoute.name]: settingsListSystemFontsRoute, [settingsUpdateRoute.name]: settingsUpdateRoute, + [settingsUpdatePublicRoute.name]: settingsUpdatePublicRoute, [settingsActivityListRoute.name]: settingsActivityListRoute, [startupGetBootstrapRoute.name]: startupGetBootstrapRoute, [performanceRecordRendererRoute.name]: performanceRecordRendererRoute diff --git a/src/shared/contracts/routes/cli.routes.ts b/src/shared/contracts/routes/cli.routes.ts index c54031797..125512ce7 100644 --- a/src/shared/contracts/routes/cli.routes.ts +++ b/src/shared/contracts/routes/cli.routes.ts @@ -15,7 +15,7 @@ export const LocalControlApprovalModeSchema = z.enum(['never', 'policy']) export const LocalControlCapabilitySchema = z .object({ method: LocalControlMethodSchema, - effect: LocalControlEffectSchema, + possibleEffects: z.array(LocalControlEffectSchema).min(1), callers: z.array(LocalControlPrincipalSchema).min(1).max(2), scopes: z.array(LocalControlScopeSchema).min(1), transport: LocalControlTransportSchema, diff --git a/src/shared/contracts/routes/settings.routes.ts b/src/shared/contracts/routes/settings.routes.ts index 91b22a696..ad49b7d4d 100644 --- a/src/shared/contracts/routes/settings.routes.ts +++ b/src/shared/contracts/routes/settings.routes.ts @@ -147,6 +147,22 @@ export const settingsUpdateRoute = defineRouteContract({ }) }) +export const settingsGetPublicRoute = defineRouteContract({ + name: 'settings.getPublic', + input: settingsGetSnapshotRoute.input, + output: settingsGetSnapshotRoute.output +}) + +export const settingsUpdatePublicRoute = defineRouteContract({ + name: 'settings.updatePublic', + input: z + .object({ + changes: z.array(SettingsChangeSchema).length(1) + }) + .strict(), + output: settingsUpdateRoute.output +}) + export const SettingsActivityCategorySchema = z.enum([ 'provider', 'model', diff --git a/test/main/cli/args.test.ts b/test/main/cli/args.test.ts index 5e301df66..115bc85ea 100644 --- a/test/main/cli/args.test.ts +++ b/test/main/cli/args.test.ts @@ -154,6 +154,38 @@ describe('CLI argument grammar', () => { }) }) + it('parses allowlisted settings reads and one scalar update', () => { + expect( + parseCliArguments(['settings', 'get', '--keys', 'fontSizeLevel, privacyModeEnabled'], {}) + ).toMatchObject({ + contract: { name: 'settings.getPublic' }, + params: { keys: ['fontSizeLevel', 'privacyModeEnabled'] } + }) + expect( + parseCliArguments(['settings', 'set', '--key', 'fontSizeLevel', '--value', '3'], {}) + ).toMatchObject({ + contract: { name: 'settings.updatePublic' }, + params: { changes: [{ key: 'fontSizeLevel', value: 3 }] } + }) + expect( + parseCliArguments(['settings', 'set', '--key', 'fontFamily', '--value', 'Berkeley Mono'], {}) + ).toMatchObject({ + params: { changes: [{ key: 'fontFamily', value: 'Berkeley Mono' }] } + }) + expect( + parseCliArguments(['settings', 'set', '--key', 'fontSizeLevel', '--value', '3e0'], {}) + ).toMatchObject({ + params: { changes: [{ key: 'fontSizeLevel', value: 3 }] } + }) + + expect(() => parseCliArguments(['settings', 'get', '--keys', ','], {})).toThrow( + 'at least one setting key' + ) + expect(() => parseCliArguments(['settings', 'set', '--key', 'loggingEnabled'], {})).toThrow( + 'requires --key and --value' + ) + }) + it('maps image and video options without exposing file output paths', () => { expect( parseCliArguments( diff --git a/test/main/cli/policy.test.ts b/test/main/cli/policy.test.ts index c7cb96e10..b5bcc467c 100644 --- a/test/main/cli/policy.test.ts +++ b/test/main/cli/policy.test.ts @@ -137,6 +137,92 @@ describe('CliRequestPolicy', () => { expect(harness.auditRecords.at(-1)?.outcome).toBe('misconfigured') }) + it('resolves input-dependent effects and rejects undeclared resolver output', async () => { + const harness = createHarness() + const dynamicEntry = { + ...entry('read', 'policy'), + effect: { + possible: ['preference-write', 'security-config'], + resolve: (input: unknown) => + (input as { secure?: boolean }).secure ? 'security-config' : 'preference-write' + } + } satisfies CliSurfaceEntry + + await expect( + harness.policy.authorize({ + entry: dynamicEntry, + input: { secure: false }, + caller: humanCaller, + requestId: 'request-preference', + signal: new AbortController().signal + }) + ).resolves.toBeDefined() + expect(harness.authorize).not.toHaveBeenCalled() + await expect( + harness.policy.authorize({ + entry: dynamicEntry, + input: { secure: true }, + caller: humanCaller, + requestId: 'request-security', + signal: new AbortController().signal + }) + ).resolves.toBeDefined() + expect(harness.authorize).toHaveBeenCalledOnce() + expect(harness.auditRecords.map((record) => record.effect)).toEqual([ + 'preference-write', + 'security-config' + ]) + + await expect( + harness.policy.authorize({ + entry: { + ...dynamicEntry, + effect: { possible: ['read'], resolve: () => 'destructive' } + }, + input: {}, + caller: humanCaller, + requestId: 'request-invalid-effect', + signal: new AbortController().signal + }) + ).rejects.toMatchObject({ code: 'internal_error' }) + }) + + it('applies input-level agent restrictions before requesting approval', async () => { + const harness = createHarness({ allowlisted: true }) + + await expect( + harness.policy.authorize({ + entry: { ...entry('preference-write', 'policy'), agentInputAllowed: () => false }, + input: {}, + caller: agentCaller, + requestId: 'request-agent-input', + signal: new AbortController().signal + }) + ).rejects.toMatchObject({ code: 'permission_denied' }) + expect(harness.authorize).not.toHaveBeenCalled() + expect(harness.auditRecords.at(-1)?.outcome).toBe('denied') + }) + + it('fails closed when an input-level agent policy throws', async () => { + const harness = createHarness({ allowlisted: true }) + + await expect( + harness.policy.authorize({ + entry: { + ...entry('preference-write', 'policy'), + agentInputAllowed: () => { + throw new Error('broken policy') + } + }, + input: {}, + caller: agentCaller, + requestId: 'request-agent-policy-error', + signal: new AbortController().signal + }) + ).rejects.toMatchObject({ code: 'internal_error' }) + expect(harness.authorize).not.toHaveBeenCalled() + }) + it('audits and denies callers or scopes excluded by the surface entry', async () => { const harness = createHarness() await expect( diff --git a/test/main/cli/surface.test.ts b/test/main/cli/surface.test.ts index 06bf2c4d9..da02a7d5a 100644 --- a/test/main/cli/surface.test.ts +++ b/test/main/cli/surface.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' import { DEEPCHAT_ROUTE_CATALOG } from '@shared/contracts/routes' -import { CLI_SURFACE_V1, getCliSurfaceEntry, listCliSurfaceCapabilities } from '@/cli/surface' +import { + CLI_SURFACE_V1, + getCliSurfaceEntry, + listCliSurfaceCapabilities, + resolveCliSurfaceEffect +} from '@/cli/surface' describe('CLI surface V1', () => { it('contains only explicit canonical route contracts', () => { @@ -23,6 +28,8 @@ describe('CLI surface V1', () => { 'ocr.extractUpload', 'ocr.getRuntimeStatus', 'providers.listPublic', + 'settings.getPublic', + 'settings.updatePublic', 'speech.generate', 'videos.generate' ]) @@ -40,72 +47,113 @@ describe('CLI surface V1', () => { expect(getCliSurfaceEntry('approvals.resolve')).toBeUndefined() }) + it('classifies public setting changes from their validated key', () => { + const entry = getCliSurfaceEntry('settings.updatePublic')! + + expect( + resolveCliSurfaceEffect(entry, { + changes: [{ key: 'fontSizeLevel', value: 3 }] + }) + ).toBe('preference-write') + expect( + resolveCliSurfaceEffect(entry, { + changes: [{ key: 'loggingEnabled', value: true }] + }) + ).toBe('security-config') + expect( + resolveCliSurfaceEffect(entry, { + changes: [{ key: 'ocrBackend', value: 'cpu' }] + }) + ).toBe('execution-config') + expect( + entry.agentInputAllowed?.({ changes: [{ key: 'privacyModeEnabled', value: true }] }) + ).toBe(false) + expect( + entry.approvalDisplay?.({ changes: [{ key: 'privacyModeEnabled', value: true }] }) + ).toEqual({ changes: [{ key: 'privacyModeEnabled', value: true }] }) + }) + it('publishes stable sorted capability metadata', () => { expect(listCliSurfaceCapabilities()).toEqual([ - expect.objectContaining({ method: 'artifacts.delete', effect: 'local-maintenance' }), - expect.objectContaining({ method: 'artifacts.describe', effect: 'read' }), - expect.objectContaining({ method: 'artifacts.read', effect: 'read', transport: 'download' }), + expect.objectContaining({ + method: 'artifacts.delete', + possibleEffects: ['local-maintenance'] + }), + expect.objectContaining({ method: 'artifacts.describe', possibleEffects: ['read'] }), + expect.objectContaining({ + method: 'artifacts.read', + possibleEffects: ['read'], + transport: 'download' + }), expect.objectContaining({ method: 'audio.transcribeArtifact', - effect: 'compute', + possibleEffects: ['compute'], transport: 'rpc', callers: ['human', 'agent'], scopes: ['audio:transcribe', 'artifacts:read'] }), expect.objectContaining({ method: 'audio.transcribeUpload', - effect: 'compute', + possibleEffects: ['compute'], transport: 'upload', callers: ['human'] }), - expect.objectContaining({ method: 'cli.capabilities', effect: 'read' }), - expect.objectContaining({ method: 'cli.doctor', effect: 'read' }), - expect.objectContaining({ method: 'cli.status', effect: 'read' }), - expect.objectContaining({ method: 'cli.version', effect: 'read' }), + expect.objectContaining({ method: 'cli.capabilities', possibleEffects: ['read'] }), + expect.objectContaining({ method: 'cli.doctor', possibleEffects: ['read'] }), + expect.objectContaining({ method: 'cli.status', possibleEffects: ['read'] }), + expect.objectContaining({ method: 'cli.version', possibleEffects: ['read'] }), expect.objectContaining({ method: 'images.generate', - effect: 'compute', + possibleEffects: ['compute'], transport: 'stream' }), expect.objectContaining({ method: 'models.invoke', - effect: 'compute', + possibleEffects: ['compute'], transport: 'stream' }), expect.objectContaining({ method: 'ocr.clearCache', - effect: 'local-maintenance', + possibleEffects: ['local-maintenance'], approval: 'never', callers: ['human'] }), expect.objectContaining({ method: 'ocr.extractArtifact', - effect: 'compute', + possibleEffects: ['compute'], transport: 'rpc', callers: ['human', 'agent'], scopes: ['ocr:extract', 'artifacts:read'] }), expect.objectContaining({ method: 'ocr.extractUpload', - effect: 'compute', + possibleEffects: ['compute'], transport: 'upload', callers: ['human'] }), - expect.objectContaining({ method: 'ocr.getRuntimeStatus', effect: 'read' }), - expect.objectContaining({ method: 'providers.listPublic', effect: 'read' }), + expect.objectContaining({ method: 'ocr.getRuntimeStatus', possibleEffects: ['read'] }), + expect.objectContaining({ method: 'providers.listPublic', possibleEffects: ['read'] }), + expect.objectContaining({ method: 'settings.getPublic', possibleEffects: ['read'] }), + expect.objectContaining({ + method: 'settings.updatePublic', + possibleEffects: ['preference-write', 'execution-config', 'security-config'], + approval: 'policy' + }), expect.objectContaining({ method: 'speech.generate', - effect: 'compute', + possibleEffects: ['compute'], transport: 'stream' }), expect.objectContaining({ method: 'videos.generate', - effect: 'compute', + possibleEffects: ['compute'], transport: 'stream' }) ]) expect( - listCliSurfaceCapabilities().every((capability) => capability.approval === 'never') + listCliSurfaceCapabilities() + .filter((capability) => capability.method !== 'settings.updatePublic') + .every((capability) => capability.approval === 'never') ).toBe(true) }) }) diff --git a/test/main/routes/dispatcher.test.ts b/test/main/routes/dispatcher.test.ts index 4bb012051..61f844d29 100644 --- a/test/main/routes/dispatcher.test.ts +++ b/test/main/routes/dispatcher.test.ts @@ -2166,6 +2166,22 @@ describe('dispatchDeepchatRoute', () => { }) }) + it('exposes the same allowlisted settings through the public route', async () => { + const { runtime } = createRuntime() + + await expect( + dispatchDeepchatRoute( + runtime, + 'settings.getPublic', + { keys: ['fontSizeLevel', 'privacyModeEnabled'] }, + createRendererRouteContext(42, 7) + ) + ).resolves.toEqual({ + version: expect.any(Number), + values: { fontSizeLevel: 2, privacyModeEnabled: false } + }) + }) + it('lists system fonts through the settings handler adapter', async () => { const { runtime, fontSettings } = createRuntime() @@ -3168,6 +3184,38 @@ describe('dispatchDeepchatRoute', () => { }) }) + it('limits each public settings mutation to one typed change', async () => { + const { runtime, settings } = createRuntime() + const context = createRendererRouteContext(42, 7) + + await expect( + dispatchDeepchatRoute( + runtime, + 'settings.updatePublic', + { changes: [{ key: 'fontSizeLevel', value: 3 }] }, + context + ) + ).resolves.toMatchObject({ + changedKeys: ['fontSizeLevel'], + values: { fontSizeLevel: 3 } + }) + await expect( + dispatchDeepchatRoute( + runtime, + 'settings.updatePublic', + { + changes: [ + { key: 'fontSizeLevel', value: 4 }, + { key: 'privacyModeEnabled', value: true } + ] + }, + context + ) + ).rejects.toThrow() + expect(settings.fontSizeLevel).toBe(3) + expect(settings.privacyModeEnabled).toBe(false) + }) + it('dispatches built-in knowledge config routes through KnowledgeSettings', async () => { const { runtime, providerSettings } = createRuntime() const nextConfigs = [ From ad91a2ac3f128483595c48018cc9242c8bfb7d72 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 14:41:44 +0800 Subject: [PATCH 15/51] feat(cli): add provider model controls --- docs/architecture/local-control-plane/spec.md | 8 +- .../architecture/local-control-plane/tasks.md | 2 +- src/cli/args.ts | 181 +++++++++++- src/cli/format.ts | 41 ++- src/cli/run.ts | 34 ++- src/main/app/composition.ts | 17 +- src/main/cli/computeService.ts | 7 +- src/main/cli/index.ts | 4 + src/main/cli/providerModelAdminRoutes.ts | 260 ++++++++++++++++ src/main/cli/surface.ts | 150 +++++++++- src/main/provider/routes.ts | 22 +- src/shared/contracts/routes.ts | 12 + src/shared/contracts/routes/models.routes.ts | 36 +++ .../contracts/routes/providers.routes.ts | 106 +++++++ test/main/cli/args.test.ts | 72 +++++ test/main/cli/client.test.ts | 89 ++++++ test/main/cli/computeService.test.ts | 1 + .../main/cli/providerModelAdminRoutes.test.ts | 279 ++++++++++++++++++ test/main/cli/surface.test.ts | 110 ++++++- test/main/provider/routes.test.ts | 50 ++++ 20 files changed, 1456 insertions(+), 25 deletions(-) create mode 100644 src/main/cli/providerModelAdminRoutes.ts create mode 100644 test/main/cli/providerModelAdminRoutes.test.ts diff --git a/docs/architecture/local-control-plane/spec.md b/docs/architecture/local-control-plane/spec.md index 170ed1490..73b8586f2 100644 --- a/docs/architecture/local-control-plane/spec.md +++ b/docs/architecture/local-control-plane/spec.md @@ -243,7 +243,7 @@ confirmation flag. | 5. Offline OCR | `ocr.getRuntimeStatus`, `ocr.extractUpload`, `ocr.extractArtifact`, `ocr.clearCache`; `deepchat ocr …` | read / compute / local-maintenance | H; scoped A uses owned inputs and cannot clear | never | bounded text/metrics JSON | | 6. Full Agent run | `sessions.runDetached`; `deepchat agent run` | compute | H only | never | durable run ID + targeted JSONL | | 7. Settings | `settings.getPublic`, `settings.updatePublic`; `deepchat settings …` | read or key-derived mutation | H; scoped A for allowlisted keys | policy by effect | redacted JSON | -| 8. Provider/model administration | `providers.listPublic`, `providers.testConnection`, `providers.addPublic`, `providers.updatePublic`, `providers.remove`, `providers.setCredential`, `models.listRuntime`, `models.setStatus`, `models.getConfig`, `models.setConfig`, `models.resetConfig`; `deepchat provider …`, `deepchat model config …` | read / execution-config / credential / destructive | H; A is read-only | policy for mutations | redacted JSON | +| 8. Provider/model administration | `providers.listPublic`, `providers.testPublicConnection`, `providers.addPublic`, `providers.updatePublic`, `providers.remove`, `providers.setCredential`, `models.listRuntime`, `models.setStatus`, `models.getPublicConfig`, `models.setPublicConfig`, `models.resetConfig`; `deepchat provider …`, `deepchat model config …` | read / execution-config / credential / destructive | H; A is read-only | policy for mutations | redacted JSON | | 9. Skills | `skills.listPublic`, `skills.setDisabled`, `skills.installFromUrl`, `skills.installUpload`, `skills.uninstall`; `deepchat skill …` | read / supply-chain / destructive | H; scoped A may request allowlisted mutations | policy for mutations | JSON | | 10. MCP | `mcp.listPublic`, `mcp.addPublic`, `mcp.updatePublic`, `mcp.remove`, `mcp.setServerEnabled`, `mcp.startServer`, `mcp.stopServer`; `deepchat mcp …` | read / security-config / supply-chain / destructive | H; scoped A may request allowlisted non-credential mutations | policy for mutations | redacted JSON/events | | 11. Runs, events, artifacts | `runs.get`, `runs.cancel`, `events.subscribe`, `artifacts.describe`, `artifacts.read`, `artifacts.delete`; `deepchat run …` | read / local-maintenance | H owns all; A may inspect/pass owned IDs but cannot read bytes, delete, or cancel unrelated work | never | JSONL or binary artifact for H; metadata for A | @@ -255,6 +255,12 @@ Surface names and contracts are frozen by `surfaceVersion`. Additive entries req capability and surface-version change policy; removal or semantic incompatibility requires a new surface major. App and protocol versions are reported independently. +Provider creation and updates accept only allowlisted adapter fields and credential-free HTTP(S) +base URLs. V1 `providers.setCredential` handles API keys only, read from bounded stdin; OAuth flows +and structured AWS/Vertex credentials remain on their existing typed renderer flows instead of +accepting a generic credential object. Public model-config contracts reject unknown fields and omit +main-owned identity fields even though the legacy renderer contract remains intentionally loose. + ## Caller Model and Route Migration The current optional renderer fields become a discriminated caller: diff --git a/docs/architecture/local-control-plane/tasks.md b/docs/architecture/local-control-plane/tasks.md index c8012dc64..74514cda9 100644 --- a/docs/architecture/local-control-plane/tasks.md +++ b/docs/architecture/local-control-plane/tasks.md @@ -57,7 +57,7 @@ ## Administration Surface - [x] Add public/redacted settings reads and allowlisted per-effect updates. -- [ ] Add public/redacted provider/model reads and separated credential mutations. +- [x] Add public/redacted provider/model reads and separated credential mutations. - [ ] Add reviewed Skill list/enable/install/uninstall operations. - [ ] Add reviewed MCP list/add/update/remove/enable/start/stop operations. - [ ] Prove raw MCP calls, arbitrary internal routes, secret reads, and Agent destructive operations are diff --git a/src/cli/args.ts b/src/cli/args.ts index 3c640dd07..37cabbc39 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -15,13 +15,27 @@ import { audioTranscribeArtifactRoute, audioTranscribeUploadRoute } from '@shared/contracts/routes/audio.routes' -import { modelsInvokeRoute } from '@shared/contracts/routes/models.routes' +import { + modelsGetPublicConfigRoute, + modelsInvokeRoute, + modelsListRuntimeRoute, + modelsResetConfigRoute, + modelsSetPublicConfigRoute, + modelsSetStatusRoute +} from '@shared/contracts/routes/models.routes' import { imagesGenerateRoute, speechGenerateRoute, videosGenerateRoute } from '@shared/contracts/routes/media.routes' -import { providersListPublicRoute } from '@shared/contracts/routes/providers.routes' +import { + providersAddPublicRoute, + providersListPublicRoute, + providersRemoveRoute, + providersSetCredentialRoute, + providersTestPublicConnectionRoute, + providersUpdatePublicRoute +} from '@shared/contracts/routes/providers.routes' import { OCR_EXTRACTION_MAX_INPUT_BYTES, ocrClearCacheRoute, @@ -68,6 +82,16 @@ export type CliRpcContract = | typeof ocrExtractArtifactRoute | typeof ocrClearCacheRoute | typeof providersListPublicRoute + | typeof providersTestPublicConnectionRoute + | typeof providersAddPublicRoute + | typeof providersUpdatePublicRoute + | typeof providersSetCredentialRoute + | typeof providersRemoveRoute + | typeof modelsListRuntimeRoute + | typeof modelsGetPublicConfigRoute + | typeof modelsSetStatusRoute + | typeof modelsSetPublicConfigRoute + | typeof modelsResetConfigRoute | typeof settingsGetPublicRoute | typeof settingsUpdatePublicRoute @@ -106,6 +130,18 @@ const COMMANDS = new Map([ ['ocr extract', ocrExtractUploadRoute], ['ocr clear-cache', ocrClearCacheRoute], ['provider list', providersListPublicRoute], + ['provider test', providersTestPublicConnectionRoute], + ['provider add', providersAddPublicRoute], + ['provider update', providersUpdatePublicRoute], + ['provider set-credential', providersSetCredentialRoute], + ['provider clear-credential', providersSetCredentialRoute], + ['provider remove', providersRemoveRoute], + ['model list', modelsListRuntimeRoute], + ['model config-get', modelsGetPublicConfigRoute], + ['model enable', modelsSetStatusRoute], + ['model disable', modelsSetStatusRoute], + ['model config-set', modelsSetPublicConfigRoute], + ['model config-reset', modelsResetConfigRoute], ['settings get', settingsGetPublicRoute], ['settings set', settingsUpdatePublicRoute] ]) @@ -202,6 +238,10 @@ const VALUE_DOMAIN_OPTIONS: Readonly> = { voice: stringOption, speed: (value) => parseNumberInRange(value, '--speed', 0.25, 4), instructions: stringOption, + name: stringOption, + 'api-type': stringOption, + 'base-url': stringOption, + enabled: (value) => parseBoolean(value, '--enabled'), key: stringOption, keys: stringOption, value: settingValueOption, @@ -259,6 +299,18 @@ const COMMAND_DOMAIN_OPTIONS = new Map>([ ['ocr status', new Set()], ['ocr clear-cache', new Set()], ['provider list', new Set(['enabled-only'])], + ['provider test', new Set(['provider', 'model'])], + ['provider add', new Set(['name', 'api-type', 'base-url', 'enabled'])], + ['provider update', new Set(['provider', 'name', 'api-type', 'base-url', 'enabled'])], + ['provider set-credential', new Set(['provider', 'stdin'])], + ['provider clear-credential', new Set(['provider'])], + ['provider remove', new Set(['provider'])], + ['model list', new Set(['provider'])], + ['model config-get', new Set(['provider', 'model'])], + ['model enable', new Set(['provider', 'model'])], + ['model disable', new Set(['provider', 'model'])], + ['model config-set', new Set(['provider', 'model', 'stdin'])], + ['model config-reset', new Set(['provider', 'model'])], ['settings get', new Set(['keys'])], ['settings set', new Set(['key', 'value'])] ]) @@ -489,6 +541,10 @@ export function parseCliArguments( const settingKey = getString('key') const settingKeys = getString('keys') const settingValue = domainValues.get('value') + const providerName = getString('name') + const providerApiType = getString('api-type') + const providerBaseUrl = getString('base-url') + const providerEnabled = getBoolean('enabled') const parsedSettingKeys = settingKeys ?.split(',') .map((key) => key.trim()) @@ -520,6 +576,17 @@ export function parseCliArguments( const isOcrExtract = commandKey === 'ocr extract' const isMediaGenerate = isImageGenerate || isVideoGenerate || isSpeechGenerate const isProviderList = commandKey === 'provider list' + const isProviderTest = commandKey === 'provider test' + const isProviderAdd = commandKey === 'provider add' + const isProviderUpdate = commandKey === 'provider update' + const isProviderSetCredential = commandKey === 'provider set-credential' + const isProviderClearCredential = commandKey === 'provider clear-credential' + const isProviderRemove = commandKey === 'provider remove' + const isModelList = commandKey === 'model list' + const isModelConfigGet = commandKey === 'model config-get' + const isModelStatus = commandKey === 'model enable' || commandKey === 'model disable' + const isModelConfigSet = commandKey === 'model config-set' + const isModelConfigReset = commandKey === 'model config-reset' const isSettingsGet = commandKey === 'settings get' const isSettingsSet = commandKey === 'settings set' const allowedDomainOptions = COMMAND_DOMAIN_OPTIONS.get(commandKey) ?? new Set() @@ -577,9 +644,83 @@ export function parseCliArguments( if (!helpRequested && isSettingsGet && settingKeys !== undefined && !parsedSettingKeys?.length) { throw new CliUsageError('--keys must contain at least one setting key') } + if (!helpRequested && isProviderAdd && (!providerName || !providerApiType || !providerBaseUrl)) { + throw new CliUsageError('deepchat provider add requires --name, --api-type, and --base-url') + } + if ( + !helpRequested && + isProviderUpdate && + (!providerId || + (providerName === undefined && + providerApiType === undefined && + providerBaseUrl === undefined && + providerEnabled === undefined)) + ) { + throw new CliUsageError('deepchat provider update requires --provider and at least one update') + } + if ( + !helpRequested && + (isProviderTest || + isProviderSetCredential || + isProviderClearCredential || + isProviderRemove || + isModelList) && + !providerId + ) { + throw new CliUsageError(`deepchat ${domain} ${verb} requires --provider`) + } + if (!helpRequested && isProviderSetCredential && !readStdin) { + throw new CliUsageError('deepchat provider set-credential requires --stdin') + } + if ( + !helpRequested && + (isModelConfigGet || isModelStatus || isModelConfigSet || isModelConfigReset) && + (!providerId || !modelId) + ) { + throw new CliUsageError(`deepchat model ${verb} requires --provider and --model`) + } + if (!helpRequested && isModelConfigSet && !readStdin) { + throw new CliUsageError('deepchat model config-set requires --stdin') + } let params: JsonValue = artifactId ? { id: artifactId } : {} if (isProviderList) params = { enabledOnly } + if (isProviderTest && providerId) { + params = { providerId, ...(modelId ? { modelId } : {}) } + } + if (isProviderAdd && providerName && providerApiType && providerBaseUrl) { + params = { + name: providerName, + apiType: providerApiType, + baseUrl: providerBaseUrl, + ...(providerEnabled !== undefined ? { enabled: providerEnabled } : {}) + } + } + if (isProviderUpdate && providerId) { + params = { + providerId, + updates: { + ...(providerName !== undefined ? { name: providerName } : {}), + ...(providerApiType !== undefined ? { apiType: providerApiType } : {}), + ...(providerBaseUrl !== undefined ? { baseUrl: providerBaseUrl } : {}), + ...(providerEnabled !== undefined ? { enabled: providerEnabled } : {}) + } + } + } + if (isProviderSetCredential && providerId) { + params = { providerId, action: 'set', kind: 'api-key' } + } + if (isProviderClearCredential && providerId) { + params = { providerId, action: 'clear', kind: 'api-key' } + } + if (isProviderRemove && providerId) params = { providerId } + if (isModelList && providerId) params = { providerId } + if (isModelConfigGet && providerId && modelId) params = { providerId, modelId } + if (isModelStatus && providerId && modelId) { + params = { providerId, modelId, enabled: commandKey === 'model enable' } + } + if (isModelConfigSet && providerId && modelId) params = { providerId, modelId } + if (isModelConfigReset && providerId && modelId) params = { providerId, modelId } if (isSettingsGet) { params = parsedSettingKeys ? { keys: parsedSettingKeys } : {} } @@ -729,12 +870,24 @@ export function formatCliHelp(command?: Pick|--artifact )' : command.domain === 'provider' - ? ' [--enabled-only]' - : command.domain === 'settings' && command.verb === 'get' - ? ' [--keys ]' - : command.domain === 'settings' - ? ' --key --value ' - : '' + ? command.verb === 'list' + ? ' [--enabled-only]' + : command.verb === 'add' + ? ' --name --api-type --base-url [--enabled ]' + : command.verb === 'update' + ? ' --provider [--name ] [--api-type ] [--base-url ] [--enabled ]' + : command.verb === 'set-credential' + ? ' --provider --stdin' + : ` --provider ${command.verb === 'test' ? ' [--model ]' : ''}` + : command.domain === 'model' && command.verb !== 'invoke' + ? command.verb === 'list' + ? ' --provider ' + : ` --provider --model ${command.verb === 'config-set' ? ' --stdin' : ''}` + : command.domain === 'settings' && command.verb === 'get' + ? ' [--keys ]' + : command.domain === 'settings' + ? ' --key --value ' + : '' const commandKey = `${command.domain} ${command.verb}` const optionLines = commandKey === 'model invoke' @@ -807,6 +960,18 @@ export function formatCliHelp(command?: Pick [ - `${provider.id} ${provider.enabled ? 'enabled' : 'disabled'} ${provider.name}`, + `${provider.id} ${provider.enabled ? 'enabled' : 'disabled'} ${provider.storedCredentialConfigured ? 'credential-stored' : 'no-stored-credential'} ${provider.name}`, ...provider.models.map( (model) => ` ${model.id} ${model.enabled ? 'enabled' : 'disabled'} ${model.type ?? 'chat'}` @@ -86,6 +86,45 @@ export function formatHumanResult( ]) .join('\n') } + case 'providers.testPublicConnection': { + const result = contract.output.parse(value) + return result.isOk + ? 'Provider connection succeeded' + : `Provider connection failed: ${result.errorMsg}` + } + case 'providers.addPublic': + case 'providers.updatePublic': { + const result = contract.output.parse(value) + return `${result.provider.id} ${result.provider.enabled ? 'enabled' : 'disabled'} ${result.provider.name}` + } + case 'providers.setCredential': { + const result = contract.output.parse(value) + return result.action === 'set' + ? `Stored ${result.kind} credential for ${result.providerId}` + : `Cleared ${result.kind} credential for ${result.providerId}` + } + case 'providers.remove': { + const result = contract.output.parse(value) + return result.removed ? 'Provider removed' : 'Provider was not found' + } + case 'models.listRuntime': { + const result = contract.output.parse(value) + return result.models + .map((model) => `${model.id} ${model.enabled ? 'enabled' : 'disabled'} ${model.name}`) + .join('\n') + } + case 'models.getPublicConfig': + case 'models.setPublicConfig': { + return JSON.stringify(contract.output.parse(value).config, null, 2) + } + case 'models.setStatus': { + const result = contract.output.parse(value) + return `${result.modelId} ${result.enabled ? 'enabled' : 'disabled'}` + } + case 'models.resetConfig': { + contract.output.parse(value) + return 'Model configuration reset' + } case 'settings.getPublic': { const result = contract.output.parse(value) return Object.entries(result.values) diff --git a/src/cli/run.ts b/src/cli/run.ts index ab3b93bf6..d909e5905 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto' +import type { JsonValue } from '@shared/contracts/json' import { LOCAL_CONTROL_AGENT_TOKEN_ENV, createLocalControlFailure, @@ -8,6 +9,7 @@ import { import { artifactsDescribeRoute } from '@shared/contracts/routes/artifacts.routes' import { MediaGenerationEventSchema } from '@shared/contracts/routes/media.routes' import { ModelInvokeEventSchema } from '@shared/contracts/routes/models.routes' +import { PROVIDER_CREDENTIAL_MAX_BYTES } from '@shared/contracts/routes/providers.routes' import { parseCliArguments, formatCliHelp, inferCliOutputMode, type CliOutputMode } from './args' import { loadLocalControlDescriptor, @@ -171,7 +173,13 @@ export async function runCli( try { let params = parsed.params if (parsed.readStdin) { - const input = await readBoundedUtf8Stdin(stdin, controller.signal) + const input = await readBoundedUtf8Stdin( + stdin, + controller.signal, + parsed.contract.name === 'providers.setCredential' + ? PROVIDER_CREDENTIAL_MAX_BYTES + : undefined + ) if (!params || typeof params !== 'object' || Array.isArray(params)) { throw new CliClientError( 'internal_error', @@ -192,6 +200,30 @@ export async function runCli( case 'speech.generate': params = { ...params, text: input } break + case 'providers.setCredential': + params = { ...params, value: input.replace(/(?:\r\n|\n)$/, '') } + break + case 'models.setPublicConfig': { + let config: unknown + try { + config = JSON.parse(input) as unknown + } catch { + throw new CliClientError( + 'invalid_request', + 'Model configuration stdin must be valid JSON', + CLI_EXIT_CODES.usage + ) + } + if (!config || typeof config !== 'object' || Array.isArray(config)) { + throw new CliClientError( + 'invalid_request', + 'Model configuration stdin must be a JSON object', + CLI_EXIT_CODES.usage + ) + } + params = { ...params, config: config as JsonValue } + break + } default: throw new CliClientError( 'internal_error', diff --git a/src/main/app/composition.ts b/src/main/app/composition.ts index 4f04ca15a..9cdf60620 100644 --- a/src/main/app/composition.ts +++ b/src/main/app/composition.ts @@ -224,6 +224,7 @@ import { CliServer, createArtifactRoutes, createCliComputeRoutes, + createCliProviderModelAdminRoutes, createCliRoutes } from '@/cli' import { AcpRegistryMigrationService } from '@/agent/acp/catalog/acpRegistryMigrationService' @@ -2130,6 +2131,7 @@ export async function createMainProcessControl(dependencies: { } function registerRoutes(): void { + const providerQueryScheduler = createNodeScheduler() const providerRoutes = createProviderRoutes({ providerSettings, providerRuntime, @@ -2141,7 +2143,7 @@ export async function createMainProcessControl(dependencies: { updateProvidersBatch: (batchUpdate) => providerRuntime.updateProvidersBatch(batchUpdate) }), oauthService, - scheduler: createNodeScheduler(), + scheduler: providerQueryScheduler, recordSettingsActivity: (input) => settingsDatabase.recordSettingsActivity(input) }) const toolRoutes = createToolRoutes(toolService) @@ -2383,6 +2385,16 @@ export async function createMainProcessControl(dependencies: { }) const artifactRoutes = createArtifactRoutes(artifactSpool) const cliComputeRoutes = createCliComputeRoutes(cliComputeService) + const cliProviderModelAdminRoutes = createCliProviderModelAdminRoutes({ + providerSettings, + providerRuntime, + scheduler: providerQueryScheduler, + recordSettingsActivity: (input) => { + void settingsDatabase.recordSettingsActivity(input).catch((error) => { + console.warn('[SettingsActivity] Failed to record CLI provider activity:', error) + }) + } + }) routeDispatcher = createRouteDispatcher({ appDatabaseMaintenance: { assertRouteAllowed: (routeName) => assertRouteAllowedDuringDatabaseMaintenance(routeName) @@ -2420,7 +2432,8 @@ export async function createMainProcessControl(dependencies: { approvalRoutes, cliRoutes, artifactRoutes, - cliComputeRoutes + cliComputeRoutes, + cliProviderModelAdminRoutes ], settingsWindow: windowPresenter, startupWorkloadCoordinator diff --git a/src/main/cli/computeService.ts b/src/main/cli/computeService.ts index 8573eaa4c..5655aaa35 100644 --- a/src/main/cli/computeService.ts +++ b/src/main/cli/computeService.ts @@ -38,6 +38,7 @@ import { import { CliRequestError } from './errors' import { resolveGeneratedMedia, type GeneratedMediaKind } from './mediaOutput' import { artifactExtensionForMimeType, type ArtifactSpool } from './artifactSpool' +import { toPublicProviderSummary } from './providerModelAdminRoutes' const MAX_STREAM_DELTA_CHARACTERS = 1024 * 1024 const MAX_MODEL_STREAM_EVENTS = 10_000 @@ -172,11 +173,7 @@ export class CliComputeService { .filter((model) => !enabledOnly || model.enabled) return PublicProviderSchema.parse({ - id: provider.id, - name: provider.name || provider.id, - apiType: provider.apiType, - enabled: provider.enable, - custom: provider.custom === true, + ...toPublicProviderSummary(provider), models }) }) diff --git a/src/main/cli/index.ts b/src/main/cli/index.ts index 5546ceefc..a4825ed50 100644 --- a/src/main/cli/index.ts +++ b/src/main/cli/index.ts @@ -9,6 +9,10 @@ export { } from './audioTranscriptionService' export { CliOcrService, type CliOcrServiceOptions } from './ocrService' export { createCliRoutes, type CliRuntimeStatus } from './routes' +export { + createCliProviderModelAdminRoutes, + type CliProviderModelAdminDependencies +} from './providerModelAdminRoutes' export { CLI_SURFACE_V1, getCliSurfaceEntry, listCliSurfaceCapabilities } from './surface' export { CliMutationGuard, diff --git a/src/main/cli/providerModelAdminRoutes.ts b/src/main/cli/providerModelAdminRoutes.ts new file mode 100644 index 000000000..af8913b35 --- /dev/null +++ b/src/main/cli/providerModelAdminRoutes.ts @@ -0,0 +1,260 @@ +import { randomUUID } from 'node:crypto' +import { + PublicModelConfigSchema, + PublicProviderSummarySchema, + modelsGetPublicConfigRoute, + modelsSetPublicConfigRoute, + providersAddPublicRoute, + providersSetCredentialRoute, + providersTestPublicConnectionRoute, + providersUpdatePublicRoute, + type PublicProviderSummary, + type SettingsActivityInput +} from '@shared/contracts/routes' +import type { LLM_PROVIDER } from '@shared/types/provider' +import type { ProviderRuntime } from '@/provider' +import type { ProviderQueryScheduler } from '@/provider/providerService' +import type { ProviderSettingsPort } from '@/provider/settings' +import { createRouteMap, type DeepchatRouteMap, type RouteCaller } from '@/routes/routeRegistry' +import { CliRequestError } from './errors' + +type PublicProviderSettings = Pick< + ProviderSettingsPort, + 'getProviderById' | 'getModelConfig' | 'isKnownModel' | 'setModelConfig' +> +type PublicProviderRuntime = Pick< + ProviderRuntime, + 'addProviderAtomic' | 'check' | 'updateProviderAtomic' +> + +const PUBLIC_PROVIDER_TEST_TIMEOUT_MS = 5_000 + +type ExtendedProviderCredentialState = LLM_PROVIDER & { + credential?: { accessKeyId?: string; secretAccessKey?: string; profile?: string } + accountPrivateKey?: string +} + +export type CliProviderModelAdminDependencies = Readonly<{ + providerSettings: PublicProviderSettings + providerRuntime: PublicProviderRuntime + scheduler: ProviderQueryScheduler + recordSettingsActivity?(input: SettingsActivityInput): void + createProviderId?: () => string +}> + +function requireCliCaller(caller: RouteCaller): void { + if (caller.kind !== 'cli') { + throw new CliRequestError('permission_denied', 'Public provider routes require a CLI caller', { + httpStatus: 403 + }) + } +} + +function hasStoredCredential(provider: LLM_PROVIDER): boolean { + const candidate = provider as ExtendedProviderCredentialState + return Boolean( + provider.apiKey?.trim() || + provider.oauthToken?.trim() || + candidate.credential?.accessKeyId?.trim() || + candidate.credential?.secretAccessKey?.trim() || + candidate.credential?.profile?.trim() || + candidate.accountPrivateKey?.trim() + ) +} + +export function toPublicProviderSummary(provider: LLM_PROVIDER): PublicProviderSummary { + return PublicProviderSummarySchema.parse({ + id: provider.id, + name: provider.name || provider.id, + apiType: provider.apiType, + enabled: provider.enable, + custom: provider.custom === true, + storedCredentialConfigured: hasStoredCredential(provider) + }) +} + +export function createCliProviderModelAdminRoutes( + dependencies: CliProviderModelAdminDependencies +): DeepchatRouteMap { + const createProviderId = dependencies.createProviderId ?? randomUUID + const requireProvider = (providerId: string): LLM_PROVIDER => { + const provider = dependencies.providerSettings.getProviderById(providerId) + if (!provider) { + throw new CliRequestError('not_found', 'Provider was not found', { httpStatus: 404 }) + } + return provider + } + const requireModel = (providerId: string, modelId: string): void => { + requireProvider(providerId) + if (!dependencies.providerSettings.isKnownModel(providerId, modelId)) { + throw new CliRequestError('not_found', 'Model was not found', { httpStatus: 404 }) + } + } + const recordActivity = (input: SettingsActivityInput): void => { + dependencies.recordSettingsActivity?.(input) + } + + return createRouteMap([ + [ + providersTestPublicConnectionRoute.name, + async (rawInput, context) => { + requireCliCaller(context.caller) + const input = providersTestPublicConnectionRoute.input.parse(rawInput) + requireProvider(input.providerId) + let isOk = false + try { + const result = await dependencies.scheduler.timeout({ + task: dependencies.providerRuntime.check(input.providerId, input.modelId), + ms: PUBLIC_PROVIDER_TEST_TIMEOUT_MS, + reason: `providers.testPublicConnection:${input.providerId}` + }) + isOk = result.isOk + } catch { + isOk = false + } + return providersTestPublicConnectionRoute.output.parse({ + isOk, + errorMsg: isOk ? null : 'Provider connection failed' + }) + } + ], + [ + providersAddPublicRoute.name, + async (rawInput, context) => { + requireCliCaller(context.caller) + const input = providersAddPublicRoute.input.parse(rawInput) + const providerId = createProviderId() + if (dependencies.providerSettings.getProviderById(providerId)) { + throw new CliRequestError('conflict', 'Generated provider ID is already in use', { + httpStatus: 409 + }) + } + const provider: LLM_PROVIDER = { + id: providerId, + name: input.name, + apiType: input.apiType, + apiKey: '', + baseUrl: input.baseUrl, + enable: input.enabled, + custom: true + } + dependencies.providerRuntime.addProviderAtomic(provider) + const stored = requireProvider(providerId) + recordActivity({ + category: 'provider', + action: 'created', + targetType: 'provider', + targetId: providerId, + targetLabel: stored.name, + routeName: 'settings-provider', + routeParams: { providerId }, + summaryKey: 'settings.controlCenter.activity.providerCreated', + summaryParams: { name: stored.name } + }) + return providersAddPublicRoute.output.parse({ + provider: toPublicProviderSummary(stored) + }) + } + ], + [ + providersUpdatePublicRoute.name, + async (rawInput, context) => { + requireCliCaller(context.caller) + const input = providersUpdatePublicRoute.input.parse(rawInput) + const current = requireProvider(input.providerId) + if (input.updates.apiType !== undefined && current.custom !== true) { + throw new CliRequestError('conflict', 'Built-in provider API type cannot be changed', { + httpStatus: 409 + }) + } + const updates: Partial = { + ...(input.updates.name !== undefined ? { name: input.updates.name } : {}), + ...(input.updates.apiType !== undefined ? { apiType: input.updates.apiType } : {}), + ...(input.updates.baseUrl !== undefined ? { baseUrl: input.updates.baseUrl } : {}), + ...(input.updates.enabled !== undefined ? { enable: input.updates.enabled } : {}) + } + const requiresRebuild = dependencies.providerRuntime.updateProviderAtomic( + input.providerId, + updates + ) + const stored = requireProvider(input.providerId) + const action = + input.updates.enabled === undefined + ? 'updated' + : input.updates.enabled + ? 'enabled' + : 'disabled' + recordActivity({ + category: 'provider', + action, + targetType: 'provider', + targetId: input.providerId, + targetLabel: stored.name, + routeName: 'settings-provider', + routeParams: { providerId: input.providerId }, + summaryKey: 'settings.controlCenter.activity.providerUpdated', + summaryParams: { name: stored.name } + }) + return providersUpdatePublicRoute.output.parse({ + provider: toPublicProviderSummary(stored), + requiresRebuild + }) + } + ], + [ + providersSetCredentialRoute.name, + async (rawInput, context) => { + requireCliCaller(context.caller) + const input = providersSetCredentialRoute.input.parse(rawInput) + const current = requireProvider(input.providerId) + dependencies.providerRuntime.updateProviderAtomic(input.providerId, { + apiKey: input.action === 'set' ? input.value : '' + }) + const stored = requireProvider(input.providerId) + recordActivity({ + category: 'provider', + action: 'updated', + targetType: 'provider', + targetId: input.providerId, + targetLabel: current.name, + routeName: 'settings-provider', + routeParams: { providerId: input.providerId }, + summaryKey: 'settings.controlCenter.activity.providerUpdated', + summaryParams: { name: current.name } + }) + return providersSetCredentialRoute.output.parse({ + providerId: input.providerId, + action: input.action, + kind: input.kind, + storedApiKeyConfigured: Boolean(stored.apiKey?.trim()) + }) + } + ], + [ + modelsGetPublicConfigRoute.name, + async (rawInput, context) => { + requireCliCaller(context.caller) + const input = modelsGetPublicConfigRoute.input.parse(rawInput) + requireModel(input.providerId, input.modelId) + return modelsGetPublicConfigRoute.output.parse({ + config: PublicModelConfigSchema.parse( + dependencies.providerSettings.getModelConfig(input.modelId, input.providerId) + ) + }) + } + ], + [ + modelsSetPublicConfigRoute.name, + async (rawInput, context) => { + requireCliCaller(context.caller) + const input = modelsSetPublicConfigRoute.input.parse(rawInput) + requireModel(input.providerId, input.modelId) + dependencies.providerSettings.setModelConfig(input.modelId, input.providerId, input.config) + const config = PublicModelConfigSchema.parse( + dependencies.providerSettings.getModelConfig(input.modelId, input.providerId) + ) + return modelsSetPublicConfigRoute.output.parse({ config }) + } + ] + ]) +} diff --git a/src/main/cli/surface.ts b/src/main/cli/surface.ts index a135b1cb6..a4637ce2b 100644 --- a/src/main/cli/surface.ts +++ b/src/main/cli/surface.ts @@ -1,5 +1,5 @@ import type { RouteContract } from '@shared/contracts/contract' -import type { JsonValue } from '@shared/contracts/json' +import { JsonValueSchema, type JsonValue } from '@shared/contracts/json' import { AUDIO_TRANSCRIPTION_MAX_INPUT_BYTES, OCR_EXTRACTION_MAX_INPUT_BYTES, @@ -13,12 +13,22 @@ import { cliStatusRoute, cliVersionRoute, imagesGenerateRoute, + modelsGetPublicConfigRoute, modelsInvokeRoute, + modelsListRuntimeRoute, + modelsResetConfigRoute, + modelsSetPublicConfigRoute, + modelsSetStatusRoute, ocrClearCacheRoute, ocrExtractArtifactRoute, ocrExtractUploadRoute, ocrGetRuntimeStatusRoute, + providersAddPublicRoute, providersListPublicRoute, + providersRemoveRoute, + providersSetCredentialRoute, + providersTestPublicConnectionRoute, + providersUpdatePublicRoute, speechGenerateRoute, settingsGetPublicRoute, settingsUpdatePublicRoute, @@ -118,6 +128,25 @@ function stringArrayField(input: unknown, field: string): string[] { : [] } +function objectFieldKeys(input: unknown, field: string): string[] { + if (!input || typeof input !== 'object' || Array.isArray(input)) return [] + const value = (input as Record)[field] + return value && typeof value === 'object' && !Array.isArray(value) + ? Object.keys(value).sort() + : [] +} + +function jsonObjectField(input: unknown, field: string): Record { + if (!input || typeof input !== 'object' || Array.isArray(input)) return {} + const parsed = JsonValueSchema.safeParse((input as Record)[field]) + return parsed.success && + parsed.data && + typeof parsed.data === 'object' && + !Array.isArray(parsed.data) + ? parsed.data + : {} +} + export function listCliSurfaceEffects(entry: CliSurfaceEntry): readonly LocalControlEffect[] { return typeof entry.effect === 'string' ? [entry.effect] : entry.effect.possible } @@ -301,6 +330,125 @@ const CLI_SURFACE_V1_ENTRIES = [ approval: 'never', limits: DIAGNOSTIC_LIMITS }, + { + contract: providersTestPublicConnectionRoute, + effect: 'compute', + callers: ['human'], + scopes: ['providers:read'], + transport: 'rpc', + approval: 'never', + auditProjection: (input) => selectAuditFields(input, ['providerId', 'modelId']), + limits: { maxBodyBytes: 16 * 1024, timeoutMs: LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS } + }, + { + contract: providersAddPublicRoute, + effect: 'execution-config', + callers: ['human'], + scopes: ['providers:write'], + transport: 'rpc', + approval: 'policy', + auditProjection: (input) => selectAuditFields(input, ['name', 'apiType', 'enabled']), + approvalDisplay: (input) => selectAuditFields(input, ['name', 'apiType', 'baseUrl', 'enabled']), + limits: { maxBodyBytes: 16 * 1024, timeoutMs: 30_000 } + }, + { + contract: providersUpdatePublicRoute, + effect: 'execution-config', + callers: ['human'], + scopes: ['providers:write'], + transport: 'rpc', + approval: 'policy', + auditProjection: (input) => ({ + ...selectAuditFields(input, ['providerId']), + fields: objectFieldKeys(input, 'updates') + }), + approvalDisplay: (input) => ({ + ...selectAuditFields(input, ['providerId']), + updates: jsonObjectField(input, 'updates') + }), + limits: { maxBodyBytes: 16 * 1024, timeoutMs: 30_000 } + }, + { + contract: providersSetCredentialRoute, + effect: 'credential', + callers: ['human'], + scopes: ['providers:credential'], + transport: 'rpc', + approval: 'policy', + auditProjection: (input) => selectAuditFields(input, ['providerId', 'action', 'kind']), + approvalDisplay: (input) => selectAuditFields(input, ['providerId', 'action', 'kind']), + limits: { maxBodyBytes: 128 * 1024, timeoutMs: 30_000 } + }, + { + contract: providersRemoveRoute, + effect: 'destructive', + callers: ['human'], + scopes: ['providers:write'], + transport: 'rpc', + approval: 'policy', + auditProjection: (input) => selectAuditFields(input, ['providerId']), + approvalDisplay: (input) => selectAuditFields(input, ['providerId']), + limits: DIAGNOSTIC_LIMITS + }, + { + contract: modelsListRuntimeRoute, + effect: 'read', + callers: ['human', 'agent'], + scopes: ['models:read'], + transport: 'rpc', + approval: 'never', + auditProjection: (input) => selectAuditFields(input, ['providerId']), + limits: { maxBodyBytes: 16 * 1024, timeoutMs: LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS } + }, + { + contract: modelsGetPublicConfigRoute, + effect: 'read', + callers: ['human', 'agent'], + scopes: ['models:read'], + transport: 'rpc', + approval: 'never', + auditProjection: (input) => selectAuditFields(input, ['providerId', 'modelId']), + limits: DIAGNOSTIC_LIMITS + }, + { + contract: modelsSetStatusRoute, + effect: 'execution-config', + callers: ['human'], + scopes: ['providers:write'], + transport: 'rpc', + approval: 'policy', + auditProjection: (input) => selectAuditFields(input, ['providerId', 'modelId', 'enabled']), + approvalDisplay: (input) => selectAuditFields(input, ['providerId', 'modelId', 'enabled']), + limits: DIAGNOSTIC_LIMITS + }, + { + contract: modelsSetPublicConfigRoute, + effect: 'execution-config', + callers: ['human'], + scopes: ['providers:write'], + transport: 'rpc', + approval: 'policy', + auditProjection: (input) => ({ + ...selectAuditFields(input, ['providerId', 'modelId']), + fields: objectFieldKeys(input, 'config') + }), + approvalDisplay: (input) => ({ + ...selectAuditFields(input, ['providerId', 'modelId']), + config: jsonObjectField(input, 'config') + }), + limits: { maxBodyBytes: 64 * 1024, timeoutMs: 30_000 } + }, + { + contract: modelsResetConfigRoute, + effect: 'execution-config', + callers: ['human'], + scopes: ['providers:write'], + transport: 'rpc', + approval: 'policy', + auditProjection: (input) => selectAuditFields(input, ['providerId', 'modelId']), + approvalDisplay: (input) => selectAuditFields(input, ['providerId', 'modelId']), + limits: DIAGNOSTIC_LIMITS + }, { contract: settingsGetPublicRoute, effect: 'read', diff --git a/src/main/provider/routes.ts b/src/main/provider/routes.ts index cafdf675a..7a437277d 100644 --- a/src/main/provider/routes.ts +++ b/src/main/provider/routes.ts @@ -72,6 +72,7 @@ import { import type { ProviderImportService } from './providerImportService' import { ProviderService, type ProviderQueryScheduler } from './providerService' import type { ProviderRuntime } from '.' +import { CliRequestError } from '@/cli/errors' export function createProviderRoutes(deps: { providerSettings: ProviderSettingsPort @@ -295,8 +296,19 @@ export function createProviderRoutes(deps: { ], [ providersRemoveRoute.name, - async (rawInput) => { + async (rawInput, context) => { const input = providersRemoveRoute.input.parse(rawInput) + if (context.caller.kind === 'cli') { + const provider = providerSettings.getProviderById(input.providerId) + if (!provider) { + throw new CliRequestError('not_found', 'Provider was not found', { httpStatus: 404 }) + } + if (provider.custom !== true) { + throw new CliRequestError('conflict', 'Built-in providers cannot be removed', { + httpStatus: 409 + }) + } + } providerRuntime.removeProviderAtomic(input.providerId) const result = providersRemoveRoute.output.parse({ removed: true }) recordActivity({ @@ -534,8 +546,14 @@ export function createProviderRoutes(deps: { ], [ modelsSetStatusRoute.name, - async (rawInput) => { + async (rawInput, context) => { const input = modelsSetStatusRoute.input.parse(rawInput) + if ( + context.caller.kind === 'cli' && + !providerSettings.isKnownModel(input.providerId, input.modelId) + ) { + throw new CliRequestError('not_found', 'Model was not found', { httpStatus: 404 }) + } await providerRuntime.updateModelStatus(input.providerId, input.modelId, input.enabled) const result = modelsSetStatusRoute.output.parse(input) recordActivity({ diff --git a/src/shared/contracts/routes.ts b/src/shared/contracts/routes.ts index 00caa657d..d21a09449 100644 --- a/src/shared/contracts/routes.ts +++ b/src/shared/contracts/routes.ts @@ -271,6 +271,7 @@ import { modelsExportConfigsRoute, modelsGetCapabilitiesRoute, modelsGetConfigRoute, + modelsGetPublicConfigRoute, modelsGetProviderCatalogRoute, modelsGetProviderConfigsRoute, modelsHasUserConfigRoute, @@ -281,6 +282,7 @@ import { modelsResetConfigRoute, modelsSetBatchStatusRoute, modelsSetConfigRoute, + modelsSetPublicConfigRoute, modelsSetStatusRoute, modelsTranscribeAudioRoute, modelsUpdateCustomRoute @@ -360,6 +362,7 @@ import { cronJobsUpsertRoute } from './routes/cronJobs.routes' import { + providersAddPublicRoute, providersAddRoute, providersGetAcpProcessConfigOptionsRoute, providersGetEmbeddingDimensionsRoute, @@ -379,10 +382,13 @@ import { providersRemoveRoute, providersReorderRoute, providersRunAcpDebugActionRoute, + providersSetCredentialRoute, providersSetByIdRoute, providersSyncModelScopeMcpServersRoute, providersTestConnectionRoute, + providersTestPublicConnectionRoute, providersUpdateRateLimitRoute, + providersUpdatePublicRoute, providersUpdateRoute, providersWarmupAcpProcessRoute } from './routes/providers.routes' @@ -928,6 +934,9 @@ const DEEPCHAT_ROUTE_CATALOG_PART_4 = { [providersListRoute.name]: providersListRoute, [providersListSummariesRoute.name]: providersListSummariesRoute, [providersListPublicRoute.name]: providersListPublicRoute, + [providersAddPublicRoute.name]: providersAddPublicRoute, + [providersUpdatePublicRoute.name]: providersUpdatePublicRoute, + [providersSetCredentialRoute.name]: providersSetCredentialRoute, [providersListDefaultsRoute.name]: providersListDefaultsRoute, [providersSetByIdRoute.name]: providersSetByIdRoute, [providersUpdateRoute.name]: providersUpdateRoute, @@ -936,6 +945,7 @@ const DEEPCHAT_ROUTE_CATALOG_PART_4 = { [providersReorderRoute.name]: providersReorderRoute, [providersListModelsRoute.name]: providersListModelsRoute, [providersTestConnectionRoute.name]: providersTestConnectionRoute, + [providersTestPublicConnectionRoute.name]: providersTestPublicConnectionRoute, [providersGetRateLimitStatusRoute.name]: providersGetRateLimitStatusRoute, [providersGetKeyStatusRoute.name]: providersGetKeyStatusRoute, [providersUpdateRateLimitRoute.name]: providersUpdateRateLimitRoute, @@ -964,7 +974,9 @@ const DEEPCHAT_ROUTE_CATALOG_PART_4 = { [modelsRemoveCustomRoute.name]: modelsRemoveCustomRoute, [modelsUpdateCustomRoute.name]: modelsUpdateCustomRoute, [modelsGetConfigRoute.name]: modelsGetConfigRoute, + [modelsGetPublicConfigRoute.name]: modelsGetPublicConfigRoute, [modelsSetConfigRoute.name]: modelsSetConfigRoute, + [modelsSetPublicConfigRoute.name]: modelsSetPublicConfigRoute, [modelsResetConfigRoute.name]: modelsResetConfigRoute, [modelsGetProviderConfigsRoute.name]: modelsGetProviderConfigsRoute, [modelsHasUserConfigRoute.name]: modelsHasUserConfigRoute, diff --git a/src/shared/contracts/routes/models.routes.ts b/src/shared/contracts/routes/models.routes.ts index b04d1c459..94379fdef 100644 --- a/src/shared/contracts/routes/models.routes.ts +++ b/src/shared/contracts/routes/models.routes.ts @@ -193,6 +193,42 @@ export const modelsGetConfigRoute = defineRouteContract({ }) }) +export const PublicModelConfigSchema = ModelConfigSchema.omit({ + conversationId: true, + ownedBy: true +}) + .extend({ + maxTokens: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + contextLength: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + maxCompletionTokens: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional() + }) + .strip() + +const PublicModelConfigInputSchema = PublicModelConfigSchema.omit({ isUserDefined: true }).strict() + +export const modelsGetPublicConfigRoute = defineRouteContract({ + name: 'models.getPublicConfig', + input: z + .object({ + modelId: z.string().min(1), + providerId: EntityIdSchema + }) + .strict(), + output: z.object({ config: PublicModelConfigSchema }).strict() +}) + +export const modelsSetPublicConfigRoute = defineRouteContract({ + name: 'models.setPublicConfig', + input: z + .object({ + modelId: z.string().min(1), + providerId: EntityIdSchema, + config: PublicModelConfigInputSchema + }) + .strict(), + output: modelsGetPublicConfigRoute.output +}) + export const modelsSetConfigRoute = defineRouteContract({ name: 'models.setConfig', input: z.object({ diff --git a/src/shared/contracts/routes/providers.routes.ts b/src/shared/contracts/routes/providers.routes.ts index 37cebe937..60d0c9074 100644 --- a/src/shared/contracts/routes/providers.routes.ts +++ b/src/shared/contracts/routes/providers.routes.ts @@ -14,6 +14,34 @@ import { } from '../domainSchemas' import { PROVIDER_IMPORT_CUSTOM_API_TYPES, PROVIDER_IMPORT_SOURCE_IDS } from '../../providerImport' +export const PROVIDER_CREDENTIAL_MAX_BYTES = 64 * 1024 + +const StoredProviderCredentialSchema = z + .string() + .min(1) + .max(PROVIDER_CREDENTIAL_MAX_BYTES) + .refine((value) => value.trim().length > 0, { message: 'Credential must not be blank' }) + .refine((value) => new TextEncoder().encode(value).byteLength <= PROVIDER_CREDENTIAL_MAX_BYTES, { + message: 'Credential exceeds its UTF-8 byte limit' + }) + +const PublicProviderApiTypeSchema = z.enum(PROVIDER_IMPORT_CUSTOM_API_TYPES) +const PublicProviderBaseUrlSchema = z + .url() + .max(4096) + .superRefine((value, context) => { + const url = new URL(value) + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + context.addIssue({ code: 'custom', message: 'Provider URL must use HTTP or HTTPS' }) + } + if (url.username || url.password || url.search || url.hash) { + context.addIssue({ + code: 'custom', + message: 'Provider URL must not contain credentials, query parameters, or a fragment' + }) + } + }) + export const PublicProviderModelSchema = z .object({ id: z.string().min(1).max(256), @@ -38,6 +66,7 @@ export const PublicProviderSchema = z apiType: z.string().min(1).max(128), enabled: z.boolean(), custom: z.boolean(), + storedCredentialConfigured: z.boolean(), models: z.array(PublicProviderModelSchema).max(10_000) }) .strict() @@ -48,8 +77,79 @@ export const providersListPublicRoute = defineRouteContract({ output: z.object({ providers: z.array(PublicProviderSchema).max(1_000) }).strict() }) +export const PublicProviderSummarySchema = PublicProviderSchema.omit({ models: true }) + +export const providersAddPublicRoute = defineRouteContract({ + name: 'providers.addPublic', + input: z + .object({ + name: z.string().trim().min(1).max(256), + apiType: PublicProviderApiTypeSchema, + baseUrl: PublicProviderBaseUrlSchema, + enabled: z.boolean().optional().default(true) + }) + .strict(), + output: z.object({ provider: PublicProviderSummarySchema }).strict() +}) + +export const providersUpdatePublicRoute = defineRouteContract({ + name: 'providers.updatePublic', + input: z + .object({ + providerId: EntityIdSchema.max(128), + updates: z + .object({ + name: z.string().trim().min(1).max(256).optional(), + apiType: PublicProviderApiTypeSchema.optional(), + baseUrl: PublicProviderBaseUrlSchema.optional(), + enabled: z.boolean().optional() + }) + .strict() + .refine((updates) => Object.keys(updates).length > 0, { + message: 'At least one provider update is required' + }) + }) + .strict(), + output: z + .object({ + provider: PublicProviderSummarySchema, + requiresRebuild: z.boolean() + }) + .strict() +}) + +export const providersSetCredentialRoute = defineRouteContract({ + name: 'providers.setCredential', + input: z.discriminatedUnion('action', [ + z + .object({ + providerId: EntityIdSchema.max(128), + action: z.literal('set'), + kind: z.literal('api-key'), + value: StoredProviderCredentialSchema + }) + .strict(), + z + .object({ + providerId: EntityIdSchema.max(128), + action: z.literal('clear'), + kind: z.literal('api-key') + }) + .strict() + ]), + output: z + .object({ + providerId: EntityIdSchema.max(128), + action: z.enum(['set', 'clear']), + kind: z.literal('api-key'), + storedApiKeyConfigured: z.boolean() + }) + .strict() +}) + export type PublicProvider = z.infer export type PublicProviderModel = z.infer +export type PublicProviderSummary = z.infer const ProviderImportSourceIdSchema = z.enum(PROVIDER_IMPORT_SOURCE_IDS) const ProviderImportCustomApiTypeSchema = z.enum(PROVIDER_IMPORT_CUSTOM_API_TYPES) @@ -86,6 +186,12 @@ export const providersTestConnectionRoute = defineRouteContract({ }) }) +export const providersTestPublicConnectionRoute = defineRouteContract({ + name: 'providers.testPublicConnection', + input: providersTestConnectionRoute.input, + output: providersTestConnectionRoute.output +}) + export const providersListRoute = defineRouteContract({ name: 'providers.list', input: z.object({}).default({}), diff --git a/test/main/cli/args.test.ts b/test/main/cli/args.test.ts index 115bc85ea..d4b1e14e3 100644 --- a/test/main/cli/args.test.ts +++ b/test/main/cli/args.test.ts @@ -186,6 +186,78 @@ describe('CLI argument grammar', () => { ) }) + it('parses provider administration without accepting credentials in argv', () => { + expect( + parseCliArguments( + [ + 'provider', + 'add', + '--name', + 'Local API', + '--api-type', + 'openai-completions', + '--base-url', + 'http://localhost:8080/v1', + '--enabled', + 'false' + ], + {} + ) + ).toMatchObject({ + contract: { name: 'providers.addPublic' }, + params: { + name: 'Local API', + apiType: 'openai-completions', + baseUrl: 'http://localhost:8080/v1', + enabled: false + } + }) + expect( + parseCliArguments(['provider', 'set-credential', '--provider', 'provider-1', '--stdin'], {}) + ).toMatchObject({ + contract: { name: 'providers.setCredential' }, + params: { providerId: 'provider-1', action: 'set', kind: 'api-key' }, + readStdin: true + }) + expect(parseCliArguments(['provider', 'test', '--provider', 'provider-1'], {})).toMatchObject({ + contract: { name: 'providers.testPublicConnection' } + }) + expect(() => + parseCliArguments( + ['provider', 'set-credential', '--provider', 'provider-1', '--value', 'secret'], + {} + ) + ).toThrow('--value is not valid') + expect(() => parseCliArguments(['provider', 'update', '--provider', 'provider-1'], {})).toThrow( + 'at least one update' + ) + }) + + it('parses model administration with full config supplied only through stdin', () => { + expect( + parseCliArguments(['model', 'enable', '--provider', 'provider-1', '--model', 'model-1'], {}) + ).toMatchObject({ + contract: { name: 'models.setStatus' }, + params: { providerId: 'provider-1', modelId: 'model-1', enabled: true } + }) + expect( + parseCliArguments( + ['model', 'config-set', '--provider', 'provider-1', '--model', 'model-1', '--stdin'], + {} + ) + ).toMatchObject({ + contract: { name: 'models.setPublicConfig' }, + params: { providerId: 'provider-1', modelId: 'model-1' }, + readStdin: true + }) + expect(() => + parseCliArguments( + ['model', 'config-set', '--provider', 'provider-1', '--model', 'model-1'], + {} + ) + ).toThrow('requires --stdin') + }) + it('maps image and video options without exposing file output paths', () => { expect( parseCliArguments( diff --git a/test/main/cli/client.test.ts b/test/main/cli/client.test.ts index 4263b2cd3..b2f84b9c3 100644 --- a/test/main/cli/client.test.ts +++ b/test/main/cli/client.test.ts @@ -495,4 +495,93 @@ describe('bundled CLI client', () => { expect(stderr.read()).toBe('') expect(invokeUpload).not.toHaveBeenCalled() }) + + it('reads provider credentials from bounded stdin instead of argv', async () => { + const stdout = captureOutput() + const stderr = captureOutput() + const invokeRpc = vi.fn(async (invocation) => + LocalControlRpcResponseSchema.parse({ + protocolVersion: 1, + surfaceVersion: 1, + id: invocation.id, + ok: true, + result: { + providerId: 'provider-1', + action: 'set', + kind: 'api-key', + storedApiKeyConfigured: true + } + }) + ) + + await expect( + runCli(['provider', 'set-credential', '--provider', 'provider-1', '--stdin'], { + env: {}, + stdin: Readable.from([' super-secret \n']), + stdout: stdout.stream, + stderr: stderr.stream, + randomId: () => 'request-1', + loadDescriptor: async () => testDescriptor, + invokeRpc + }) + ).resolves.toBe(0) + + expect(invokeRpc).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'providers.setCredential', + params: { + providerId: 'provider-1', + action: 'set', + kind: 'api-key', + value: ' super-secret ' + } + }) + ) + expect(stdout.read()).toBe('Stored api-key credential for provider-1\n') + expect(stdout.read()).not.toContain('super-secret') + expect(stderr.read()).toBe('') + }) + + it('parses model configuration JSON from stdin before invoking the typed route', async () => { + const stdout = captureOutput() + const stderr = captureOutput() + const config = { + maxTokens: 4096, + contextLength: 32768, + vision: false, + functionCall: true, + reasoning: true, + type: 'chat' + } + const invokeRpc = vi.fn(async (invocation) => + LocalControlRpcResponseSchema.parse({ + protocolVersion: 1, + surfaceVersion: 1, + id: invocation.id, + ok: true, + result: { config } + }) + ) + + await expect( + runCli(['model', 'config-set', '--provider', 'provider-1', '--model', 'model-1', '--stdin'], { + env: {}, + stdin: Readable.from([JSON.stringify(config)]), + stdout: stdout.stream, + stderr: stderr.stream, + randomId: () => 'request-1', + loadDescriptor: async () => testDescriptor, + invokeRpc + }) + ).resolves.toBe(0) + + expect(invokeRpc).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'models.setPublicConfig', + params: { providerId: 'provider-1', modelId: 'model-1', config } + }) + ) + expect(JSON.parse(stdout.read())).toEqual(config) + expect(stderr.read()).toBe('') + }) }) diff --git a/test/main/cli/computeService.test.ts b/test/main/cli/computeService.test.ts index ccdf92523..3ba581692 100644 --- a/test/main/cli/computeService.test.ts +++ b/test/main/cli/computeService.test.ts @@ -172,6 +172,7 @@ describe('CLI compute service', () => { apiType: 'openai-compatible', enabled: true, custom: true, + storedCredentialConfigured: true, models: [ { id: 'model-1', diff --git a/test/main/cli/providerModelAdminRoutes.test.ts b/test/main/cli/providerModelAdminRoutes.test.ts new file mode 100644 index 000000000..45750c23c --- /dev/null +++ b/test/main/cli/providerModelAdminRoutes.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, it, vi } from 'vitest' +import { + modelsGetPublicConfigRoute, + modelsSetPublicConfigRoute, + providersAddPublicRoute, + providersSetCredentialRoute, + providersTestPublicConnectionRoute, + providersUpdatePublicRoute +} from '@shared/contracts/routes' +import type { LLM_PROVIDER, ModelConfig } from '@shared/types/provider' +import { createCliProviderModelAdminRoutes } from '@/cli/providerModelAdminRoutes' +import type { CliRouteCaller, RouteContext } from '@/routes/routeRegistry' + +const caller: CliRouteCaller = { + kind: 'cli', + principal: 'human', + connectionId: 'connection-1', + scopes: ['providers:write', 'providers:credential'] +} + +function createHarness(initialProviders: LLM_PROVIDER[] = []) { + const providers = new Map(initialProviders.map((provider) => [provider.id, provider])) + const defaultModelConfig: ModelConfig = { + maxTokens: 4096, + contextLength: 32768, + vision: false, + functionCall: true, + reasoning: true, + type: 'chat' as ModelConfig['type'] + } + const modelConfigs = new Map() + const addProviderAtomic = vi.fn((provider: LLM_PROVIDER) => providers.set(provider.id, provider)) + const updateProviderAtomic = vi.fn((providerId: string, updates: Partial) => { + const provider = providers.get(providerId) + if (!provider) return false + providers.set(providerId, { ...provider, ...updates }) + return 'apiType' in updates || 'baseUrl' in updates + }) + const check = vi.fn(async () => ({ + isOk: false, + errorMsg: 'Request failed with Authorization: Bearer super-secret' + })) + const recordSettingsActivity = vi.fn() + const routes = createCliProviderModelAdminRoutes({ + providerSettings: { + getProviderById: (providerId) => providers.get(providerId), + getModelConfig: (modelId, providerId) => + modelConfigs.get(`${providerId}:${modelId}`) ?? defaultModelConfig, + isKnownModel: (_providerId, modelId) => modelId === 'model-1', + setModelConfig: (modelId, providerId, config) => { + modelConfigs.set(`${providerId}:${modelId}`, config) + } + }, + providerRuntime: { addProviderAtomic, check, updateProviderAtomic }, + scheduler: { + timeout: async ({ task }: { task: Promise }) => await task + }, + recordSettingsActivity, + createProviderId: () => 'provider-generated' + }) + const invoke = async (method: string, input: unknown, context: RouteContext = { caller }) => { + const route = routes.get(method as never) + if (!route) throw new Error(`Missing route: ${method}`) + return await route(input, context) + } + return { + providers, + addProviderAtomic, + check, + updateProviderAtomic, + recordSettingsActivity, + modelConfigs, + invoke + } +} + +describe('CLI provider administration routes', () => { + it('adds only a credential-free custom provider and returns a redacted summary', async () => { + const harness = createHarness() + + await expect( + harness.invoke(providersAddPublicRoute.name, { + name: 'Private endpoint', + apiType: 'openai-completions', + baseUrl: 'https://models.example/v1', + enabled: true + }) + ).resolves.toEqual({ + provider: { + id: 'provider-generated', + name: 'Private endpoint', + apiType: 'openai-completions', + enabled: true, + custom: true, + storedCredentialConfigured: false + } + }) + expect(harness.addProviderAtomic).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'provider-generated', + apiKey: '', + custom: true + }) + ) + }) + + it('updates only allowlisted fields and protects built-in provider identity', async () => { + const custom: LLM_PROVIDER = { + id: 'custom-1', + name: 'Custom', + apiType: 'openai', + apiKey: 'secret', + baseUrl: 'https://old.example/v1', + enable: true, + custom: true + } + const builtin: LLM_PROVIDER = { ...custom, id: 'builtin-1', custom: false } + const harness = createHarness([custom, builtin]) + + await expect( + harness.invoke(providersUpdatePublicRoute.name, { + providerId: custom.id, + updates: { apiType: 'anthropic', enabled: false } + }) + ).resolves.toMatchObject({ + provider: { + id: custom.id, + apiType: 'anthropic', + enabled: false, + storedCredentialConfigured: true + }, + requiresRebuild: true + }) + expect(harness.providers.get(custom.id)?.apiKey).toBe('secret') + + await expect( + harness.invoke(providersUpdatePublicRoute.name, { + providerId: builtin.id, + updates: { apiType: 'gemini' } + }) + ).rejects.toMatchObject({ code: 'conflict' }) + }) + + it('sets and clears API keys without returning credential material', async () => { + const provider: LLM_PROVIDER = { + id: 'provider-1', + name: 'Provider', + apiType: 'openai', + apiKey: '', + baseUrl: 'https://api.example/v1', + enable: true, + custom: true + } + const harness = createHarness([provider]) + + const setResult = await harness.invoke(providersSetCredentialRoute.name, { + providerId: provider.id, + action: 'set', + kind: 'api-key', + value: 'super-secret ' + }) + expect(setResult).toEqual({ + providerId: provider.id, + action: 'set', + kind: 'api-key', + storedApiKeyConfigured: true + }) + expect(JSON.stringify(setResult)).not.toContain('super-secret') + expect(harness.providers.get(provider.id)?.apiKey).toBe('super-secret ') + + await expect( + harness.invoke(providersSetCredentialRoute.name, { + providerId: provider.id, + action: 'clear', + kind: 'api-key' + }) + ).resolves.toMatchObject({ action: 'clear', storedApiKeyConfigured: false }) + expect(harness.providers.get(provider.id)?.apiKey).toBe('') + }) + + it('redacts provider implementation errors from public connection tests', async () => { + const provider: LLM_PROVIDER = { + id: 'provider-1', + name: 'Provider', + apiType: 'openai', + apiKey: 'super-secret', + baseUrl: 'https://api.example/v1', + enable: true, + custom: true + } + const harness = createHarness([provider]) + + const result = await harness.invoke(providersTestPublicConnectionRoute.name, { + providerId: provider.id + }) + expect(result).toEqual({ isOk: false, errorMsg: 'Provider connection failed' }) + expect(JSON.stringify(result)).not.toContain('super-secret') + }) + + it('rejects renderer callers and URLs that can hide credential material', async () => { + const harness = createHarness() + + await expect( + harness.invoke( + providersAddPublicRoute.name, + { + name: 'Provider', + apiType: 'openai', + baseUrl: 'https://api.example/v1' + }, + { caller: { kind: 'renderer', webContentsId: 1, windowId: 1 } } + ) + ).rejects.toMatchObject({ code: 'permission_denied' }) + expect( + providersAddPublicRoute.input.safeParse({ + name: 'Provider', + apiType: 'openai', + baseUrl: 'https://user:password@api.example/v1?api_key=secret' + }).success + ).toBe(false) + expect( + providersSetCredentialRoute.input.safeParse({ + providerId: 'provider-1', + action: 'set', + kind: 'api-key', + value: '密'.repeat(22_000) + }).success + ).toBe(false) + }) + + it('uses strict public model config input and strips main-owned identity fields', async () => { + const provider: LLM_PROVIDER = { + id: 'provider-1', + name: 'Provider', + apiType: 'openai', + apiKey: '', + baseUrl: 'https://api.example/v1', + enable: true, + custom: true + } + const harness = createHarness([provider]) + const config = { + maxTokens: 2048, + contextLength: 16384, + vision: false, + functionCall: false, + reasoning: false, + type: 'chat' + } + + await expect( + harness.invoke(modelsSetPublicConfigRoute.name, { + providerId: provider.id, + modelId: 'model-1', + config + }) + ).resolves.toEqual({ config }) + expect(harness.modelConfigs.get(`${provider.id}:model-1`)).toEqual(config) + expect( + modelsSetPublicConfigRoute.input.safeParse({ + providerId: provider.id, + modelId: 'model-1', + config: { ...config, conversationId: 'private-session', futureSecret: 'secret' } + }).success + ).toBe(false) + + harness.modelConfigs.set(`${provider.id}:model-1`, { + ...config, + conversationId: 'private-session', + ownedBy: 'internal-owner' + } as ModelConfig) + const result = await harness.invoke(modelsGetPublicConfigRoute.name, { + providerId: provider.id, + modelId: 'model-1' + }) + expect(result).toEqual({ config }) + expect(JSON.stringify(result)).not.toContain('private-session') + }) +}) diff --git a/test/main/cli/surface.test.ts b/test/main/cli/surface.test.ts index da02a7d5a..65c33ae97 100644 --- a/test/main/cli/surface.test.ts +++ b/test/main/cli/surface.test.ts @@ -22,12 +22,22 @@ describe('CLI surface V1', () => { 'cli.status', 'cli.version', 'images.generate', + 'models.getPublicConfig', 'models.invoke', + 'models.listRuntime', + 'models.resetConfig', + 'models.setPublicConfig', + 'models.setStatus', 'ocr.clearCache', 'ocr.extractArtifact', 'ocr.extractUpload', 'ocr.getRuntimeStatus', + 'providers.addPublic', 'providers.listPublic', + 'providers.remove', + 'providers.setCredential', + 'providers.testPublicConnection', + 'providers.updatePublic', 'settings.getPublic', 'settings.updatePublic', 'speech.generate', @@ -73,6 +83,49 @@ describe('CLI surface V1', () => { ).toEqual({ changes: [{ key: 'privacyModeEnabled', value: true }] }) }) + it('never projects provider credential material into approval or audit metadata', () => { + const entry = getCliSurfaceEntry('providers.setCredential')! + const input = { + providerId: 'provider-1', + action: 'set', + kind: 'api-key', + value: 'super-secret' + } + + expect(entry.auditProjection?.(input)).toEqual({ + providerId: 'provider-1', + action: 'set', + kind: 'api-key' + }) + expect(entry.approvalDisplay?.(input)).toEqual({ + providerId: 'provider-1', + action: 'set', + kind: 'api-key' + }) + expect(JSON.stringify(entry.approvalDisplay?.(input))).not.toContain('super-secret') + }) + + it('shows safe mutation values in approvals while keeping audits structural', () => { + const providerEntry = getCliSurfaceEntry('providers.updatePublic')! + const providerInput = { + providerId: 'provider-1', + updates: { baseUrl: 'https://api.example/v1', enabled: false } + } + expect(providerEntry.auditProjection?.(providerInput)).toEqual({ + providerId: 'provider-1', + fields: ['baseUrl', 'enabled'] + }) + expect(providerEntry.approvalDisplay?.(providerInput)).toEqual(providerInput) + + const modelEntry = getCliSurfaceEntry('models.setPublicConfig')! + const modelInput = { + providerId: 'provider-1', + modelId: 'model-1', + config: { maxTokens: 4096, contextLength: 32768 } + } + expect(modelEntry.approvalDisplay?.(modelInput)).toEqual(modelInput) + }) + it('publishes stable sorted capability metadata', () => { expect(listCliSurfaceCapabilities()).toEqual([ expect.objectContaining({ @@ -107,11 +160,28 @@ describe('CLI surface V1', () => { possibleEffects: ['compute'], transport: 'stream' }), + expect.objectContaining({ method: 'models.getPublicConfig', possibleEffects: ['read'] }), expect.objectContaining({ method: 'models.invoke', possibleEffects: ['compute'], transport: 'stream' }), + expect.objectContaining({ method: 'models.listRuntime', possibleEffects: ['read'] }), + expect.objectContaining({ + method: 'models.resetConfig', + possibleEffects: ['execution-config'], + approval: 'policy' + }), + expect.objectContaining({ + method: 'models.setPublicConfig', + possibleEffects: ['execution-config'], + approval: 'policy' + }), + expect.objectContaining({ + method: 'models.setStatus', + possibleEffects: ['execution-config'], + approval: 'policy' + }), expect.objectContaining({ method: 'ocr.clearCache', possibleEffects: ['local-maintenance'], @@ -132,7 +202,32 @@ describe('CLI surface V1', () => { callers: ['human'] }), expect.objectContaining({ method: 'ocr.getRuntimeStatus', possibleEffects: ['read'] }), + expect.objectContaining({ + method: 'providers.addPublic', + possibleEffects: ['execution-config'], + approval: 'policy' + }), expect.objectContaining({ method: 'providers.listPublic', possibleEffects: ['read'] }), + expect.objectContaining({ + method: 'providers.remove', + possibleEffects: ['destructive'], + approval: 'policy' + }), + expect.objectContaining({ + method: 'providers.setCredential', + possibleEffects: ['credential'], + approval: 'policy' + }), + expect.objectContaining({ + method: 'providers.testPublicConnection', + possibleEffects: ['compute'], + approval: 'never' + }), + expect.objectContaining({ + method: 'providers.updatePublic', + possibleEffects: ['execution-config'], + approval: 'policy' + }), expect.objectContaining({ method: 'settings.getPublic', possibleEffects: ['read'] }), expect.objectContaining({ method: 'settings.updatePublic', @@ -152,8 +247,17 @@ describe('CLI surface V1', () => { ]) expect( listCliSurfaceCapabilities() - .filter((capability) => capability.method !== 'settings.updatePublic') - .every((capability) => capability.approval === 'never') - ).toBe(true) + .filter((capability) => capability.approval === 'policy') + .map((capability) => capability.method) + ).toEqual([ + 'models.resetConfig', + 'models.setPublicConfig', + 'models.setStatus', + 'providers.addPublic', + 'providers.remove', + 'providers.setCredential', + 'providers.updatePublic', + 'settings.updatePublic' + ]) }) }) diff --git a/test/main/provider/routes.test.ts b/test/main/provider/routes.test.ts index d6784b7de..a419256fa 100644 --- a/test/main/provider/routes.test.ts +++ b/test/main/provider/routes.test.ts @@ -4,9 +4,11 @@ import { createProviderRoutes } from '@/provider/routes' import { modelsGetCapabilitiesRoute, modelsGetProviderCatalogRoute, + modelsSetStatusRoute, providersImportApplyRoute, providersImportScanRoute, providersListSummariesRoute, + providersRemoveRoute, providersUpdateRoute } from '@shared/contracts/routes' import { ModelType } from '@shared/model' @@ -31,6 +33,54 @@ function createRoutes(deps: { } describe('Provider routes', () => { + it('prevents CLI removal of built-in providers', async () => { + const removeProviderAtomic = vi.fn() + const routes = createRoutes({ + providerSettings: { + getProviderById: vi.fn(() => ({ id: 'openai', custom: false })) + }, + providerRuntime: { removeProviderAtomic } + }) + + await expect( + routes.get(providersRemoveRoute.name)?.( + { providerId: 'openai' }, + { + caller: { + kind: 'cli', + principal: 'human', + connectionId: 'connection-1', + scopes: ['providers:write'] + } + } + ) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(removeProviderAtomic).not.toHaveBeenCalled() + }) + + it('prevents CLI status records for unknown models', async () => { + const updateModelStatus = vi.fn() + const routes = createRoutes({ + providerSettings: { isKnownModel: vi.fn(() => false) }, + providerRuntime: { updateModelStatus } + }) + + await expect( + routes.get(modelsSetStatusRoute.name)?.( + { providerId: 'provider-1', modelId: 'unknown-model', enabled: true }, + { + caller: { + kind: 'cli', + principal: 'human', + connectionId: 'connection-1', + scopes: ['providers:write'] + } + } + ) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(updateModelStatus).not.toHaveBeenCalled() + }) + it('returns one authoritative capability snapshot and forwards draft route metadata', async () => { const snapshot = { identity: { From 72b67b5f2beb5ae77c0de1dc52aed88a4200d75d Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 14:51:39 +0800 Subject: [PATCH 16/51] fix(skill): bound archive extraction --- src/main/skill/archive.ts | 253 +++++++++++++++++++++++++++ src/main/skill/index.ts | 67 +------ test/main/skill/archive.test.ts | 160 +++++++++++++++++ test/main/skill/skillService.test.ts | 32 +++- 4 files changed, 443 insertions(+), 69 deletions(-) create mode 100644 src/main/skill/archive.ts create mode 100644 test/main/skill/archive.test.ts diff --git a/src/main/skill/archive.ts b/src/main/skill/archive.ts new file mode 100644 index 000000000..025eb9b38 --- /dev/null +++ b/src/main/skill/archive.ts @@ -0,0 +1,253 @@ +import fs from 'node:fs' +import path from 'node:path' +import { Unzip, UnzipInflate } from 'fflate' + +export type SkillArchiveLimits = Readonly<{ + maxArchiveBytes: number + maxEntries: number + maxEntryBytes: number + maxExtractedBytes: number + maxCompressionRatio: number + compressionRatioExemptBytes: number + maxPathDepth: number + maxPathCharacters: number +}> + +export const DEFAULT_SKILL_ARCHIVE_LIMITS: SkillArchiveLimits = { + maxArchiveBytes: 200 * 1024 * 1024, + maxEntries: 4096, + maxEntryBytes: 64 * 1024 * 1024, + maxExtractedBytes: 256 * 1024 * 1024, + maxCompressionRatio: 200, + compressionRatioExemptBytes: 1024 * 1024, + maxPathDepth: 32, + maxPathCharacters: 1024 +} + +type ResolvedArchiveEntry = Readonly<{ + destination: string + key: string + isDirectory: boolean +}> + +const WINDOWS_RESERVED_SEGMENT = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i + +function requireBoundedSize(value: number | undefined, label: string): number | undefined { + if (value === undefined) return undefined + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} has an invalid size`) + } + return value +} + +function resolveArchiveEntry( + entryName: string, + targetDir: string, + limits: SkillArchiveLimits +): ResolvedArchiveEntry { + if (entryName.includes('\0') || entryName.length > limits.maxPathCharacters) { + throw new Error('ZIP entry has an invalid path') + } + + const normalizedEntry = entryName.replace(/\\/g, '/') + if (!normalizedEntry) throw new Error('ZIP entry has an invalid path') + if (/^[A-Za-z]:/.test(normalizedEntry) || normalizedEntry.startsWith('/')) { + throw new Error('ZIP entry has an invalid path') + } + + const segments: string[] = [] + for (const segment of normalizedEntry.split('/')) { + if (!segment || segment === '.') continue + if (segment === '..') throw new Error('ZIP entry has an invalid path') + if ( + segment.includes(':') || + segment.endsWith('.') || + segment.endsWith(' ') || + WINDOWS_RESERVED_SEGMENT.test(segment) + ) { + throw new Error('ZIP entry has a non-portable path') + } + segments.push(segment) + } + if (segments.length === 0) throw new Error('ZIP entry has an invalid path') + if (segments.length > limits.maxPathDepth) { + throw new Error('ZIP entry exceeds the path depth limit') + } + + const destination = path.resolve(targetDir, ...segments) + const relativeToTarget = path.relative(targetDir, destination) + if ( + relativeToTarget === '..' || + relativeToTarget.startsWith(`..${path.sep}`) || + path.isAbsolute(relativeToTarget) + ) { + throw new Error('ZIP entry has an invalid path') + } + + return { + destination, + key: segments.join('/').normalize('NFC').toLocaleLowerCase('en-US'), + isDirectory: normalizedEntry.endsWith('/') + } +} + +function writeAll(fd: number, chunk: Uint8Array): void { + let offset = 0 + while (offset < chunk.byteLength) { + const written = fs.writeSync(fd, chunk, offset, chunk.byteLength - offset) + if (written <= 0) throw new Error('Failed to write extracted ZIP entry') + offset += written + } +} + +export async function extractSkillArchive( + archivePath: string, + targetDir: string, + limitOverrides: Partial = {} +): Promise { + const limits = { ...DEFAULT_SKILL_ARCHIVE_LIMITS, ...limitOverrides } + const archiveStats = await fs.promises.stat(archivePath) + if (!archiveStats.isFile()) throw new Error('Skill ZIP input is not a file') + if (archiveStats.size > limits.maxArchiveBytes) { + throw new Error( + `ZIP file too large: ${archiveStats.size} bytes (max: ${limits.maxArchiveBytes})` + ) + } + + const resolvedTargetDir = path.resolve(targetDir) + fs.mkdirSync(resolvedTargetDir, { recursive: true }) + + const seenEntries = new Set() + const openFiles = new Set() + let entryCount = 0 + let declaredBytes = 0 + let extractedBytes = 0 + let archiveBytes = 0 + let failure: Error | null = null + + const closeFile = (fd: number): void => { + if (!openFiles.has(fd)) return + fs.closeSync(fd) + openFiles.delete(fd) + } + const fail = (error: unknown, fd?: number): void => { + if (!failure) failure = error instanceof Error ? error : new Error(String(error)) + if (fd !== undefined) { + try { + closeFile(fd) + } catch { + // Preserve the extraction or write failure that caused cleanup. + } + } + } + const unzip = new Unzip((file) => { + if (failure) { + file.ondata = () => undefined + return + } + + try { + entryCount += 1 + if (entryCount > limits.maxEntries) throw new Error('ZIP archive has too many entries') + + const entry = resolveArchiveEntry(file.name, resolvedTargetDir, limits) + const declaredSize = requireBoundedSize(file.originalSize, 'ZIP entry') + const compressedSize = requireBoundedSize(file.size, 'Compressed ZIP entry') + if (declaredSize !== undefined) { + if (declaredSize > limits.maxEntryBytes) { + throw new Error('ZIP entry exceeds the extracted size limit') + } + declaredBytes += declaredSize + if (declaredBytes > limits.maxExtractedBytes) { + throw new Error('ZIP archive exceeds the total extracted size limit') + } + if ( + compressedSize !== undefined && + declaredSize > limits.compressionRatioExemptBytes && + (compressedSize === 0 || declaredSize / compressedSize > limits.maxCompressionRatio) + ) { + throw new Error('ZIP entry exceeds the compression ratio limit') + } + } + + if (seenEntries.has(entry.key)) throw new Error('ZIP archive contains duplicate paths') + seenEntries.add(entry.key) + + if (entry.isDirectory) { + fs.mkdirSync(entry.destination, { recursive: true }) + file.ondata = (error, chunk) => { + if (error) fail(error) + if (chunk?.byteLength) fail(new Error('ZIP directory entry contains file data')) + } + file.start() + return + } + + fs.mkdirSync(path.dirname(entry.destination), { recursive: true }) + const fd = fs.openSync(entry.destination, 'wx') + openFiles.add(fd) + let entryBytes = 0 + file.ondata = (error, chunk, final) => { + if (error) { + fail(error, fd) + return + } + if (failure) { + if (final) { + try { + closeFile(fd) + } catch { + // Preserve the first extraction failure. + } + } + return + } + + try { + if (chunk?.byteLength) { + entryBytes += chunk.byteLength + extractedBytes += chunk.byteLength + if (entryBytes > limits.maxEntryBytes) { + throw new Error('ZIP entry exceeds the extracted size limit') + } + if (extractedBytes > limits.maxExtractedBytes) { + throw new Error('ZIP archive exceeds the total extracted size limit') + } + writeAll(fd, chunk) + } + if (final) closeFile(fd) + } catch (writeError) { + fail(writeError, fd) + } + } + file.start() + } catch (error) { + fail(error) + } + }) + unzip.register(UnzipInflate) + + const archiveStream = fs.createReadStream(archivePath, { highWaterMark: 64 * 1024 }) + try { + for await (const chunk of archiveStream) { + archiveBytes += chunk.byteLength + if (archiveBytes > limits.maxArchiveBytes) { + throw new Error(`ZIP file exceeds its ${limits.maxArchiveBytes} byte limit`) + } + unzip.push(chunk, false) + if (failure) throw failure + } + unzip.push(new Uint8Array(0), true) + if (failure) throw failure + if (openFiles.size > 0) throw new Error('ZIP archive ended before an entry was complete') + } finally { + archiveStream.destroy() + for (const fd of openFiles) { + try { + closeFile(fd) + } catch { + // The original extraction error remains the actionable failure. + } + } + } +} diff --git a/src/main/skill/index.ts b/src/main/skill/index.ts index 077ccbce9..432a03587 100644 --- a/src/main/skill/index.ts +++ b/src/main/skill/index.ts @@ -5,8 +5,8 @@ import { execFile } from 'node:child_process' import { randomUUID } from 'node:crypto' import { promisify } from 'node:util' import matter from 'gray-matter' -import { unzipSync } from 'fflate' import type { SkillSettingsPort } from './settings' +import { extractSkillArchive } from './archive' import { createWatcherRequestId, type IFileWatcherService, @@ -73,7 +73,7 @@ export const SKILL_CONFIG = { /** Maximum size for SKILL.md file (bytes) - prevents memory exhaustion */ SKILL_FILE_MAX_SIZE: 5 * 1024 * 1024, // 5MB - /** Maximum size for ZIP file (bytes) - prevents ZIP bomb attacks */ + /** Maximum compressed ZIP input size (bytes) */ ZIP_MAX_SIZE: 200 * 1024 * 1024, // 200MB /** Download timeout (milliseconds) - prevents hanging connections */ @@ -2173,7 +2173,9 @@ export class SkillService implements SkillServicePort { const tempDir = fs.mkdtempSync(path.join(app.getPath('temp'), 'deepchat-skill-')) try { - this.extractZipToDirectory(zipPath, tempDir) + await extractSkillArchive(zipPath, tempDir, { + maxArchiveBytes: SKILL_CONFIG.ZIP_MAX_SIZE + }) const skillDir = this.resolveSkillDirFromExtracted(tempDir) if (!skillDir) { return { success: false, error: 'SKILL.md not found in zip archive' } @@ -3025,65 +3027,6 @@ export class SkillService implements SkillServicePort { return code === 'EPERM' || code === 'EBUSY' || code === 'EACCES' || code === 'ENOTEMPTY' } - private extractZipToDirectory(zipPath: string, targetDir: string): void { - // Check ZIP file size before loading to prevent memory exhaustion - const stats = fs.statSync(zipPath) - if (stats.size > SKILL_CONFIG.ZIP_MAX_SIZE) { - throw new Error(`ZIP file too large: ${stats.size} bytes (max: ${SKILL_CONFIG.ZIP_MAX_SIZE})`) - } - - const zipContent = new Uint8Array(fs.readFileSync(zipPath)) - const extracted = unzipSync(zipContent) - const resolvedTargetDir = path.resolve(targetDir) - - for (const entryName of Object.keys(extracted)) { - const fileContent = extracted[entryName] - if (!fileContent) { - continue - } - - const normalizedEntry = entryName.replace(/\\/g, '/') - if (!normalizedEntry) { - continue - } - - if (/^[A-Za-z]:/.test(normalizedEntry) || normalizedEntry.startsWith('/')) { - throw new Error('Invalid zip entry') - } - - const segments = normalizedEntry.split('/') - const safeSegments: string[] = [] - for (const segment of segments) { - if (!segment || segment === '.') { - continue - } - if (segment === '..') { - throw new Error('Invalid zip entry') - } - safeSegments.push(segment) - } - - if (safeSegments.length === 0) { - continue - } - - const isDirectoryEntry = normalizedEntry.endsWith('/') - const destination = path.resolve(resolvedTargetDir, ...safeSegments) - const relativeToTarget = path.relative(resolvedTargetDir, destination) - if (relativeToTarget.startsWith('..') || path.isAbsolute(relativeToTarget)) { - throw new Error('Invalid zip entry') - } - - if (isDirectoryEntry) { - fs.mkdirSync(destination, { recursive: true }) - continue - } - - fs.mkdirSync(path.dirname(destination), { recursive: true }) - fs.writeFileSync(destination, Buffer.from(fileContent)) - } - } - private resolveSkillDirFromExtracted(extractDir: string): string | null { const rootSkill = path.join(extractDir, 'SKILL.md') if (fs.existsSync(rootSkill)) { diff --git a/test/main/skill/archive.test.ts b/test/main/skill/archive.test.ts new file mode 100644 index 000000000..59b5d594a --- /dev/null +++ b/test/main/skill/archive.test.ts @@ -0,0 +1,160 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { strToU8, zipSync } from 'fflate' +import { extractSkillArchive } from '@/skill/archive' + +vi.unmock('fs') +vi.unmock('node:fs') +vi.unmock('path') +vi.unmock('node:path') + +const temporaryDirectories: string[] = [] + +function createTemporaryDirectory(): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'deepchat-skill-archive-test-')) + temporaryDirectories.push(directory) + return directory +} + +function writeArchive(directory: string, entries: Record): string { + const archivePath = path.join(directory, 'skill.zip') + fs.writeFileSync(archivePath, zipSync(entries, { level: 9 })) + return archivePath +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('extractSkillArchive', () => { + it('streams regular files into the target directory', async () => { + const root = createTemporaryDirectory() + const archivePath = writeArchive(root, { + 'example/SKILL.md': strToU8('# Example'), + 'example/scripts/run.js': strToU8("console.log('ok')") + }) + const target = path.join(root, 'target') + + await extractSkillArchive(archivePath, target) + + expect(fs.readFileSync(path.join(target, 'example', 'SKILL.md'), 'utf8')).toBe('# Example') + expect(fs.readFileSync(path.join(target, 'example', 'scripts', 'run.js'), 'utf8')).toBe( + "console.log('ok')" + ) + }) + + it('rejects traversal without writing outside the target', async () => { + const root = createTemporaryDirectory() + const archivePath = writeArchive(root, { + '../escaped.txt': strToU8('escaped') + }) + const target = path.join(root, 'target') + + await expect(extractSkillArchive(archivePath, target)).rejects.toThrow('invalid path') + expect(fs.existsSync(path.join(root, 'escaped.txt'))).toBe(false) + }) + + it('rejects archives whose declared output exceeds the total limit', async () => { + const root = createTemporaryDirectory() + const archivePath = writeArchive(root, { + 'one.txt': new Uint8Array(8), + 'two.txt': new Uint8Array(8) + }) + + await expect( + extractSkillArchive(archivePath, path.join(root, 'target'), { + maxExtractedBytes: 12 + }) + ).rejects.toThrow('total extracted size limit') + }) + + it('rejects oversized entries before their content is written', async () => { + const root = createTemporaryDirectory() + const archivePath = writeArchive(root, { + 'large.txt': new Uint8Array(16) + }) + const target = path.join(root, 'target') + + await expect(extractSkillArchive(archivePath, target, { maxEntryBytes: 8 })).rejects.toThrow( + 'entry exceeds' + ) + expect(fs.existsSync(path.join(target, 'large.txt'))).toBe(false) + }) + + it('rejects suspicious expansion ratios above the exempt size', async () => { + const root = createTemporaryDirectory() + const archivePath = writeArchive(root, { + 'repeated.bin': new Uint8Array(4096) + }) + + await expect( + extractSkillArchive(archivePath, path.join(root, 'target'), { + compressionRatioExemptBytes: 0, + maxCompressionRatio: 2 + }) + ).rejects.toThrow('compression ratio limit') + }) + + it('rejects duplicate normalized paths', async () => { + const root = createTemporaryDirectory() + const archivePath = writeArchive(root, { + 'Example/SKILL.md': strToU8('one'), + 'example/skill.md': strToU8('two') + }) + + await expect(extractSkillArchive(archivePath, path.join(root, 'target'))).rejects.toThrow( + 'duplicate paths' + ) + }) + + it('rejects paths that create Windows alternate streams or device files', async () => { + const root = createTemporaryDirectory() + const archivePath = writeArchive(root, { + 'scripts/output.txt:payload': strToU8('one'), + 'NUL.txt': strToU8('two') + }) + + await expect(extractSkillArchive(archivePath, path.join(root, 'target'))).rejects.toThrow( + 'non-portable path' + ) + }) + + it('allows safe names that merely start with two dots', async () => { + const root = createTemporaryDirectory() + const archivePath = writeArchive(root, { + '..notes/SKILL.md': strToU8('notes') + }) + const target = path.join(root, 'target') + + await extractSkillArchive(archivePath, target) + + expect(fs.readFileSync(path.join(target, '..notes', 'SKILL.md'), 'utf8')).toBe('notes') + }) + + it('rejects archives that exceed the entry limit', async () => { + const root = createTemporaryDirectory() + const archivePath = writeArchive(root, { + 'one.txt': strToU8('one'), + 'two.txt': strToU8('two') + }) + + await expect( + extractSkillArchive(archivePath, path.join(root, 'target'), { maxEntries: 1 }) + ).rejects.toThrow('too many entries') + }) + + it('rejects compressed input above the configured byte limit', async () => { + const root = createTemporaryDirectory() + const archivePath = writeArchive(root, { + 'SKILL.md': strToU8('# Example') + }) + + await expect( + extractSkillArchive(archivePath, path.join(root, 'target'), { maxArchiveBytes: 1 }) + ).rejects.toThrow('ZIP file too large') + }) +}) diff --git a/test/main/skill/skillService.test.ts b/test/main/skill/skillService.test.ts index 5f376bf94..89bddc0d9 100644 --- a/test/main/skill/skillService.test.ts +++ b/test/main/skill/skillService.test.ts @@ -27,6 +27,9 @@ const discoveryWorkerMock = vi.hoisted(() => ({ })) const publishDeepchatEventMock = vi.hoisted(() => vi.fn()) +const skillArchiveMock = vi.hoisted(() => ({ + extractSkillArchive: vi.fn() +})) // Mock external dependencies vi.mock('electron', () => ({ @@ -134,10 +137,6 @@ vi.mock('gray-matter', () => { } }) -vi.mock('fflate', () => ({ - unzipSync: vi.fn() -})) - vi.mock('node:child_process', () => ({ execFile: vi.fn( ( @@ -168,12 +167,12 @@ vi.mock('@shared/logger', () => ({ })) vi.mock('../../../src/main/skill/discoveryWorker', () => discoveryWorkerMock) +vi.mock('../../../src/main/skill/archive', () => skillArchiveMock) // Import mocked modules import fs from 'fs' import path from 'path' import matter from 'gray-matter' -import { unzipSync } from 'fflate' import { execFile } from 'node:child_process' import { randomUUID } from 'node:crypto' import logger from '@shared/logger' @@ -336,6 +335,7 @@ describe('SkillService', () => { // Setup default mocks ;(fs.existsSync as Mock).mockReturnValue(true) ;(fs.mkdirSync as Mock).mockReturnValue(undefined) + ;(fs.mkdtempSync as Mock).mockReturnValue('/mock/temp/deepchat-skill-123') ;(fs.readdirSync as Mock).mockReturnValue([]) ;(fs.statSync as Mock).mockReturnValue({ isFile: () => true, @@ -372,6 +372,7 @@ describe('SkillService', () => { new Error('worker unavailable') ) discoveryWorkerMock.logSkillDiscoveryWorkerWarnings.mockImplementation(() => {}) + skillArchiveMock.extractSkillArchive.mockResolvedValue(undefined) ;(skillSessionStatePort.hasNewSession as Mock).mockResolvedValue(false) ;(skillSessionStatePort.repairImportedLegacySessionSkills as Mock).mockImplementation( async (conversationId: string) => newSessionActiveSkillsStore.get(conversationId) ?? [] @@ -2297,14 +2298,31 @@ describe('SkillService', () => { // Temp dir exists return true }) - ;(fs.readFileSync as Mock).mockReturnValue(new Uint8Array([0x50, 0x4b, 0x03, 0x04])) - ;(unzipSync as Mock).mockReturnValue({}) ;(fs.readdirSync as Mock).mockReturnValue([]) const result = await skillService.installFromZip('/path/to/skill.zip') expect(result.success).toBe(false) expect(result.error).toContain('SKILL.md not found') + expect(skillArchiveMock.extractSkillArchive).toHaveBeenCalledWith( + '/path/to/skill.zip', + '/mock/temp/deepchat-skill-123', + { maxArchiveBytes: SKILL_CONFIG.ZIP_MAX_SIZE } + ) + }) + + it('cleans the temporary directory when bounded extraction fails', async () => { + skillArchiveMock.extractSkillArchive.mockRejectedValueOnce( + new Error('ZIP archive exceeds the total extracted size limit') + ) + + const result = await skillService.installFromZip('/path/to/skill.zip') + + expect(result).toMatchObject({ success: false, errorCode: 'io_error' }) + expect(fs.rmSync).toHaveBeenCalledWith('/mock/temp/deepchat-skill-123', { + recursive: true, + force: true + }) }) }) From 05d1cfe68dd800542a4de585c5a778b2dde341f4 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 15:01:27 +0800 Subject: [PATCH 17/51] fix(cli): bind upload bodies --- src/cli/transport.ts | 74 +++++++++++++++++++++++++--- src/main/cli/body.ts | 15 ++++-- src/main/cli/policy.ts | 23 +++++++-- src/main/cli/server.ts | 54 +++++++++++++++++--- src/shared/contracts/localControl.ts | 13 +++++ test/main/cli/body.test.ts | 9 +++- test/main/cli/policy.test.ts | 22 +++++++++ test/main/cli/server.test.ts | 60 ++++++++++++++++++++-- test/main/cli/transport.test.ts | 12 +++-- 9 files changed, 252 insertions(+), 30 deletions(-) diff --git a/src/cli/transport.ts b/src/cli/transport.ts index df27f8208..cd59a35c1 100644 --- a/src/cli/transport.ts +++ b/src/cli/transport.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import { constants as fsConstants } from 'node:fs' import { lstat, open } from 'node:fs/promises' import { request as httpRequest, type IncomingHttpHeaders, type IncomingMessage } from 'node:http' @@ -13,9 +14,11 @@ import { LocalControlRpcRequestSchema, LocalControlRpcResponseSchema, LocalControlStreamRecordSchema, + LocalControlUploadRequestSchema, type LocalControlDescriptor, type LocalControlEventEnvelope, - type LocalControlRpcResponse + type LocalControlRpcResponse, + type LocalControlUploadBinding } from '@shared/contracts/localControl' import type { JsonValue } from '@shared/contracts/json' import { CLI_EXIT_CODES, CliClientError } from './errors' @@ -88,6 +91,25 @@ function createInvocationBody(invocation: CliRpcInvocation): Buffer { ) } +function createUploadInvocationHeader( + invocation: CliRpcInvocation, + upload: LocalControlUploadBinding +): string { + return Buffer.from( + JSON.stringify( + LocalControlUploadRequestSchema.parse({ + protocolVersion: invocation.descriptor.protocolVersion, + surfaceVersion: invocation.descriptor.surfaceVersion, + id: invocation.id, + method: invocation.method, + params: invocation.params, + upload + }) + ), + 'utf8' + ).toString('base64url') +} + async function readJsonResponse( response: IncomingMessage, expectedRequestId: string, @@ -213,6 +235,33 @@ function uploadFileError(message: string, code: 'invalid_request' | 'body_too_la ) } +async function hashUploadFile( + handle: Awaited>, + size: number, + signal: AbortSignal +): Promise { + try { + const hash = createHash('sha256') + const buffer = Buffer.allocUnsafe(Math.min(size, 256 * 1024)) + let position = 0 + while (position < size) { + if (signal.aborted) throw abortReason(signal) + const length = Math.min(buffer.length, size - position) + const { bytesRead } = await handle.read(buffer, 0, length, position) + if (bytesRead === 0) { + throw uploadFileError('Upload source changed while it was being read', 'invalid_request') + } + hash.update(buffer.subarray(0, bytesRead)) + position += bytesRead + } + return hash.digest('hex') + } catch (error) { + if (signal.aborted) throw abortReason(signal) + if (error instanceof CliClientError) throw error + throw uploadFileError('Upload source could not be read', 'invalid_request') + } +} + export async function invokeLocalControlUpload( invocation: CliUploadInvocation ): Promise { @@ -221,11 +270,6 @@ export async function invokeLocalControlUpload( throw protocolFailure('CLI upload limit is invalid') } - const envelope = createInvocationBody(invocation).toString('base64url') - if (Buffer.byteLength(envelope, 'ascii') > LOCAL_CONTROL_MAX_UPLOAD_REQUEST_HEADER_BYTES) { - throw uploadFileError('Upload metadata exceeds the CLI byte limit', 'invalid_request') - } - let pathStat try { pathStat = await lstat(invocation.filePath) @@ -264,6 +308,24 @@ export async function invokeLocalControlUpload( throw uploadFileError('Upload source changed before it could be read', 'invalid_request') } + const sha256 = await hashUploadFile(handle, openedStat.size, invocation.signal) + const hashedStat = await handle.stat() + if ( + !hashedStat.isFile() || + hashedStat.size !== openedStat.size || + hashedStat.dev !== openedStat.dev || + hashedStat.ino !== openedStat.ino + ) { + throw uploadFileError('Upload source changed while it was being read', 'invalid_request') + } + const envelope = createUploadInvocationHeader(invocation, { + size: openedStat.size, + sha256 + }) + if (Buffer.byteLength(envelope, 'ascii') > LOCAL_CONTROL_MAX_UPLOAD_REQUEST_HEADER_BYTES) { + throw uploadFileError('Upload metadata exceeds the CLI byte limit', 'invalid_request') + } + const uploadStream = handle.createReadStream({ autoClose: false, start: 0, diff --git a/src/main/cli/body.ts b/src/main/cli/body.ts index 3c3c6d32d..243ce30d3 100644 --- a/src/main/cli/body.ts +++ b/src/main/cli/body.ts @@ -1,4 +1,4 @@ -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { mkdir, open, readFile, unlink, type FileHandle } from 'node:fs/promises' import path from 'node:path' import type { IncomingMessage } from 'node:http' @@ -14,12 +14,14 @@ export type BoundedRequestBody = kind: 'memory' bytes: Buffer size: number + sha256: string cleanup(): Promise }> | Readonly<{ kind: 'file' path: string size: number + sha256: string cleanup(): Promise }> @@ -30,7 +32,7 @@ export type BoundedBodyOptions = Readonly<{ requireContentLength: boolean }> -function parseDeclaredLength(request: IncomingMessage): number | null { +export function readDeclaredBodyLength(request: IncomingMessage): number | null { const distinctValues = request.headersDistinct['content-length'] if (distinctValues && distinctValues.length !== 1) { throw new CliRequestError('invalid_request', 'Content-Length must be singular') @@ -90,7 +92,7 @@ export async function readBoundedRequestBody( throw new CliRequestError('invalid_request', 'Content-Encoding is not supported') } - const declaredLength = parseDeclaredLength(request) + const declaredLength = readDeclaredBodyLength(request) if (options.requireContentLength && declaredLength === null) { throw new CliRequestError('invalid_request', 'Content-Length is required', { httpStatus: 411 @@ -103,6 +105,7 @@ export async function readBoundedRequestBody( } const chunks: Buffer[] = [] + const sha256 = createHash('sha256') let size = 0 let filePosition = 0 let fileHandle: FileHandle | undefined @@ -133,6 +136,8 @@ export async function readBoundedRequestBody( }) } + sha256.update(chunk) + if (!fileHandle && size > options.memoryThresholdBytes) { await mkdir(options.tempDirectory, { recursive: true, mode: 0o700 }) tempPath = path.join(options.tempDirectory, `body-${randomUUID()}.tmp`) @@ -151,6 +156,8 @@ export async function readBoundedRequestBody( throw new CliRequestError('invalid_request', 'Request body length does not match') } + const digest = sha256.digest('hex') + if (fileHandle && tempPath) { await fileHandle.close() fileHandle = undefined @@ -160,6 +167,7 @@ export async function readBoundedRequestBody( kind: 'file', path: persistedPath, size, + sha256: digest, cleanup: async () => { if (cleaned) return await removeFile(persistedPath) @@ -172,6 +180,7 @@ export async function readBoundedRequestBody( kind: 'memory', bytes: Buffer.concat(chunks, size), size, + sha256: digest, cleanup: async () => undefined } } catch (error) { diff --git a/src/main/cli/policy.ts b/src/main/cli/policy.ts index 7f0a6f5ae..d218c1738 100644 --- a/src/main/cli/policy.ts +++ b/src/main/cli/policy.ts @@ -37,6 +37,7 @@ export type CliPolicyAuditRecord = Readonly<{ export type CliRequestPolicyInput = Readonly<{ entry: CliSurfaceEntry input: unknown + transportBinding?: JsonValue caller: CliRouteCaller requestId: string signal: AbortSignal @@ -130,9 +131,20 @@ export class CliRequestPolicy { httpStatus: 500 }) } + const redactedArguments = + input.transportBinding !== undefined + ? { + request: auditProjection(input.entry, input.input), + transport: input.transportBinding + } + : auditProjection(input.entry, input.input) + const approvalArguments = + input.transportBinding !== undefined + ? { params: input.input, transport: input.transportBinding } + : input.input const redactedArgumentsHash = hashApprovalArguments({ operation: input.entry.contract.name, - arguments: auditProjection(input.entry, input.input) + arguments: redactedArguments }) const audit = async ( outcome: CliPolicyAuditOutcome, @@ -187,14 +199,19 @@ export class CliRequestPolicy { } let approvalRequestId: string try { + const routeDisplayData = input.entry.approvalDisplay(input.input) + const approvalDisplayData = + input.transportBinding !== undefined + ? { request: routeDisplayData, transport: input.transportBinding } + : routeDisplayData const approval = await this.options.mutationGuard.authorize({ operation: input.entry.contract.name, effect, principal: input.caller.principal, connectionId: input.caller.connectionId, clientRequestId: input.requestId, - arguments: input.input, - displayData: input.entry.approvalDisplay(input.input), + arguments: approvalArguments, + displayData: approvalDisplayData, signal: input.signal }) approvalRequestId = approval.approvalRequestId diff --git a/src/main/cli/server.ts b/src/main/cli/server.ts index 8fa386254..1e923cf1f 100644 --- a/src/main/cli/server.ts +++ b/src/main/cli/server.ts @@ -23,14 +23,22 @@ import { LocalControlScopesSchema, LocalControlTokenSchema, LocalControlRpcRequestSchema, + LocalControlUploadRequestSchema, createLocalControlFailure, createLocalControlSuccess, type LocalControlDescriptor, + type LocalControlRpcRequest, + type LocalControlUploadBinding, type LocalControlStreamRecord } from '@shared/contracts/localControl' import type { CliRouteCaller } from '@/routes/routeRegistry' import type { CliRequestAdmission, CliRequestPolicyInput } from './policy' -import { parseBoundedJsonBody, parseBoundedJsonBytes, readBoundedRequestBody } from './body' +import { + parseBoundedJsonBody, + parseBoundedJsonBytes, + readBoundedRequestBody, + readDeclaredBodyLength +} from './body' import { cleanupLocalControlLayout, createLocalControlLayout, @@ -574,6 +582,7 @@ export class CliServer { try { let bodySize = 0 let rawRequest: unknown + let transportBinding: LocalControlUploadBinding | undefined if (requestTransport === 'upload') { rawRequest = parseUploadRequestHeader(request) } else { @@ -600,11 +609,21 @@ export class CliServer { ) } - const parsedRequest = LocalControlRpcRequestSchema.safeParse(rawRequest) - if (!parsedRequest.success) { - throw new CliRequestError('invalid_request', 'Request does not match the RPC contract') + let rpcRequest: LocalControlRpcRequest + if (requestTransport === 'upload') { + const parsedRequest = LocalControlUploadRequestSchema.safeParse(rawRequest) + if (!parsedRequest.success) { + throw new CliRequestError('invalid_request', 'Request does not match the upload contract') + } + rpcRequest = parsedRequest.data + transportBinding = parsedRequest.data.upload + } else { + const parsedRequest = LocalControlRpcRequestSchema.safeParse(rawRequest) + if (!parsedRequest.success) { + throw new CliRequestError('invalid_request', 'Request does not match the RPC contract') + } + rpcRequest = parsedRequest.data } - const rpcRequest = parsedRequest.data requestId = rpcRequest.id routeMethod = rpcRequest.method const entry = this.surface.get(rpcRequest.method) @@ -618,6 +637,20 @@ export class CliServer { httpStatus: 413 }) } + if (transportBinding && transportBinding.size > entry.limits.maxBodyBytes) { + throw new CliRequestError('body_too_large', 'Upload body exceeds method limit', { + httpStatus: 413 + }) + } + if (transportBinding) { + const declaredLength = readDeclaredBodyLength(request) + if (declaredLength !== null && declaredLength !== transportBinding.size) { + throw new CliRequestError( + 'invalid_request', + 'Content-Length does not match the upload binding' + ) + } + } const parsedInput = entry.contract.input.safeParse(rpcRequest.params) if (!parsedInput.success) { throw new CliRequestError('invalid_request', 'Request does not match the route contract') @@ -641,7 +674,8 @@ export class CliServer { input, caller, requestId, - signal: controller.signal + signal: controller.signal, + ...(transportBinding ? { transportBinding } : {}) }) this.assertSurfaceAccess(entry, caller) if (controller.signal.aborted) throw requestAbortError(controller.signal) @@ -671,8 +705,12 @@ export class CliServer { requireContentLength: false }) try { - if (uploadBody.size === 0) { - throw new CliRequestError('invalid_request', 'Upload body is empty') + if ( + !transportBinding || + uploadBody.size !== transportBinding.size || + uploadBody.sha256 !== transportBinding.sha256 + ) { + throw new CliRequestError('invalid_request', 'Upload body does not match its binding') } if (uploadBody.kind !== 'file') { throw new CliRequestError('internal_error', 'Upload body was not persisted', { diff --git a/src/shared/contracts/localControl.ts b/src/shared/contracts/localControl.ts index ef2ca7b34..ddab74ecb 100644 --- a/src/shared/contracts/localControl.ts +++ b/src/shared/contracts/localControl.ts @@ -137,6 +137,17 @@ export const LocalControlRpcRequestSchema = z }) .strict() +export const LocalControlUploadBindingSchema = z + .object({ + size: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + sha256: z.string().regex(/^[a-f0-9]{64}$/) + }) + .strict() + +export const LocalControlUploadRequestSchema = LocalControlRpcRequestSchema.extend({ + upload: LocalControlUploadBindingSchema +}).strict() + export const LOCAL_CONTROL_ERROR_CODES = [ 'invalid_request', 'unsupported_version', @@ -211,6 +222,8 @@ export type LocalControlPrincipal = z.infer export type LocalControlEndpoint = z.infer export type LocalControlDescriptor = z.infer export type LocalControlRpcRequest = z.infer +export type LocalControlUploadBinding = z.infer +export type LocalControlUploadRequest = z.infer export type LocalControlErrorCode = z.infer export type LocalControlError = z.infer export type LocalControlRpcResponse = z.infer diff --git a/test/main/cli/body.test.ts b/test/main/cli/body.test.ts index 9f033cf52..b5bd259cd 100644 --- a/test/main/cli/body.test.ts +++ b/test/main/cli/body.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import { Readable } from 'node:stream' import type { IncomingMessage } from 'node:http' import { mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises' @@ -9,6 +10,10 @@ import { CliRequestError } from '@/cli/errors' const temporaryDirectories: string[] = [] +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex') +} + async function createTemporaryDirectory(): Promise { const directory = await mkdtemp(path.join(os.tmpdir(), 'deepchat-cli-body-')) temporaryDirectories.push(directory) @@ -48,7 +53,7 @@ describe('bounded CLI request bodies', () => { requireContentLength: true }) - expect(body).toMatchObject({ kind: 'memory', size: 12 }) + expect(body).toMatchObject({ kind: 'memory', size: 12, sha256: sha256('{"value":42}') }) expect(body.kind === 'memory' ? body.bytes.toString('utf8') : '').toBe('{"value":42}') await expect(stat(tempDirectory)).rejects.toMatchObject({ code: 'ENOENT' }) }) @@ -68,6 +73,7 @@ describe('bounded CLI request bodies', () => { expect(body.kind).toBe('file') if (body.kind !== 'file') throw new Error('Expected a spilled body') expect(await readFile(body.path, 'utf8')).toBe('1234567890') + expect(body.sha256).toBe(sha256('1234567890')) if (process.platform !== 'win32') { expect((await stat(body.path)).mode & 0o777).toBe(0o600) } @@ -118,6 +124,7 @@ describe('bounded CLI request bodies', () => { kind: 'memory', bytes: Buffer.from('{"nested":{"__proto__":true}}'), size: 31, + sha256: sha256('{"nested":{"__proto__":true}}'), cleanup }) ).rejects.toMatchObject({ code: 'invalid_request' }) diff --git a/test/main/cli/policy.test.ts b/test/main/cli/policy.test.ts index b5bcc467c..29d9208d5 100644 --- a/test/main/cli/policy.test.ts +++ b/test/main/cli/policy.test.ts @@ -256,6 +256,28 @@ describe('CliRequestPolicy', () => { expect(harness.auditRecords[0].redactedArgumentsHash).toMatch(/^[a-f0-9]{64}$/) }) + it('binds upload metadata to mutation approval without exposing raw params', async () => { + const harness = createHarness() + const transportBinding = { size: 11, sha256: 'a'.repeat(64) } + + await harness.policy.authorize({ + entry: entry('supply-chain', 'policy'), + input: { secret: 'must-be-bound' }, + transportBinding, + caller: humanCaller, + requestId: 'request-upload', + signal: new AbortController().signal + }) + + expect(harness.authorize).toHaveBeenCalledWith( + expect.objectContaining({ + arguments: { params: { secret: 'must-be-bound' }, transport: transportBinding }, + displayData: { request: { target: 'safe-setting' }, transport: transportBinding } + }) + ) + expect(JSON.stringify(harness.auditRecords)).not.toContain('must-be-bound') + }) + it('limits concurrent and burst agent compute per conversation and releases idempotently', async () => { const harness = createHarness({ agentComputeLimit: 1, agentComputeStartsPerMinute: 2 }) const first = await harness.invoke('compute', agentCaller) diff --git a/test/main/cli/server.test.ts b/test/main/cli/server.test.ts index 9bd217336..1a7084851 100644 --- a/test/main/cli/server.test.ts +++ b/test/main/cli/server.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import { request as httpRequest } from 'node:http' import { mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises' import os from 'node:os' @@ -11,11 +12,12 @@ import { LOCAL_CONTROL_SURFACE_VERSION, LOCAL_CONTROL_UPLOAD_REQUEST_HEADER, LocalControlDescriptorSchema, - LocalControlRpcRequestSchema, LocalControlRpcResponseSchema, + LocalControlUploadRequestSchema, type LocalControlDescriptor, type LocalControlRpcResponse, - type LocalControlScope + type LocalControlScope, + type LocalControlUploadBinding } from '@shared/contracts/localControl' import { createCliRoutes } from '@/cli/routes' import { CliServer, type AgentCliToken, type CliUploadedInputFile } from '@/cli/server' @@ -115,16 +117,21 @@ function uploadRequest( body: Buffer includeContentLength?: boolean signal?: AbortSignal + binding?: LocalControlUploadBinding } ): Promise { const envelope = Buffer.from( JSON.stringify( - LocalControlRpcRequestSchema.parse({ + LocalControlUploadRequestSchema.parse({ protocolVersion: LOCAL_CONTROL_PROTOCOL_VERSION, surfaceVersion: LOCAL_CONTROL_SURFACE_VERSION, id: 'request-upload', method: cliVersionRoute.name, - params: {} + params: {}, + upload: input.binding ?? { + size: input.body.length, + sha256: createHash('sha256').update(input.body).digest('hex') + } }) ) ).toString('base64url') @@ -403,8 +410,9 @@ describe('CLI local transport', () => { it('validates upload policy before spilling and cleans the private input file', async () => { let uploadedPath = '' let uploadedBytes = Buffer.alloc(0) - const { descriptor, userDataPath, dispatchUpload } = await createTestServer({ + const { descriptor, userDataPath, dispatchUpload, authorize } = await createTestServer({ surface: createUploadSurface(16), + authorize: async () => ({ release: () => undefined }), dispatchUpload: async (_method, _input, upload) => { uploadedPath = upload.path uploadedBytes = await readFile(upload.path) @@ -421,10 +429,52 @@ describe('CLI local transport', () => { expect(response).toMatchObject({ status: 200, body: { ok: true } }) expect(uploadedBytes).toEqual(Buffer.from('audio-input')) expect(dispatchUpload).toHaveBeenCalledOnce() + expect(authorize).toHaveBeenCalledWith( + expect.objectContaining({ + transportBinding: { + size: 11, + sha256: createHash('sha256').update('audio-input').digest('hex') + } + }) + ) await expect(stat(uploadedPath)).rejects.toMatchObject({ code: 'ENOENT' }) expect(await readdir(path.join(userDataPath, 'local-control', 'tmp'))).toEqual([]) }) + it('rejects upload bytes that do not match the approved size or digest', async () => { + const { descriptor, dispatchUpload, authorize } = await createTestServer({ + surface: createUploadSurface(16), + authorize: async () => ({ release: () => undefined }) + }) + const body = Buffer.from('audio-input') + + const wrongSize = await uploadRequest(descriptor, { + body, + binding: { + size: 10, + sha256: createHash('sha256').update(body).digest('hex') + } + }) + const wrongDigest = await uploadRequest(descriptor, { + body, + binding: { + size: 11, + sha256: createHash('sha256').update('other-bytes').digest('hex') + } + }) + + expect(wrongSize).toMatchObject({ + status: 400, + body: { ok: false, error: { code: 'invalid_request' } } + }) + expect(wrongDigest).toMatchObject({ + status: 400, + body: { ok: false, error: { code: 'invalid_request' } } + }) + expect(authorize).toHaveBeenCalledOnce() + expect(dispatchUpload).not.toHaveBeenCalled() + }) + it('bounds chunked uploads cumulatively and removes partial spill files', async () => { const { descriptor, userDataPath, dispatchUpload } = await createTestServer({ surface: createUploadSurface(8) diff --git a/test/main/cli/transport.test.ts b/test/main/cli/transport.test.ts index eba4277fc..643153d4a 100644 --- a/test/main/cli/transport.test.ts +++ b/test/main/cli/transport.test.ts @@ -1,4 +1,4 @@ -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { createServer, type RequestListener, type Server } from 'node:http' import os from 'node:os' @@ -8,7 +8,7 @@ import { LOCAL_CONTROL_PROTOCOL_VERSION, LOCAL_CONTROL_SURFACE_VERSION, LOCAL_CONTROL_UPLOAD_REQUEST_HEADER, - LocalControlRpcRequestSchema, + LocalControlUploadRequestSchema, createLocalControlSuccess, type LocalControlDescriptor, type LocalControlEndpoint, @@ -221,7 +221,7 @@ describe('CLI response transport', () => { let receivedEnvelope: unknown const descriptor = await listen((request, response) => { const rawEnvelope = request.headers[LOCAL_CONTROL_UPLOAD_REQUEST_HEADER] - receivedEnvelope = LocalControlRpcRequestSchema.parse( + receivedEnvelope = LocalControlUploadRequestSchema.parse( JSON.parse(Buffer.from(String(rawEnvelope), 'base64url').toString('utf8')) ) const chunks: Buffer[] = [] @@ -252,7 +252,11 @@ describe('CLI response transport', () => { expect(receivedEnvelope).toMatchObject({ id: 'request-1', method: 'audio.transcribeUpload', - params: { filename: 'sample.wav' } + params: { filename: 'sample.wav' }, + upload: { + size: 11, + sha256: createHash('sha256').update('audio-bytes').digest('hex') + } }) expect(receivedBody).toEqual(Buffer.from('audio-bytes')) }) From 2550cc22682841a09106abad4e9823af9c2f0ef0 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 15:05:08 +0800 Subject: [PATCH 18/51] fix(skill): stream archive downloads --- src/main/skill/archiveDownload.ts | 184 ++++++++++++++++++++++++ src/main/skill/index.ts | 52 +------ test/main/skill/archiveDownload.test.ts | 127 ++++++++++++++++ 3 files changed, 317 insertions(+), 46 deletions(-) create mode 100644 src/main/skill/archiveDownload.ts create mode 100644 test/main/skill/archiveDownload.test.ts diff --git a/src/main/skill/archiveDownload.ts b/src/main/skill/archiveDownload.ts new file mode 100644 index 000000000..69b1883bb --- /dev/null +++ b/src/main/skill/archiveDownload.ts @@ -0,0 +1,184 @@ +import { open, unlink, type FileHandle } from 'node:fs/promises' + +const DEFAULT_MAX_REDIRECTS = 5 +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]) +const ZIP_MEDIA_TYPES = new Set([ + 'application/octet-stream', + 'application/x-zip', + 'application/x-zip-compressed', + 'application/zip' +]) + +export type SkillArchiveDownloadOptions = Readonly<{ + maxBytes: number + timeoutMs: number + maxRedirects?: number + fetchImpl?: typeof fetch +}> + +function positiveInteger(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} must be positive`) + return value +} + +function nonnegativeInteger(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${name} must be nonnegative`) + return value +} + +function parseHttpUrl(value: string): URL { + let url: URL + try { + url = new URL(value) + } catch { + throw new Error('Skill archive URL is invalid') + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error('Skill archive URL must use HTTP or HTTPS') + } + if (url.username || url.password) { + throw new Error('Skill archive URL must not contain credentials') + } + return url +} + +async function removePartialFile(filePath: string): Promise { + try { + await unlink(filePath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } +} + +async function writeAll(handle: FileHandle, bytes: Buffer, position: number): Promise { + let offset = 0 + while (offset < bytes.length) { + const { bytesWritten } = await handle.write( + bytes, + offset, + bytes.length - offset, + position + offset + ) + if (bytesWritten === 0) throw new Error('Failed to persist Skill archive download') + offset += bytesWritten + } + return position + bytes.length +} + +function declaredContentLength(response: Response, maxBytes: number): number | null { + const raw = response.headers.get('content-length') + if (raw === null) return null + if (!/^(0|[1-9][0-9]*)$/.test(raw)) { + throw new Error('Skill archive Content-Length is invalid') + } + const value = Number(raw) + if (!Number.isSafeInteger(value)) { + throw new Error('Skill archive Content-Length is too large') + } + if (value > maxBytes) { + throw new Error(`Skill archive exceeds the ${maxBytes}-byte download limit`) + } + return value +} + +function assertZipMediaType(response: Response): void { + const raw = response.headers.get('content-type') + if (!raw) return + const mediaType = raw.split(';', 1)[0].trim().toLowerCase() + if (!ZIP_MEDIA_TYPES.has(mediaType)) { + throw new Error('Skill archive response is not a ZIP payload') + } +} + +async function fetchWithSafeRedirects( + initialUrl: URL, + fetchImpl: typeof fetch, + signal: AbortSignal, + maxRedirects: number +): Promise { + let currentUrl = initialUrl + for (let redirectCount = 0; ; redirectCount += 1) { + const response = await fetchImpl(currentUrl, { redirect: 'manual', signal }) + if (!REDIRECT_STATUSES.has(response.status)) return response + if (redirectCount >= maxRedirects) { + await response.body?.cancel().catch(() => undefined) + throw new Error('Skill archive download exceeded the redirect limit') + } + const location = response.headers.get('location') + if (!location) { + await response.body?.cancel().catch(() => undefined) + throw new Error('Skill archive redirect has no destination') + } + const nextUrl = parseHttpUrl(new URL(location, currentUrl).toString()) + if (currentUrl.protocol === 'https:' && nextUrl.protocol !== 'https:') { + await response.body?.cancel().catch(() => undefined) + throw new Error('Skill archive download refused an HTTPS downgrade') + } + await response.body?.cancel().catch(() => undefined) + currentUrl = nextUrl + } +} + +export async function downloadSkillArchive( + url: string, + destinationPath: string, + options: SkillArchiveDownloadOptions +): Promise { + const maxBytes = positiveInteger(options.maxBytes, 'maxBytes') + const timeoutMs = positiveInteger(options.timeoutMs, 'timeoutMs') + const maxRedirects = nonnegativeInteger( + options.maxRedirects ?? DEFAULT_MAX_REDIRECTS, + 'maxRedirects' + ) + const fetchImpl = options.fetchImpl ?? fetch + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + timeout.unref() + let handle: FileHandle | undefined + let completed = false + let response: Response | undefined + + try { + response = await fetchWithSafeRedirects( + parseHttpUrl(url), + fetchImpl, + controller.signal, + maxRedirects + ) + if (!response.ok) { + throw new Error(`Skill archive download failed with HTTP ${response.status}`) + } + assertZipMediaType(response) + const expectedBytes = declaredContentLength(response, maxBytes) + if (!response.body) throw new Error('Skill archive response has no body') + + handle = await open(destinationPath, 'wx', 0o600) + let size = 0 + let position = 0 + for await (const rawChunk of response.body) { + const chunk = Buffer.from(rawChunk) + size += chunk.length + if (size > maxBytes) { + throw new Error(`Skill archive exceeds the ${maxBytes}-byte download limit`) + } + position = await writeAll(handle, chunk, position) + } + if (size === 0) throw new Error('Skill archive download is empty') + const contentEncoding = response.headers.get('content-encoding')?.trim().toLowerCase() + if ( + expectedBytes !== null && + (!contentEncoding || contentEncoding === 'identity') && + size !== expectedBytes + ) { + throw new Error('Skill archive body length does not match Content-Length') + } + await handle.close() + handle = undefined + completed = true + } finally { + clearTimeout(timeout) + await handle?.close().catch(() => undefined) + if (!completed) await response?.body?.cancel().catch(() => undefined) + if (!completed) await removePartialFile(destinationPath) + } +} diff --git a/src/main/skill/index.ts b/src/main/skill/index.ts index 432a03587..70d00e943 100644 --- a/src/main/skill/index.ts +++ b/src/main/skill/index.ts @@ -7,6 +7,7 @@ import { promisify } from 'node:util' import matter from 'gray-matter' import type { SkillSettingsPort } from './settings' import { extractSkillArchive } from './archive' +import { downloadSkillArchive } from './archiveDownload' import { createWatcherRequestId, type IFileWatcherService, @@ -2207,9 +2208,12 @@ export class SkillService implements SkillServicePort { ): Promise { const normalizedAgentId = await this.requireAgentScope(agentId) const finishOperation = this.beginAgentScopeOperation(normalizedAgentId) - const tempZipPath = path.join(app.getPath('temp'), `deepchat-skill-${Date.now()}.zip`) + const tempZipPath = path.join(app.getPath('temp'), `deepchat-skill-${randomUUID()}.zip`) try { - await this.downloadSkillZip(url, tempZipPath) + await downloadSkillArchive(url, tempZipPath, { + maxBytes: SKILL_CONFIG.ZIP_MAX_SIZE, + timeoutMs: SKILL_CONFIG.DOWNLOAD_TIMEOUT + }) const result = await this.installFromZipForAgent(normalizedAgentId, tempZipPath, options) if (result.success && result.skillName) { this.updateSkillManagementItem( @@ -3047,50 +3051,6 @@ export class SkillService implements SkillServicePort { return null } - private async downloadSkillZip(url: string, destPath: string): Promise { - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), SKILL_CONFIG.DOWNLOAD_TIMEOUT) - - try { - const response = await fetch(url, { signal: controller.signal }) - if (!response.ok) { - throw new Error(`Failed to download skill zip: ${response.status} ${response.statusText}`) - } - - // Check Content-Length to prevent memory exhaustion - const contentLength = response.headers.get('content-length') - if (contentLength && parseInt(contentLength) > SKILL_CONFIG.ZIP_MAX_SIZE) { - throw new Error( - `File too large: ${contentLength} bytes (max: ${SKILL_CONFIG.ZIP_MAX_SIZE})` - ) - } - - // Validate Content-Type - const contentType = response.headers.get('content-type') - if ( - contentType && - !contentType.includes('application/zip') && - !contentType.includes('application/octet-stream') && - !contentType.includes('application/x-zip') - ) { - throw new Error(`Expected ZIP file but got: ${contentType}`) - } - - const buffer = new Uint8Array(await response.arrayBuffer()) - - // Double-check actual size after download - if (buffer.length > SKILL_CONFIG.ZIP_MAX_SIZE) { - throw new Error( - `Downloaded file too large: ${buffer.length} bytes (max: ${SKILL_CONFIG.ZIP_MAX_SIZE})` - ) - } - - fs.writeFileSync(destPath, Buffer.from(buffer)) - } finally { - clearTimeout(timeoutId) - } - } - private async cloneGitSkillRepo(repoUrl: string): Promise { const operationRoot = path.join(app.getPath('home'), '.deepchat', 'tmp', 'skill-installs') fs.mkdirSync(operationRoot, { recursive: true }) diff --git a/test/main/skill/archiveDownload.test.ts b/test/main/skill/archiveDownload.test.ts new file mode 100644 index 000000000..97724dc90 --- /dev/null +++ b/test/main/skill/archiveDownload.test.ts @@ -0,0 +1,127 @@ +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { downloadSkillArchive } from '@/skill/archiveDownload' + +const temporaryDirectories: string[] = [] + +async function temporaryDestination(): Promise { + const directory = await mkdtemp(path.join(os.tmpdir(), 'deepchat-skill-download-')) + temporaryDirectories.push(directory) + return path.join(directory, 'skill.zip') +} + +function streamingResponse( + chunks: readonly string[], + headers: Record = {} +): Response { + return new Response( + new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(Buffer.from(chunk)) + controller.close() + } + }), + { status: 200, headers: { 'content-type': 'application/zip', ...headers } } + ) +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })) + ) +}) + +describe('Skill archive download', () => { + it('streams a bounded archive directly to a private destination', async () => { + const destination = await temporaryDestination() + const fetchImpl = vi.fn(async () => + streamingResponse(['zip-', 'bytes'], { 'content-length': '9' }) + ) + + await downloadSkillArchive('https://skills.example/archive.zip', destination, { + maxBytes: 16, + timeoutMs: 5_000, + fetchImpl + }) + + expect(await readFile(destination, 'utf8')).toBe('zip-bytes') + if (process.platform !== 'win32') { + expect((await stat(destination)).mode & 0o777).toBe(0o600) + } + expect(fetchImpl).toHaveBeenCalledWith( + new URL('https://skills.example/archive.zip'), + expect.objectContaining({ redirect: 'manual' }) + ) + }) + + it('enforces the cumulative byte limit and removes partial downloads', async () => { + const destination = await temporaryDestination() + + await expect( + downloadSkillArchive('https://skills.example/archive.zip', destination, { + maxBytes: 8, + timeoutMs: 5_000, + fetchImpl: async () => streamingResponse(['12345', '6789']) + }) + ).rejects.toThrow('8-byte download limit') + await expect(stat(destination)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('rejects invalid or oversized declared lengths before creating a file', async () => { + const invalidDestination = await temporaryDestination() + const oversizedDestination = await temporaryDestination() + + await expect( + downloadSkillArchive('https://skills.example/invalid.zip', invalidDestination, { + maxBytes: 8, + timeoutMs: 5_000, + fetchImpl: async () => streamingResponse(['zip'], { 'content-length': '1e3' }) + }) + ).rejects.toThrow('Content-Length is invalid') + await expect( + downloadSkillArchive('https://skills.example/large.zip', oversizedDestination, { + maxBytes: 8, + timeoutMs: 5_000, + fetchImpl: async () => streamingResponse(['zip'], { 'content-length': '9' }) + }) + ).rejects.toThrow('8-byte download limit') + await expect(stat(invalidDestination)).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(stat(oversizedDestination)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('follows bounded redirects without permitting an HTTPS downgrade', async () => { + const destination = await temporaryDestination() + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + new Response(null, { + status: 302, + headers: { location: 'https://cdn.example/skill.zip' } + }) + ) + .mockResolvedValueOnce(streamingResponse(['zip'])) + + await downloadSkillArchive('https://skills.example/archive.zip', destination, { + maxBytes: 8, + timeoutMs: 5_000, + fetchImpl + }) + expect(fetchImpl).toHaveBeenCalledTimes(2) + + const downgradeDestination = await temporaryDestination() + await expect( + downloadSkillArchive('https://skills.example/archive.zip', downgradeDestination, { + maxBytes: 8, + timeoutMs: 5_000, + fetchImpl: async () => + new Response(null, { + status: 302, + headers: { location: 'http://cdn.example/skill.zip' } + }) + }) + ).rejects.toThrow('HTTPS downgrade') + await expect(stat(downgradeDestination)).rejects.toMatchObject({ code: 'ENOENT' }) + }) +}) From 78d4957e032c3e8167b7a8d676919b9e5f82328f Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 15:26:47 +0800 Subject: [PATCH 19/51] feat(cli): add skill management --- docs/architecture/local-control-plane/spec.md | 2 +- .../architecture/local-control-plane/tasks.md | 2 +- src/cli/args.ts | 94 +++- src/cli/format.ts | 23 + src/main/app/composition.ts | 19 +- src/main/cli/index.ts | 1 + src/main/cli/skillService.ts | 484 ++++++++++++++++++ src/main/cli/surface.ts | 86 ++++ src/main/skill/index.ts | 5 +- src/shared/contracts/routes.ts | 10 + src/shared/contracts/routes/skills.routes.ts | 143 ++++++ src/shared/types/skill.ts | 2 + test/main/cli/args.test.ts | 60 +++ test/main/cli/skillService.test.ts | 331 ++++++++++++ test/main/cli/surface.test.ts | 61 ++- 15 files changed, 1311 insertions(+), 12 deletions(-) create mode 100644 src/main/cli/skillService.ts create mode 100644 test/main/cli/skillService.test.ts diff --git a/docs/architecture/local-control-plane/spec.md b/docs/architecture/local-control-plane/spec.md index 73b8586f2..b66fe8c9f 100644 --- a/docs/architecture/local-control-plane/spec.md +++ b/docs/architecture/local-control-plane/spec.md @@ -244,7 +244,7 @@ confirmation flag. | 6. Full Agent run | `sessions.runDetached`; `deepchat agent run` | compute | H only | never | durable run ID + targeted JSONL | | 7. Settings | `settings.getPublic`, `settings.updatePublic`; `deepchat settings …` | read or key-derived mutation | H; scoped A for allowlisted keys | policy by effect | redacted JSON | | 8. Provider/model administration | `providers.listPublic`, `providers.testPublicConnection`, `providers.addPublic`, `providers.updatePublic`, `providers.remove`, `providers.setCredential`, `models.listRuntime`, `models.setStatus`, `models.getPublicConfig`, `models.setPublicConfig`, `models.resetConfig`; `deepchat provider …`, `deepchat model config …` | read / execution-config / credential / destructive | H; A is read-only | policy for mutations | redacted JSON | -| 9. Skills | `skills.listPublic`, `skills.setDisabled`, `skills.installFromUrl`, `skills.installUpload`, `skills.uninstall`; `deepchat skill …` | read / supply-chain / destructive | H; scoped A may request allowlisted mutations | policy for mutations | JSON | +| 9. Skills | `skills.listPublic`, `skills.setPublicStatus`, `skills.installPublicUrl`, `skills.installUpload`, `skills.uninstallPublic`; `deepchat skill …` | read / execution-config / supply-chain / destructive | H; scoped A may request allowlisted mutations | policy for mutations | JSON | | 10. MCP | `mcp.listPublic`, `mcp.addPublic`, `mcp.updatePublic`, `mcp.remove`, `mcp.setServerEnabled`, `mcp.startServer`, `mcp.stopServer`; `deepchat mcp …` | read / security-config / supply-chain / destructive | H; scoped A may request allowlisted non-credential mutations | policy for mutations | redacted JSON/events | | 11. Runs, events, artifacts | `runs.get`, `runs.cancel`, `events.subscribe`, `artifacts.describe`, `artifacts.read`, `artifacts.delete`; `deepchat run …` | read / local-maintenance | H owns all; A may inspect/pass owned IDs but cannot read bytes, delete, or cancel unrelated work | never | JSONL or binary artifact for H; metadata for A | | 12. CLI diagnostics | `cli.status`, `cli.version`, `cli.capabilities`, `cli.doctor`; top-level commands | read | H, A | never | stable JSON/text | diff --git a/docs/architecture/local-control-plane/tasks.md b/docs/architecture/local-control-plane/tasks.md index 74514cda9..87c8576c5 100644 --- a/docs/architecture/local-control-plane/tasks.md +++ b/docs/architecture/local-control-plane/tasks.md @@ -58,7 +58,7 @@ - [x] Add public/redacted settings reads and allowlisted per-effect updates. - [x] Add public/redacted provider/model reads and separated credential mutations. -- [ ] Add reviewed Skill list/enable/install/uninstall operations. +- [x] Add reviewed Skill list/enable/install/uninstall operations. - [ ] Add reviewed MCP list/add/update/remove/enable/start/stop operations. - [ ] Prove raw MCP calls, arbitrary internal routes, secret reads, and Agent destructive operations are unreachable. diff --git a/src/cli/args.ts b/src/cli/args.ts index 37cabbc39..7624f8603 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -47,12 +47,20 @@ import { settingsGetPublicRoute, settingsUpdatePublicRoute } from '@shared/contracts/routes/settings.routes' +import { + skillsInstallPublicUrlRoute, + skillsInstallUploadRoute, + skillsListPublicRoute, + skillsSetPublicStatusRoute, + skillsUninstallPublicRoute +} from '@shared/contracts/routes/skills.routes' import { JsonValueSchema, type JsonValue } from '@shared/contracts/json' import { LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS } from '@shared/contracts/localControl' import { ATTACHMENT_PDF_OCR_MAX_TOKENS, PDF_PAGE_COUNT_SANITY_LIMIT } from '@shared/types/attachment' +import { SKILL_ARCHIVE_MAX_INPUT_BYTES } from '@shared/types/skill' import path from 'node:path' import { CliUsageError } from './errors' @@ -94,6 +102,11 @@ export type CliRpcContract = | typeof modelsResetConfigRoute | typeof settingsGetPublicRoute | typeof settingsUpdatePublicRoute + | typeof skillsListPublicRoute + | typeof skillsInstallPublicUrlRoute + | typeof skillsInstallUploadRoute + | typeof skillsSetPublicStatusRoute + | typeof skillsUninstallPublicRoute export type CliCommandOperation = 'rpc' | 'stream' | 'upload' | 'download' @@ -143,7 +156,12 @@ const COMMANDS = new Map([ ['model config-set', modelsSetPublicConfigRoute], ['model config-reset', modelsResetConfigRoute], ['settings get', settingsGetPublicRoute], - ['settings set', settingsUpdatePublicRoute] + ['settings set', settingsUpdatePublicRoute], + ['skill list', skillsListPublicRoute], + ['skill install', skillsInstallPublicUrlRoute], + ['skill enable', skillsSetPublicStatusRoute], + ['skill disable', skillsSetPublicStatusRoute], + ['skill remove', skillsUninstallPublicRoute] ]) function parseBoolean(value: string, source: string): boolean { @@ -238,6 +256,8 @@ const VALUE_DOMAIN_OPTIONS: Readonly> = { voice: stringOption, speed: (value) => parseNumberInRange(value, '--speed', 0.25, 4), instructions: stringOption, + agent: stringOption, + url: stringOption, name: stringOption, 'api-type': stringOption, 'base-url': stringOption, @@ -312,7 +332,12 @@ const COMMAND_DOMAIN_OPTIONS = new Map>([ ['model config-set', new Set(['provider', 'model', 'stdin'])], ['model config-reset', new Set(['provider', 'model'])], ['settings get', new Set(['keys'])], - ['settings set', new Set(['key', 'value'])] + ['settings set', new Set(['key', 'value'])], + ['skill list', new Set(['agent'])], + ['skill install', new Set(['agent', 'file', 'url', 'overwrite'])], + ['skill enable', new Set(['agent', 'name'])], + ['skill disable', new Set(['agent', 'name'])], + ['skill remove', new Set(['agent', 'name'])] ]) const AUDIO_MIME_BY_EXTENSION: Readonly> = { @@ -412,7 +437,8 @@ export function parseCliArguments( commandKey === 'audio speak' || commandKey === 'audio transcribe' || commandKey === 'ocr extract' || - commandKey === 'ocr clear-cache' + commandKey === 'ocr clear-cache' || + commandKey === 'skill install' ? DEFAULT_COMPUTE_TIMEOUT_MS : DEFAULT_CLI_TIMEOUT_MS let timeoutSeen = false @@ -545,21 +571,28 @@ export function parseCliArguments( const providerApiType = getString('api-type') const providerBaseUrl = getString('base-url') const providerEnabled = getBoolean('enabled') + const skillAgentId = getString('agent') + const skillUrl = getString('url') + const skillName = getString('name') const parsedSettingKeys = settingKeys ?.split(',') .map((key) => key.trim()) .filter(Boolean) const isArtifactCommand = domain === 'artifact' + const isSkillInstall = commandKey === 'skill install' if (!helpRequested && isArtifactCommand && !artifactId) { throw new CliUsageError(`deepchat ${domain} ${verb} requires --id `) } if (!helpRequested && commandKey === 'artifact get' && !outputPath) { throw new CliUsageError('deepchat artifact get requires --out ') } - if (!isArtifactCommand && (artifactId !== undefined || outputPath !== undefined || overwrite)) { + if (!isArtifactCommand && (artifactId !== undefined || outputPath !== undefined)) { throw new CliUsageError(`Artifact options are not valid for deepchat ${domain} ${verb}`) } + if (overwrite && commandKey !== 'artifact get' && !isSkillInstall) { + throw new CliUsageError(`--overwrite is not valid for deepchat ${domain} ${verb}`) + } if ( isArtifactCommand && commandKey !== 'artifact get' && @@ -589,6 +622,9 @@ export function parseCliArguments( const isModelConfigReset = commandKey === 'model config-reset' const isSettingsGet = commandKey === 'settings get' const isSettingsSet = commandKey === 'settings set' + const isSkillList = commandKey === 'skill list' + const isSkillStatus = commandKey === 'skill enable' || commandKey === 'skill disable' + const isSkillRemove = commandKey === 'skill remove' const allowedDomainOptions = COMMAND_DOMAIN_OPTIONS.get(commandKey) ?? new Set() const invalidDomainOption = Array.from(domainOptions).find( (option) => !allowedDomainOptions.has(option) @@ -682,6 +718,12 @@ export function parseCliArguments( if (!helpRequested && isModelConfigSet && !readStdin) { throw new CliUsageError('deepchat model config-set requires --stdin') } + if (!helpRequested && isSkillInstall && (inputPath !== undefined) === (skillUrl !== undefined)) { + throw new CliUsageError('deepchat skill install requires exactly one of --file or --url') + } + if (!helpRequested && (isSkillStatus || isSkillRemove) && !skillName) { + throw new CliUsageError(`deepchat skill ${verb} requires --name`) + } let params: JsonValue = artifactId ? { id: artifactId } : {} if (isProviderList) params = { enabledOnly } @@ -727,6 +769,34 @@ export function parseCliArguments( if (isSettingsSet && settingKey && domainOptions.has('value')) { params = { changes: [{ key: settingKey, value: settingValue ?? null }] } } + if (isSkillList) params = skillAgentId ? { agentId: skillAgentId } : {} + if (isSkillInstall) { + if (inputPath) { + contract = skillsInstallUploadRoute + params = { + ...(skillAgentId ? { agentId: skillAgentId } : {}), + filename: path.basename(inputPath), + overwrite + } + } else if (skillUrl) { + contract = skillsInstallPublicUrlRoute + params = { + ...(skillAgentId ? { agentId: skillAgentId } : {}), + url: skillUrl, + overwrite + } + } + } + if (isSkillStatus && skillName) { + params = { + ...(skillAgentId ? { agentId: skillAgentId } : {}), + name: skillName, + enabled: commandKey === 'skill enable' + } + } + if (isSkillRemove && skillName) { + params = { ...(skillAgentId ? { agentId: skillAgentId } : {}), name: skillName } + } if (isModelInvoke && providerId && modelId) { params = { providerId, @@ -835,7 +905,7 @@ export function parseCliArguments( operation: commandKey === 'artifact get' ? 'download' - : inputPath && (isAudioTranscribe || isOcrExtract) + : inputPath && (isAudioTranscribe || isOcrExtract || isSkillInstall) ? 'upload' : isModelInvoke || isMediaGenerate ? 'stream' @@ -846,6 +916,7 @@ export function parseCliArguments( ? { uploadMaxBytes: AUDIO_TRANSCRIPTION_MAX_INPUT_BYTES } : {}), ...(isOcrExtract && inputPath ? { uploadMaxBytes: OCR_EXTRACTION_MAX_INPUT_BYTES } : {}), + ...(isSkillInstall && inputPath ? { uploadMaxBytes: SKILL_ARCHIVE_MAX_INPUT_BYTES } : {}), ...(outputPath ? { outputPath } : {}), overwrite, readStdin @@ -887,7 +958,13 @@ export function formatCliHelp(command?: Pick]' : command.domain === 'settings' ? ' --key --value ' - : '' + : command.domain === 'skill' + ? command.verb === 'list' + ? ' [--agent ]' + : command.verb === 'install' + ? ' (--file |--url ) [--agent ] [--overwrite]' + : ' --name [--agent ]' + : '' const commandKey = `${command.domain} ${command.verb}` const optionLines = commandKey === 'model invoke' @@ -974,6 +1051,11 @@ export function formatCliHelp(command?: Pick `${key} = ${JSON.stringify(result.values[key])}`) .join('\n') } + case 'skills.listPublic': { + const result = contract.output.parse(value) + return [ + ...result.skills.map( + (skill) => + `${skill.name} ${skill.enabled ? 'enabled' : 'disabled'} ${skill.managedBy} ${skill.sourceType}${skill.metadataTruncated ? ' metadata-truncated' : ''} ${skill.description}` + ), + ...(result.truncated ? ['Skill list truncated; use --agent to narrow the scope'] : []) + ].join('\n') + } + case 'skills.installPublicUrl': + case 'skills.installUpload': { + const result = contract.output.parse(value) + return `${result.name} installed for ${result.agentId}` + } + case 'skills.setPublicStatus': { + const result = contract.output.parse(value) + return `${result.name} ${result.enabled ? 'enabled' : 'disabled'} for ${result.agentId}` + } + case 'skills.uninstallPublic': { + const result = contract.output.parse(value) + return `${result.name} removed from ${result.agentId}` + } case 'models.invoke': { return contract.output.parse(value).text } diff --git a/src/main/app/composition.ts b/src/main/app/composition.ts index 9cdf60620..8802f19b6 100644 --- a/src/main/app/composition.ts +++ b/src/main/app/composition.ts @@ -222,6 +222,7 @@ import { CliOcrService, CliRequestPolicy, CliServer, + CliSkillService, createArtifactRoutes, createCliComputeRoutes, createCliProviderModelAdminRoutes, @@ -360,6 +361,7 @@ export async function createMainProcessControl(dependencies: { let cliComputeService: CliComputeService let cliAudioTranscriptionService: CliAudioTranscriptionService let cliOcrService: CliOcrService + let cliSkillService: CliSkillService let cliMutationGuard: CliMutationGuard let cliRequestPolicy: CliRequestPolicy let hasInitialized = false @@ -426,6 +428,9 @@ export async function createMainProcessControl(dependencies: { if (cliOcrService?.handlesUpload(method)) { return await cliOcrService.dispatchUpload(method, input, upload, caller, signal) } + if (cliSkillService?.handlesUpload(method)) { + return await cliSkillService.dispatchUpload(method, input, upload, caller, signal) + } throw new Error(`CLI upload service is not ready for ${method}`) }, authorize: async (input) => { @@ -998,6 +1003,16 @@ export async function createMainProcessControl(dependencies: { })) } ) + cliSkillService = new CliSkillService({ + skills: skillService, + agentExists: async (agentId) => (await agentSettings.getAgent(agentId))?.type === 'deepchat', + recordSettingsActivity: (input) => { + void settingsDatabase.recordSettingsActivity(input).catch((error) => { + console.warn('[SettingsActivity] Failed to record CLI Skill activity:', error) + }) + }, + log: logger + }) const agentInvocationAdmission = new AgentInvocationAdmission() const agentToolDependencies: AgentToolDependencies = { @@ -2395,6 +2410,7 @@ export async function createMainProcessControl(dependencies: { }) } }) + const cliSkillRoutes = cliSkillService.createRoutes() routeDispatcher = createRouteDispatcher({ appDatabaseMaintenance: { assertRouteAllowed: (routeName) => assertRouteAllowedDuringDatabaseMaintenance(routeName) @@ -2433,7 +2449,8 @@ export async function createMainProcessControl(dependencies: { cliRoutes, artifactRoutes, cliComputeRoutes, - cliProviderModelAdminRoutes + cliProviderModelAdminRoutes, + cliSkillRoutes ], settingsWindow: windowPresenter, startupWorkloadCoordinator diff --git a/src/main/cli/index.ts b/src/main/cli/index.ts index a4825ed50..82356daba 100644 --- a/src/main/cli/index.ts +++ b/src/main/cli/index.ts @@ -8,6 +8,7 @@ export { type CliAudioTranscriptionServiceOptions } from './audioTranscriptionService' export { CliOcrService, type CliOcrServiceOptions } from './ocrService' +export { CliSkillService, type CliSkillServiceOptions } from './skillService' export { createCliRoutes, type CliRuntimeStatus } from './routes' export { createCliProviderModelAdminRoutes, diff --git a/src/main/cli/skillService.ts b/src/main/cli/skillService.ts new file mode 100644 index 000000000..2ac5ea4ae --- /dev/null +++ b/src/main/cli/skillService.ts @@ -0,0 +1,484 @@ +import { randomUUID } from 'node:crypto' +import { link, unlink } from 'node:fs/promises' +import path from 'node:path' +import { + PUBLIC_SKILL_LIST_MAX_ITEMS, + PublicSkillSchema, + skillsInstallPublicUrlRoute, + skillsInstallUploadRoute, + skillsListPublicRoute, + skillsSetPublicStatusRoute, + skillsUninstallPublicRoute, + type PublicSkill, + type SettingsActivityInput +} from '@shared/contracts/routes' +import type { SkillInstallResult, SkillServicePort } from '@shared/types/skill' +import type { UnifiedSkillItem } from '@shared/types/skillManagement' +import { BUILTIN_SKILL_AGENT_ID } from '@/skill/agentSkillRoots' +import { + createRouteMap, + type CliRouteCaller, + type DeepchatRouteMap, + type RouteCaller +} from '@/routes/routeRegistry' +import { CliRequestError } from './errors' +import type { CliUploadedInputFile } from './server' + +const PUBLIC_SKILL_DESCRIPTION_BYTES = 1024 +const PUBLIC_SKILL_CATEGORY_BYTES = 128 +const PUBLIC_SKILL_PLATFORM_BYTES = 64 +const PUBLIC_SKILL_TOOL_BYTES = 128 +const PUBLIC_SKILL_PLATFORMS = 32 +const PUBLIC_SKILL_TOOLS = 32 +const PUBLIC_TEXT_SCAN_FACTOR = 16 +const PUBLIC_LIST_SCAN_FACTOR = 16 + +type PublicSkillPort = Pick< + SkillServicePort, + | 'getUnifiedSkillCatalog' + | 'installFromUrlForAgent' + | 'installFromZipForAgent' + | 'setSkillDisabledForAgent' + | 'uninstallSkillForAgent' +> + +export type CliSkillServiceOptions = Readonly<{ + skills: PublicSkillPort + agentExists(agentId: string): Promise + recordSettingsActivity?(input: SettingsActivityInput): void + log?: Pick +}> + +function requireCliCaller(caller: RouteCaller): asserts caller is CliRouteCaller { + if (caller.kind !== 'cli') { + throw new CliRequestError('permission_denied', 'Public Skill routes require a CLI caller', { + httpStatus: 403 + }) + } +} + +function requireHumanCliCaller( + caller: RouteCaller +): asserts caller is CliRouteCaller & { principal: 'human' } { + requireCliCaller(caller) + if (caller.principal !== 'human') { + throw new CliRequestError('permission_denied', 'Skill mutation requires a human CLI caller', { + httpStatus: 403 + }) + } +} + +type SanitizedText = Readonly<{ value: string; truncated: boolean }> +type SanitizedList = Readonly<{ values: string[]; truncated: boolean }> + +async function removeFileIfPresent(filePath: string): Promise { + try { + await unlink(filePath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } +} + +async function retainUploadFile(uploadPath: string): Promise> { + const retainedPath = path.join(path.dirname(uploadPath), `body-${randomUUID()}.tmp`) + await link(uploadPath, retainedPath) + return { path: retainedPath } +} + +function compareStableText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} + +function isPublicTextControl(codePoint: number): boolean { + return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f) +} + +function isDirectionalControl(codePoint: number): boolean { + return ( + codePoint === 0x061c || + codePoint === 0x200e || + codePoint === 0x200f || + (codePoint >= 0x202a && codePoint <= 0x202e) || + (codePoint >= 0x2066 && codePoint <= 0x2069) + ) +} + +function sanitizePublicText(value: unknown, maxBytes: number): SanitizedText { + if (typeof value !== 'string') return { value: '', truncated: false } + const output: string[] = [] + let bytes = 0 + let consumedCodeUnits = 0 + let pendingSpace = false + let truncated = false + const maxScannedCodeUnits = maxBytes * PUBLIC_TEXT_SCAN_FACTOR + + for (const character of value) { + consumedCodeUnits += character.length + if (consumedCodeUnits > maxScannedCodeUnits) { + truncated = true + break + } + const codePoint = character.codePointAt(0)! + if (isDirectionalControl(codePoint)) continue + if (isPublicTextControl(codePoint) || character.trim() === '') { + pendingSpace = output.length > 0 + continue + } + + if (pendingSpace) { + if (bytes + 1 > maxBytes) { + truncated = true + break + } + output.push(' ') + bytes += 1 + pendingSpace = false + } + const characterBytes = Buffer.byteLength(character, 'utf8') + if (bytes + characterBytes > maxBytes) { + truncated = true + break + } + output.push(character) + bytes += characterBytes + } + return { + value: output.join(''), + truncated: truncated || consumedCodeUnits < value.length + } +} + +function sanitizePublicStringList( + value: unknown, + maxItems: number, + maxBytes: number +): SanitizedList { + if (!Array.isArray(value)) return { values: [], truncated: false } + let itemTruncated = false + const maxScannedItems = maxItems * PUBLIC_LIST_SCAN_FACTOR + const scannedValues = value.slice(0, maxScannedItems) + const values = Array.from( + new Set( + scannedValues + .map((entry) => { + const sanitized = sanitizePublicText(entry, maxBytes) + itemTruncated ||= sanitized.truncated + return sanitized.value + }) + .filter((entry) => entry.length > 0) + ) + ).sort(compareStableText) + return { + values: values.slice(0, maxItems), + truncated: itemTruncated || value.length > scannedValues.length || values.length > maxItems + } +} + +function toPublicSkill(skill: UnifiedSkillItem): PublicSkill { + const description = sanitizePublicText(skill.description, PUBLIC_SKILL_DESCRIPTION_BYTES) + const category = skill.category + ? sanitizePublicText(skill.category, PUBLIC_SKILL_CATEGORY_BYTES) + : { value: '', truncated: false } + const platforms = sanitizePublicStringList( + skill.platforms, + PUBLIC_SKILL_PLATFORMS, + PUBLIC_SKILL_PLATFORM_BYTES + ) + const allowedTools = sanitizePublicStringList( + skill.allowedTools, + PUBLIC_SKILL_TOOLS, + PUBLIC_SKILL_TOOL_BYTES + ) + return PublicSkillSchema.parse({ + agentId: skill.agentId, + name: skill.name, + description: description.value, + category: category.value || null, + platforms: platforms.values, + allowedTools: allowedTools.values, + sourceType: skill.sourceType, + enabled: !skill.disabled, + mutable: skill.mutable, + managedBy: + skill.ownerPluginId !== undefined + ? 'plugin' + : skill.sourceType === 'builtin' + ? 'deepchat' + : 'user', + metadataTruncated: + description.truncated || category.truncated || platforms.truncated || allowedTools.truncated + }) +} + +export class CliSkillService { + private readonly log: Pick + + constructor(private readonly options: CliSkillServiceOptions) { + this.log = options.log ?? console + } + + createRoutes(): DeepchatRouteMap { + return createRouteMap([ + [ + skillsListPublicRoute.name, + async (rawInput, context) => { + requireCliCaller(context.caller) + const input = skillsListPublicRoute.input.parse(rawInput) + const catalog = [...(await this.loadCatalog(input.agentId))].sort((left, right) => + compareStableText(left.name, right.name) + ) + return skillsListPublicRoute.output.parse({ + skills: catalog.slice(0, PUBLIC_SKILL_LIST_MAX_ITEMS).map(toPublicSkill), + truncated: catalog.length > PUBLIC_SKILL_LIST_MAX_ITEMS + }) + } + ], + [ + skillsInstallPublicUrlRoute.name, + async (rawInput, context) => { + requireHumanCliCaller(context.caller) + const input = skillsInstallPublicUrlRoute.input.parse(rawInput) + await this.requireAgent(input.agentId) + let result: SkillInstallResult + try { + result = await this.options.skills.installFromUrlForAgent(input.agentId, input.url, { + overwrite: input.overwrite + }) + } catch (error) { + throw this.unavailable('install the Skill', error) + } + const name = this.requireInstallSuccess(result) + this.recordActivity('created', input.agentId, name) + return skillsInstallPublicUrlRoute.output.parse({ + agentId: input.agentId, + name, + installed: true + }) + } + ], + [ + skillsSetPublicStatusRoute.name, + async (rawInput, context) => { + requireHumanCliCaller(context.caller) + const input = skillsSetPublicStatusRoute.input.parse(rawInput) + const skill = await this.requireSkill(input.agentId, input.name) + try { + await this.options.skills.setSkillDisabledForAgent( + input.agentId, + skill.name, + !input.enabled + ) + } catch (error) { + throw this.unavailable('update Skill status', error) + } + this.recordActivity(input.enabled ? 'enabled' : 'disabled', input.agentId, skill.name) + return skillsSetPublicStatusRoute.output.parse({ + agentId: input.agentId, + name: skill.name, + enabled: input.enabled + }) + } + ], + [ + skillsUninstallPublicRoute.name, + async (rawInput, context) => { + requireHumanCliCaller(context.caller) + const input = skillsUninstallPublicRoute.input.parse(rawInput) + const skill = await this.requireSkill(input.agentId, input.name) + if (!skill.mutable) { + throw new CliRequestError('conflict', 'Externally managed Skill cannot be removed', { + httpStatus: 409 + }) + } + let result: SkillInstallResult + try { + result = await this.options.skills.uninstallSkillForAgent(input.agentId, skill.name) + } catch (error) { + throw this.unavailable('remove the Skill', error) + } + this.requireUninstallSuccess(result) + this.recordActivity('removed', input.agentId, skill.name) + return skillsUninstallPublicRoute.output.parse({ + agentId: input.agentId, + name: skill.name, + removed: true + }) + } + ] + ]) + } + + handlesUpload(method: string): boolean { + return method === skillsInstallUploadRoute.name + } + + async dispatchUpload( + method: string, + rawInput: unknown, + upload: CliUploadedInputFile, + caller: RouteCaller, + signal: AbortSignal + ): Promise { + requireHumanCliCaller(caller) + if (!this.handlesUpload(method)) { + throw new CliRequestError('not_found', 'Skill upload method is not implemented', { + httpStatus: 404 + }) + } + const input = skillsInstallUploadRoute.input.parse(rawInput) + signal.throwIfAborted() + await this.requireAgent(input.agentId) + signal.throwIfAborted() + let retainedUpload: Readonly<{ path: string }> + try { + retainedUpload = await retainUploadFile(upload.path) + } catch (error) { + throw this.unavailable('retain the Skill upload', error) + } + try { + let result: SkillInstallResult + try { + result = await this.options.skills.installFromZipForAgent( + input.agentId, + retainedUpload.path, + { overwrite: input.overwrite } + ) + } catch (error) { + throw this.unavailable('install the Skill', error) + } + const name = this.requireInstallSuccess(result) + this.recordActivity('created', input.agentId, name) + return skillsInstallUploadRoute.output.parse({ + agentId: input.agentId, + name, + installed: true + }) + } finally { + await removeFileIfPresent(retainedUpload.path).catch((error) => { + this.log.warn('[CLI] Failed to release retained Skill upload', { + failure: { name: error instanceof Error ? error.name : typeof error } + }) + }) + } + } + + private async requireAgent(agentId: string): Promise { + if (agentId === BUILTIN_SKILL_AGENT_ID) return + let exists: boolean + try { + exists = await this.options.agentExists(agentId) + } catch (error) { + throw this.unavailable('resolve the Skill Agent', error) + } + if (!exists) { + throw new CliRequestError('not_found', 'Skill Agent was not found', { httpStatus: 404 }) + } + } + + private async loadCatalog(agentId: string): Promise { + await this.requireAgent(agentId) + try { + return await this.options.skills.getUnifiedSkillCatalog(agentId) + } catch (error) { + throw this.unavailable('read the Skill catalog', error) + } + } + + private async requireSkill(agentId: string, name: string): Promise { + const skill = (await this.loadCatalog(agentId)).find((candidate) => candidate.name === name) + if (!skill) { + throw new CliRequestError('not_found', 'Skill was not found', { httpStatus: 404 }) + } + return skill + } + + private requireInstallSuccess(result: SkillInstallResult): string { + if (result.success && result.skillName) return result.skillName + switch (result.errorCode) { + case 'conflict': + throw new CliRequestError('conflict', 'Skill conflicts with an existing installation', { + httpStatus: 409 + }) + case 'invalid_skill': + throw new CliRequestError('invalid_request', 'Skill archive is invalid') + case 'not_found': + throw new CliRequestError('not_found', 'Skill archive was not found', { httpStatus: 404 }) + case 'target_locked': + throw new CliRequestError('conflict', 'Skill installation target is busy', { + httpStatus: 409, + retriable: true + }) + case 'io_error': + throw new CliRequestError('unavailable', 'Skill installation failed', { + httpStatus: 503, + retriable: true + }) + default: + throw new CliRequestError('internal_error', 'Skill installer returned an invalid result', { + httpStatus: 500 + }) + } + } + + private requireUninstallSuccess(result: SkillInstallResult): void { + if (result.success) return + switch (result.errorCode) { + case 'not_found': + throw new CliRequestError('not_found', 'Skill was not found', { httpStatus: 404 }) + case 'invalid_skill': + throw new CliRequestError('invalid_request', 'Skill name is invalid') + case 'target_locked': + throw new CliRequestError('conflict', 'Skill installation target is busy', { + httpStatus: 409, + retriable: true + }) + case 'io_error': + throw new CliRequestError('unavailable', 'Skill could not be removed', { + httpStatus: 503, + retriable: true + }) + default: + throw new CliRequestError( + 'internal_error', + 'Skill uninstaller returned an invalid result', + { + httpStatus: 500 + } + ) + } + } + + private unavailable(action: string, error: unknown): CliRequestError { + this.log.warn(`[CLI] Failed to ${action}`, { + failure: { name: error instanceof Error ? error.name : typeof error } + }) + return new CliRequestError('unavailable', `Could not ${action}`, { + httpStatus: 503, + retriable: true + }) + } + + private recordActivity( + action: SettingsActivityInput['action'], + agentId: string, + name: string + ): void { + try { + this.options.recordSettingsActivity?.({ + category: 'knowledge', + action, + targetType: 'skill', + targetId: `${agentId}:${name}`, + targetLabel: name, + routeName: 'settings-skills', + routeParams: { agentId }, + summaryKey: 'settings.controlCenter.activity.settingUpdated', + summaryParams: { key: name } + }) + } catch (error) { + this.log.warn('[CLI] Failed to record Skill activity', { + failure: { name: error instanceof Error ? error.name : typeof error } + }) + } + } +} diff --git a/src/main/cli/surface.ts b/src/main/cli/surface.ts index a4637ce2b..ef4ef594d 100644 --- a/src/main/cli/surface.ts +++ b/src/main/cli/surface.ts @@ -32,9 +32,15 @@ import { speechGenerateRoute, settingsGetPublicRoute, settingsUpdatePublicRoute, + skillsInstallPublicUrlRoute, + skillsInstallUploadRoute, + skillsListPublicRoute, + skillsSetPublicStatusRoute, + skillsUninstallPublicRoute, videosGenerateRoute, type CliCapability } from '@shared/contracts/routes' +import { SKILL_ARCHIVE_MAX_INPUT_BYTES } from '@shared/types/skill' import { LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS, type LocalControlEffect, @@ -181,11 +187,34 @@ function selectAuditFields(input: unknown, fields: readonly string[]): Record).url + if (typeof rawUrl !== 'string') return selected + try { + const url = new URL(rawUrl) + return { + ...selected, + origin: url.origin, + path: url.pathname, + queryPresent: url.search.length > 0 + } + } catch { + return selected + } +} + const DIAGNOSTIC_LIMITS = { maxBodyBytes: 16 * 1024, timeoutMs: 5_000 } as const satisfies CliRouteLimits +const APPROVED_MUTATION_LIMITS = { + maxBodyBytes: 16 * 1024, + timeoutMs: 5 * 60_000 +} as const satisfies CliRouteLimits + const diagnosticEntry = (contract: RouteContract): CliSurfaceEntry => ({ contract, effect: 'read', @@ -480,6 +509,63 @@ const CLI_SURFACE_V1_ENTRIES = [ settingChangeKeys(input).every((key) => PREFERENCE_SETTING_KEYS.has(key)), limits: DIAGNOSTIC_LIMITS }, + { + contract: skillsListPublicRoute, + effect: 'read', + callers: ['human'], + scopes: ['skills:read'], + transport: 'rpc', + approval: 'never', + auditProjection: (input) => selectAuditFields(input, ['agentId']), + limits: DIAGNOSTIC_LIMITS + }, + { + contract: skillsInstallPublicUrlRoute, + effect: 'supply-chain', + callers: ['human'], + scopes: ['skills:write'], + transport: 'rpc', + approval: 'policy', + auditProjection: skillUrlDisplay, + approvalDisplay: skillUrlDisplay, + limits: { maxBodyBytes: 16 * 1024, timeoutMs: LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS } + }, + { + contract: skillsInstallUploadRoute, + effect: 'supply-chain', + callers: ['human'], + scopes: ['skills:write'], + transport: 'upload', + approval: 'policy', + auditProjection: (input) => selectAuditFields(input, ['agentId', 'filename', 'overwrite']), + approvalDisplay: (input) => selectAuditFields(input, ['agentId', 'filename', 'overwrite']), + limits: { + maxBodyBytes: SKILL_ARCHIVE_MAX_INPUT_BYTES, + timeoutMs: LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS + } + }, + { + contract: skillsSetPublicStatusRoute, + effect: 'execution-config', + callers: ['human'], + scopes: ['skills:write'], + transport: 'rpc', + approval: 'policy', + auditProjection: (input) => selectAuditFields(input, ['agentId', 'name', 'enabled']), + approvalDisplay: (input) => selectAuditFields(input, ['agentId', 'name', 'enabled']), + limits: APPROVED_MUTATION_LIMITS + }, + { + contract: skillsUninstallPublicRoute, + effect: 'destructive', + callers: ['human'], + scopes: ['skills:write'], + transport: 'rpc', + approval: 'policy', + auditProjection: (input) => selectAuditFields(input, ['agentId', 'name']), + approvalDisplay: (input) => selectAuditFields(input, ['agentId', 'name']), + limits: APPROVED_MUTATION_LIMITS + }, { contract: artifactsDescribeRoute, effect: 'read', diff --git a/src/main/skill/index.ts b/src/main/skill/index.ts index 70d00e943..372742f44 100644 --- a/src/main/skill/index.ts +++ b/src/main/skill/index.ts @@ -43,7 +43,8 @@ import { SkillScriptDescriptor, SkillScriptRuntime, SkillViewResult, - SkillLinkedFile + SkillLinkedFile, + SKILL_ARCHIVE_MAX_INPUT_BYTES } from '@shared/types/skill' import type { AgentSkillManagementState, @@ -75,7 +76,7 @@ export const SKILL_CONFIG = { SKILL_FILE_MAX_SIZE: 5 * 1024 * 1024, // 5MB /** Maximum compressed ZIP input size (bytes) */ - ZIP_MAX_SIZE: 200 * 1024 * 1024, // 200MB + ZIP_MAX_SIZE: SKILL_ARCHIVE_MAX_INPUT_BYTES, /** Download timeout (milliseconds) - prevents hanging connections */ DOWNLOAD_TIMEOUT: 30 * 1000, // 30 seconds diff --git a/src/shared/contracts/routes.ts b/src/shared/contracts/routes.ts index d21a09449..e858b908c 100644 --- a/src/shared/contracts/routes.ts +++ b/src/shared/contracts/routes.ts @@ -493,8 +493,11 @@ import { skillsExecuteAgentImportRoute, skillsInstallFromGitRoute, skillsInstallFromFolderRoute, + skillsInstallPublicUrlRoute, + skillsInstallUploadRoute, skillsInstallFromUrlRoute, skillsInstallFromZipRoute, + skillsListPublicRoute, skillsListCatalogRoute, skillsListAgentImportSourcesRoute, skillsListMetadataRoute, @@ -509,7 +512,9 @@ import { skillsSaveWithExtensionRoute, skillsSetActiveRoute, skillsSetDisabledRoute, + skillsSetPublicStatusRoute, skillsSetSyncDirectoryRoute, + skillsUninstallPublicRoute, skillsUninstallRoute, skillsUpdateFileRoute } from './routes/skills.routes' @@ -1042,10 +1047,13 @@ const DEEPCHAT_ROUTE_CATALOG_PART_5 = { [ocrExtractArtifactRoute.name]: ocrExtractArtifactRoute, [skillsListMetadataRoute.name]: skillsListMetadataRoute, [skillsListCatalogRoute.name]: skillsListCatalogRoute, + [skillsListPublicRoute.name]: skillsListPublicRoute, [skillsGetDirectoryRoute.name]: skillsGetDirectoryRoute, [skillsInstallFromFolderRoute.name]: skillsInstallFromFolderRoute, [skillsInstallFromZipRoute.name]: skillsInstallFromZipRoute, [skillsInstallFromUrlRoute.name]: skillsInstallFromUrlRoute, + [skillsInstallPublicUrlRoute.name]: skillsInstallPublicUrlRoute, + [skillsInstallUploadRoute.name]: skillsInstallUploadRoute, [skillsScanGitRepoRoute.name]: skillsScanGitRepoRoute, [skillsInstallFromGitRoute.name]: skillsInstallFromGitRoute, [skillsGetSyncConfigRoute.name]: skillsGetSyncConfigRoute, @@ -1055,6 +1063,7 @@ const DEEPCHAT_ROUTE_CATALOG_PART_5 = { [skillsPreviewSyncDirectoryImportRoute.name]: skillsPreviewSyncDirectoryImportRoute, [skillsExecuteSyncDirectoryImportRoute.name]: skillsExecuteSyncDirectoryImportRoute, [skillsUninstallRoute.name]: skillsUninstallRoute, + [skillsUninstallPublicRoute.name]: skillsUninstallPublicRoute, [skillsReadFileRoute.name]: skillsReadFileRoute, [skillsUpdateFileRoute.name]: skillsUpdateFileRoute, [skillsSaveWithExtensionRoute.name]: skillsSaveWithExtensionRoute, @@ -1066,6 +1075,7 @@ const DEEPCHAT_ROUTE_CATALOG_PART_5 = { [skillsGetActiveRoute.name]: skillsGetActiveRoute, [skillsSetActiveRoute.name]: skillsSetActiveRoute, [skillsSetDisabledRoute.name]: skillsSetDisabledRoute, + [skillsSetPublicStatusRoute.name]: skillsSetPublicStatusRoute, [skillsListAgentImportSourcesRoute.name]: skillsListAgentImportSourcesRoute, [skillsPreviewAgentImportRoute.name]: skillsPreviewAgentImportRoute, [skillsExecuteAgentImportRoute.name]: skillsExecuteAgentImportRoute, diff --git a/src/shared/contracts/routes/skills.routes.ts b/src/shared/contracts/routes/skills.routes.ts index 79ef2bf60..5557ec14f 100644 --- a/src/shared/contracts/routes/skills.routes.ts +++ b/src/shared/contracts/routes/skills.routes.ts @@ -19,6 +19,149 @@ import type { } from '@shared/types/agentSkillImport' import { EntityIdSchema, defineRouteContract } from '../common' +export const PUBLIC_SKILL_LIST_MAX_ITEMS = 512 + +export const PublicSkillAgentIdSchema = z + .string() + .trim() + .min(1) + .max(255) + .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/) + .refine((value) => !value.includes('..'), { message: 'Agent ID must not contain ..' }) + +export const PublicSkillNameSchema = z + .string() + .trim() + .min(1) + .max(255) + .regex(/^[a-z0-9][a-z0-9._-]*$/) + +export const PublicSkillSchema = z + .object({ + agentId: PublicSkillAgentIdSchema, + name: PublicSkillNameSchema, + description: z.string().max(1024), + category: z.string().max(128).nullable(), + platforms: z.array(z.string().min(1).max(64)).max(32), + allowedTools: z.array(z.string().min(1).max(128)).max(32), + sourceType: z.enum([ + 'builtin', + 'created', + 'folder-install', + 'zip-install', + 'url-install', + 'git-install', + 'adopted', + 'imported' + ]), + enabled: z.boolean(), + mutable: z.boolean(), + managedBy: z.enum(['deepchat', 'plugin', 'user']), + metadataTruncated: z.boolean() + }) + .strict() + +const PublicSkillAgentScopeSchema = z + .object({ + agentId: PublicSkillAgentIdSchema.optional().default('deepchat') + }) + .strict() + +const PublicSkillArchiveFilenameSchema = z + .string() + .trim() + .min(1) + .max(255) + .refine( + (value) => value !== '.' && value !== '..' && !value.includes('/') && !value.includes('\\'), + { message: 'Archive filename must be a basename' } + ) + .refine( + (value) => + !/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/.test(value), + { message: 'Archive filename contains unsafe display characters' } + ) + +const PublicSkillUrlSchema = z + .url() + .max(8192) + .superRefine((value, context) => { + const url = new URL(value) + if (url.protocol !== 'https:') { + context.addIssue({ code: 'custom', message: 'Skill URL must use HTTPS' }) + } + if (url.username || url.password || url.hash) { + context.addIssue({ + code: 'custom', + message: 'Skill URL must not contain credentials or a fragment' + }) + } + }) + +export const skillsListPublicRoute = defineRouteContract({ + name: 'skills.listPublic', + input: PublicSkillAgentScopeSchema, + output: z + .object({ + skills: z.array(PublicSkillSchema).max(PUBLIC_SKILL_LIST_MAX_ITEMS), + truncated: z.boolean() + }) + .strict() +}) + +export const skillsInstallPublicUrlRoute = defineRouteContract({ + name: 'skills.installPublicUrl', + input: PublicSkillAgentScopeSchema.extend({ + url: PublicSkillUrlSchema, + overwrite: z.boolean().optional().default(false) + }), + output: z + .object({ + agentId: PublicSkillAgentIdSchema, + name: PublicSkillNameSchema, + installed: z.literal(true) + }) + .strict() +}) + +export const skillsInstallUploadRoute = defineRouteContract({ + name: 'skills.installUpload', + input: PublicSkillAgentScopeSchema.extend({ + filename: PublicSkillArchiveFilenameSchema, + overwrite: z.boolean().optional().default(false) + }), + output: skillsInstallPublicUrlRoute.output +}) + +export const skillsSetPublicStatusRoute = defineRouteContract({ + name: 'skills.setPublicStatus', + input: PublicSkillAgentScopeSchema.extend({ + name: PublicSkillNameSchema, + enabled: z.boolean() + }), + output: z + .object({ + agentId: PublicSkillAgentIdSchema, + name: PublicSkillNameSchema, + enabled: z.boolean() + }) + .strict() +}) + +export const skillsUninstallPublicRoute = defineRouteContract({ + name: 'skills.uninstallPublic', + input: PublicSkillAgentScopeSchema.extend({ name: PublicSkillNameSchema }), + output: z + .object({ + agentId: PublicSkillAgentIdSchema, + name: PublicSkillNameSchema, + removed: z.literal(true) + }) + .strict() +}) + +export type PublicSkill = z.infer + const SkillMetadataSchema = z.custom() const UnifiedSkillItemSchema = z.custom() const SkillInstallOptionsSchema = z.custom().optional() diff --git a/src/shared/types/skill.ts b/src/shared/types/skill.ts index 24779f0ce..aec3e20d7 100644 --- a/src/shared/types/skill.ts +++ b/src/shared/types/skill.ts @@ -12,6 +12,8 @@ import type { UnifiedSkillItem } from './skillManagement' +export const SKILL_ARCHIVE_MAX_INPUT_BYTES = 200 * 1024 * 1024 + /** * Skill metadata extracted from SKILL.md frontmatter. * Always kept in memory for quick access and semantic matching. diff --git a/test/main/cli/args.test.ts b/test/main/cli/args.test.ts index d4b1e14e3..3a7306954 100644 --- a/test/main/cli/args.test.ts +++ b/test/main/cli/args.test.ts @@ -564,4 +564,64 @@ describe('CLI argument grammar', () => { expect(formatCliHelp({ domain: 'ocr', verb: 'extract' })).toContain('--page-count ') expect(formatCliHelp()).toContain('ocr clear-cache') }) + + it('parses public Skill management without exposing arbitrary paths', () => { + expect(parseCliArguments(['skill', 'list'], {})).toMatchObject({ + operation: 'rpc', + contract: { name: 'skills.listPublic' }, + params: {} + }) + expect( + parseCliArguments( + ['skill', 'install', '--file', './safe-skill.zip', '--agent', 'agent-1', '--overwrite'], + {} + ) + ).toMatchObject({ + operation: 'upload', + contract: { name: 'skills.installUpload' }, + inputPath: './safe-skill.zip', + uploadMaxBytes: 200 * 1024 * 1024, + params: { + agentId: 'agent-1', + filename: 'safe-skill.zip', + overwrite: true + } + }) + expect( + parseCliArguments( + ['skill', 'install', '--url', 'https://skills.example/archive.zip?signature=private'], + {} + ) + ).toMatchObject({ + operation: 'rpc', + contract: { name: 'skills.installPublicUrl' }, + params: { + url: 'https://skills.example/archive.zip?signature=private', + overwrite: false + } + }) + expect(parseCliArguments(['skill', 'disable', '--name', 'safe-skill'], {})).toMatchObject({ + contract: { name: 'skills.setPublicStatus' }, + params: { name: 'safe-skill', enabled: false } + }) + expect(parseCliArguments(['skill', 'remove', '--name', 'safe-skill'], {})).toMatchObject({ + contract: { name: 'skills.uninstallPublic' }, + params: { name: 'safe-skill' } + }) + + expect(() => + parseCliArguments(['skill', 'install', '--file', 'a.zip', '--url', 'https://x'], {}) + ).toThrow('exactly one of --file or --url') + expect(() => parseCliArguments(['skill', 'enable'], {})).toThrow('requires --name') + expect(() => parseCliArguments(['skill', 'list', '--overwrite'], {})).toThrow( + '--overwrite is not valid' + ) + }) + + it('keeps Skill commands discoverable', () => { + expect(formatCliHelp({ domain: 'skill', verb: 'install' })).toContain( + '--file |--url ' + ) + expect(formatCliHelp()).toContain('skill remove') + }) }) diff --git a/test/main/cli/skillService.test.ts b/test/main/cli/skillService.test.ts new file mode 100644 index 000000000..2626490cd --- /dev/null +++ b/test/main/cli/skillService.test.ts @@ -0,0 +1,331 @@ +import { access, mkdtemp, readFile, rm, unlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { + skillsInstallPublicUrlRoute, + skillsInstallUploadRoute, + skillsListPublicRoute, + skillsSetPublicStatusRoute, + skillsUninstallPublicRoute +} from '@shared/contracts/routes' +import type { SkillServicePort } from '@shared/types/skill' +import type { UnifiedSkillItem } from '@shared/types/skillManagement' +import { CliSkillService } from '@/cli/skillService' +import type { CliRouteCaller, RouteContext } from '@/routes/routeRegistry' + +const caller: CliRouteCaller = { + kind: 'cli', + principal: 'human', + connectionId: 'connection-1', + scopes: ['skills:read', 'skills:write'] +} + +function skill(overrides: Partial = {}): UnifiedSkillItem { + return { + agentId: 'deepchat', + name: 'safe-skill', + description: 'Safe\n\u001b[31m description', + path: '/Users/private/.deepchat/skills/safe-skill/SKILL.md', + skillRoot: '/Users/private/.deepchat/skills/safe-skill', + category: 'development', + platforms: ['linux', 'linux', '\u001b[2JmacOS'], + metadata: { token: 'metadata-secret' }, + allowedTools: ['read', 'bash'], + ownerPluginId: 'private-plugin-id', + canonicalPath: '/Users/private/.deepchat/skills/safe-skill', + sourceType: 'zip-install', + disabled: false, + deepchatDisabled: false, + agentLinks: { + codex: { + path: '/Users/private/.codex/skills/safe-skill', + state: 'linked', + createdByDeepChat: true + } + }, + mutable: false, + ...overrides + } +} + +function createHarness(catalog: UnifiedSkillItem[] = [skill()]) { + const getUnifiedSkillCatalog = vi.fn(async () => catalog) + const installFromUrlForAgent = vi.fn(async () => ({ + success: true, + skillName: 'installed-skill', + targetPath: '/private/install/path' + })) + const installFromZipForAgent = vi.fn(async () => ({ + success: true, + skillName: 'uploaded-skill', + targetPath: '/private/upload/path' + })) + const setSkillDisabledForAgent = vi.fn(async () => undefined) + const uninstallSkillForAgent = vi.fn(async () => ({ + success: true, + skillName: 'safe-skill' + })) + const recordSettingsActivity = vi.fn() + const service = new CliSkillService({ + skills: { + getUnifiedSkillCatalog, + installFromUrlForAgent, + installFromZipForAgent, + setSkillDisabledForAgent, + uninstallSkillForAgent + } as Pick< + SkillServicePort, + | 'getUnifiedSkillCatalog' + | 'installFromUrlForAgent' + | 'installFromZipForAgent' + | 'setSkillDisabledForAgent' + | 'uninstallSkillForAgent' + >, + agentExists: async (agentId) => agentId === 'agent-1', + recordSettingsActivity, + log: { warn: vi.fn() } + }) + const routes = service.createRoutes() + const invoke = async (method: string, input: unknown, context: RouteContext = { caller }) => { + const route = routes.get(method as never) + if (!route) throw new Error(`Missing route: ${method}`) + return await route(input, context) + } + return { + service, + getUnifiedSkillCatalog, + installFromUrlForAgent, + installFromZipForAgent, + setSkillDisabledForAgent, + uninstallSkillForAgent, + recordSettingsActivity, + invoke + } +} + +describe('CLI Skill service', () => { + it('accepts signed HTTPS URLs but rejects credentials, fragments, and unsafe filenames', () => { + expect( + skillsInstallPublicUrlRoute.input.safeParse({ + url: 'https://skills.example/archive.zip?signature=private' + }).success + ).toBe(true) + for (const url of [ + 'http://skills.example/archive.zip', + 'https://user:secret@skills.example/archive.zip', + 'https://skills.example/archive.zip#fragment' + ]) { + expect(skillsInstallPublicUrlRoute.input.safeParse({ url }).success).toBe(false) + } + expect( + skillsInstallUploadRoute.input.safeParse({ filename: 'unsafe\u001b[31m.zip' }).success + ).toBe(false) + }) + + it('returns bounded public metadata without filesystem or plugin internals', async () => { + const harness = createHarness() + + const result = await harness.invoke(skillsListPublicRoute.name, {}) + + expect(result).toEqual({ + skills: [ + { + agentId: 'deepchat', + name: 'safe-skill', + description: 'Safe [31m description', + category: 'development', + platforms: ['[2JmacOS', 'linux'], + allowedTools: ['bash', 'read'], + sourceType: 'zip-install', + enabled: true, + mutable: false, + managedBy: 'plugin', + metadataTruncated: false + } + ], + truncated: false + }) + const serialized = JSON.stringify(result) + expect(serialized).not.toContain('/Users/private') + expect(serialized).not.toContain('private-plugin-id') + expect(serialized).not.toContain('metadata-secret') + }) + + it('byte-bounds untrusted metadata and reports truncation', async () => { + const harness = createHarness([ + skill({ + description: '界'.repeat(1_000), + allowedTools: Array.from({ length: 10_000 }, (_, index) => `tool-${index}`) + }) + ]) + + const result = (await harness.invoke(skillsListPublicRoute.name, {})) as { + skills: Array<{ description: string; allowedTools: string[]; metadataTruncated: boolean }> + } + + expect(Buffer.byteLength(result.skills[0].description, 'utf8')).toBeLessThanOrEqual(1024) + expect(result.skills[0].allowedTools).toHaveLength(32) + expect(result.skills[0].metadataTruncated).toBe(true) + }) + + it('bounds scanning of metadata that sanitizes to empty text', async () => { + const harness = createHarness([ + skill({ description: `${'\u0000'.repeat(100_000)}unreachable-tail` }) + ]) + + const result = (await harness.invoke(skillsListPublicRoute.name, {})) as { + skills: Array<{ description: string; metadataTruncated: boolean }> + } + + expect(result.skills[0]).toMatchObject({ description: '', metadataTruncated: true }) + }) + + it('installs HTTPS URLs while returning only a stable public result', async () => { + const harness = createHarness() + const url = 'https://skills.example/archive.zip?signature=private' + + const result = await harness.invoke(skillsInstallPublicUrlRoute.name, { + url, + overwrite: true + }) + + expect(result).toEqual({ + agentId: 'deepchat', + name: 'installed-skill', + installed: true + }) + expect(harness.installFromUrlForAgent).toHaveBeenCalledWith('deepchat', url, { + overwrite: true + }) + expect(JSON.stringify(result)).not.toContain('private') + expect(harness.recordSettingsActivity).toHaveBeenCalledOnce() + }) + + it('maps raw installer failures to stable errors without leaking paths', async () => { + const harness = createHarness() + harness.installFromUrlForAgent.mockResolvedValueOnce({ + success: false, + errorCode: 'io_error', + error: 'EACCES /Users/private/secret-path' + }) + + const failure = await harness + .invoke(skillsInstallPublicUrlRoute.name, { + url: 'https://skills.example/archive.zip' + }) + .catch((error: unknown) => error) + + expect(failure).toMatchObject({ + code: 'unavailable', + message: 'Skill installation failed' + }) + expect(String((failure as Error).message)).not.toContain('/Users/private') + }) + + it('retains an approved upload until installation settles', async () => { + const harness = createHarness() + const signal = new AbortController().signal + const tempDirectory = await mkdtemp(path.join(tmpdir(), 'deepchat-cli-skill-')) + const uploadPath = path.join(tempDirectory, 'body-upload.tmp') + await writeFile(uploadPath, 'archive-bytes') + let retainedPath = '' + let releaseInstall!: () => void + const installReleased = new Promise((resolve) => { + releaseInstall = resolve + }) + const installStarted = new Promise((resolve) => { + harness.installFromZipForAgent.mockImplementationOnce(async (_agentId, zipPath) => { + retainedPath = zipPath + resolve() + await installReleased + return { success: true, skillName: 'uploaded-skill' } + }) + }) + + try { + const operation = harness.service.dispatchUpload( + skillsInstallUploadRoute.name, + { filename: 'skill.zip', overwrite: false }, + { path: uploadPath, size: 13 }, + caller, + signal + ) + await installStarted + expect(retainedPath).not.toBe(uploadPath) + await unlink(uploadPath) + await expect(readFile(retainedPath, 'utf8')).resolves.toBe('archive-bytes') + releaseInstall() + + await expect(operation).resolves.toEqual({ + agentId: 'deepchat', + name: 'uploaded-skill', + installed: true + }) + await expect(access(retainedPath)).rejects.toMatchObject({ code: 'ENOENT' }) + } finally { + releaseInstall() + await rm(tempDirectory, { recursive: true, force: true }) + } + }) + + it('rejects Skill mutations from Agent callers', async () => { + const harness = createHarness() + const agentCaller: CliRouteCaller = { + ...caller, + principal: 'agent', + conversationId: 'conversation-1', + expiresAt: Date.now() + 60_000 + } + + await expect( + harness.invoke(skillsInstallPublicUrlRoute.name, {}, { caller: agentCaller }) + ).rejects.toMatchObject({ code: 'permission_denied' }) + await expect( + harness.service.dispatchUpload( + skillsInstallUploadRoute.name, + { filename: 'skill.zip' }, + { path: '/unused/body.tmp', size: 42 }, + agentCaller, + new AbortController().signal + ) + ).rejects.toMatchObject({ code: 'permission_denied' }) + }) + + it('updates status and refuses removal of externally managed Skills', async () => { + const mutableSkill = skill({ ownerPluginId: undefined, mutable: true }) + const harness = createHarness([mutableSkill]) + + await expect( + harness.invoke(skillsSetPublicStatusRoute.name, { + name: 'safe-skill', + enabled: false + }) + ).resolves.toEqual({ agentId: 'deepchat', name: 'safe-skill', enabled: false }) + expect(harness.setSkillDisabledForAgent).toHaveBeenCalledWith('deepchat', 'safe-skill', true) + await expect( + harness.invoke(skillsUninstallPublicRoute.name, { name: 'safe-skill' }) + ).resolves.toEqual({ agentId: 'deepchat', name: 'safe-skill', removed: true }) + + const managed = createHarness() + await expect( + managed.invoke(skillsUninstallPublicRoute.name, { name: 'safe-skill' }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(managed.uninstallSkillForAgent).not.toHaveBeenCalled() + }) + + it('rejects renderer callers and missing Agent scopes', async () => { + const harness = createHarness() + + await expect( + harness.invoke( + skillsListPublicRoute.name, + {}, + { caller: { kind: 'renderer', webContentsId: 1, windowId: 1 } } + ) + ).rejects.toMatchObject({ code: 'permission_denied' }) + await expect( + harness.invoke(skillsListPublicRoute.name, { agentId: 'missing-agent' }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) +}) diff --git a/test/main/cli/surface.test.ts b/test/main/cli/surface.test.ts index 65c33ae97..78276f74d 100644 --- a/test/main/cli/surface.test.ts +++ b/test/main/cli/surface.test.ts @@ -40,6 +40,11 @@ describe('CLI surface V1', () => { 'providers.updatePublic', 'settings.getPublic', 'settings.updatePublic', + 'skills.installPublicUrl', + 'skills.installUpload', + 'skills.listPublic', + 'skills.setPublicStatus', + 'skills.uninstallPublic', 'speech.generate', 'videos.generate' ]) @@ -126,6 +131,30 @@ describe('CLI surface V1', () => { expect(modelEntry.approvalDisplay?.(modelInput)).toEqual(modelInput) }) + it('keeps signed Skill URL secrets out of approval and audit projections', () => { + const entry = getCliSurfaceEntry('skills.installPublicUrl')! + const input = { + agentId: 'deepchat', + url: 'https://skills.example/archive.zip?signature=private-token', + overwrite: true + } + + expect(entry.approvalDisplay?.(input)).toEqual({ + agentId: 'deepchat', + overwrite: true, + origin: 'https://skills.example', + path: '/archive.zip', + queryPresent: true + }) + expect(JSON.stringify(entry.auditProjection?.(input))).not.toContain('private-token') + expect(getCliSurfaceEntry('skills.setPublicStatus')?.limits.timeoutMs).toBeGreaterThanOrEqual( + 2 * 60_000 + ) + expect(getCliSurfaceEntry('skills.uninstallPublic')?.limits.timeoutMs).toBeGreaterThanOrEqual( + 2 * 60_000 + ) + }) + it('publishes stable sorted capability metadata', () => { expect(listCliSurfaceCapabilities()).toEqual([ expect.objectContaining({ @@ -234,6 +263,32 @@ describe('CLI surface V1', () => { possibleEffects: ['preference-write', 'execution-config', 'security-config'], approval: 'policy' }), + expect.objectContaining({ + method: 'skills.installPublicUrl', + possibleEffects: ['supply-chain'], + callers: ['human'], + approval: 'policy' + }), + expect.objectContaining({ + method: 'skills.installUpload', + possibleEffects: ['supply-chain'], + callers: ['human'], + transport: 'upload', + approval: 'policy' + }), + expect.objectContaining({ method: 'skills.listPublic', possibleEffects: ['read'] }), + expect.objectContaining({ + method: 'skills.setPublicStatus', + possibleEffects: ['execution-config'], + callers: ['human'], + approval: 'policy' + }), + expect.objectContaining({ + method: 'skills.uninstallPublic', + possibleEffects: ['destructive'], + callers: ['human'], + approval: 'policy' + }), expect.objectContaining({ method: 'speech.generate', possibleEffects: ['compute'], @@ -257,7 +312,11 @@ describe('CLI surface V1', () => { 'providers.remove', 'providers.setCredential', 'providers.updatePublic', - 'settings.updatePublic' + 'settings.updatePublic', + 'skills.installPublicUrl', + 'skills.installUpload', + 'skills.setPublicStatus', + 'skills.uninstallPublic' ]) }) }) From 895f919b64c4de6696133f0487ebbbfd52e4feb4 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 15:55:36 +0800 Subject: [PATCH 20/51] feat(cli): add MCP management --- docs/architecture/local-control-plane/spec.md | 2 +- .../architecture/local-control-plane/tasks.md | 4 +- src/cli/args.ts | 124 +++- src/cli/format.ts | 38 ++ src/cli/run.ts | 61 +- src/main/app/composition.ts | 13 +- src/main/cli/index.ts | 1 + src/main/cli/mcpAdminRoutes.ts | 541 ++++++++++++++++++ src/main/cli/publicText.ts | 94 +++ src/main/cli/skillService.ts | 95 +-- src/main/cli/surface.ts | 215 ++++++- src/shared/contracts/routes.ts | 14 + src/shared/contracts/routes/mcp.routes.ts | 398 +++++++++++++ test/main/cli/args.test.ts | 69 +++ test/main/cli/client.test.ts | 93 +++ test/main/cli/mcpAdminRoutes.test.ts | 434 ++++++++++++++ test/main/cli/surface.test.ts | 118 ++++ 17 files changed, 2176 insertions(+), 138 deletions(-) create mode 100644 src/main/cli/mcpAdminRoutes.ts create mode 100644 src/main/cli/publicText.ts create mode 100644 test/main/cli/mcpAdminRoutes.test.ts diff --git a/docs/architecture/local-control-plane/spec.md b/docs/architecture/local-control-plane/spec.md index b66fe8c9f..afa984b79 100644 --- a/docs/architecture/local-control-plane/spec.md +++ b/docs/architecture/local-control-plane/spec.md @@ -245,7 +245,7 @@ confirmation flag. | 7. Settings | `settings.getPublic`, `settings.updatePublic`; `deepchat settings …` | read or key-derived mutation | H; scoped A for allowlisted keys | policy by effect | redacted JSON | | 8. Provider/model administration | `providers.listPublic`, `providers.testPublicConnection`, `providers.addPublic`, `providers.updatePublic`, `providers.remove`, `providers.setCredential`, `models.listRuntime`, `models.setStatus`, `models.getPublicConfig`, `models.setPublicConfig`, `models.resetConfig`; `deepchat provider …`, `deepchat model config …` | read / execution-config / credential / destructive | H; A is read-only | policy for mutations | redacted JSON | | 9. Skills | `skills.listPublic`, `skills.setPublicStatus`, `skills.installPublicUrl`, `skills.installUpload`, `skills.uninstallPublic`; `deepchat skill …` | read / execution-config / supply-chain / destructive | H; scoped A may request allowlisted mutations | policy for mutations | JSON | -| 10. MCP | `mcp.listPublic`, `mcp.addPublic`, `mcp.updatePublic`, `mcp.remove`, `mcp.setServerEnabled`, `mcp.startServer`, `mcp.stopServer`; `deepchat mcp …` | read / security-config / supply-chain / destructive | H; scoped A may request allowlisted non-credential mutations | policy for mutations | redacted JSON/events | +| 10. MCP | `mcp.listPublic`, `mcp.addPublic`, `mcp.updatePublic`, `mcp.removePublic`, `mcp.setPublicStatus`, `mcp.startPublic`, `mcp.stopPublic`; `deepchat mcp …` | read / execution-config / security-config / supply-chain / credential / destructive | H; scoped A may request allowlisted non-credential mutations | policy for mutations | redacted JSON/events | | 11. Runs, events, artifacts | `runs.get`, `runs.cancel`, `events.subscribe`, `artifacts.describe`, `artifacts.read`, `artifacts.delete`; `deepchat run …` | read / local-maintenance | H owns all; A may inspect/pass owned IDs but cannot read bytes, delete, or cancel unrelated work | never | JSONL or binary artifact for H; metadata for A | | 12. CLI diagnostics | `cli.status`, `cli.version`, `cli.capabilities`, `cli.doctor`; top-level commands | read | H, A | never | stable JSON/text | | 13. Benchmark automation | client-side stable modes over compute methods; `--json`, `--jsonl`, stdin, timeout, cancel | inherited | H, scoped A | inherited | reproducible result envelope | diff --git a/docs/architecture/local-control-plane/tasks.md b/docs/architecture/local-control-plane/tasks.md index 87c8576c5..221d66423 100644 --- a/docs/architecture/local-control-plane/tasks.md +++ b/docs/architecture/local-control-plane/tasks.md @@ -59,8 +59,8 @@ - [x] Add public/redacted settings reads and allowlisted per-effect updates. - [x] Add public/redacted provider/model reads and separated credential mutations. - [x] Add reviewed Skill list/enable/install/uninstall operations. -- [ ] Add reviewed MCP list/add/update/remove/enable/start/stop operations. -- [ ] Prove raw MCP calls, arbitrary internal routes, secret reads, and Agent destructive operations are +- [x] Add reviewed MCP list/add/update/remove/enable/start/stop operations. +- [x] Prove raw MCP calls, arbitrary internal routes, secret reads, and Agent destructive operations are unreachable. ## Events and Agent Runs diff --git a/src/cli/args.ts b/src/cli/args.ts index 7624f8603..9b75187f7 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -28,6 +28,15 @@ import { speechGenerateRoute, videosGenerateRoute } from '@shared/contracts/routes/media.routes' +import { + mcpAddPublicRoute, + mcpListPublicRoute, + mcpRemovePublicRoute, + mcpSetPublicStatusRoute, + mcpStartPublicRoute, + mcpStopPublicRoute, + mcpUpdatePublicRoute +} from '@shared/contracts/routes/mcp.routes' import { providersAddPublicRoute, providersListPublicRoute, @@ -69,6 +78,7 @@ export const CLI_TIMEOUT_ENV = 'DEEPCHAT_CLI_TIMEOUT_MS' export const DEFAULT_CLI_TIMEOUT_MS = 30_000 export const MAX_CLI_TIMEOUT_MS = LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS export const DEFAULT_COMPUTE_TIMEOUT_MS = MAX_CLI_TIMEOUT_MS +export const DEFAULT_MUTATION_TIMEOUT_MS = 10 * 60_000 export type CliOutputMode = 'text' | 'json' | 'jsonl' export type CliRpcContract = @@ -107,6 +117,13 @@ export type CliRpcContract = | typeof skillsInstallUploadRoute | typeof skillsSetPublicStatusRoute | typeof skillsUninstallPublicRoute + | typeof mcpListPublicRoute + | typeof mcpAddPublicRoute + | typeof mcpUpdatePublicRoute + | typeof mcpRemovePublicRoute + | typeof mcpSetPublicStatusRoute + | typeof mcpStartPublicRoute + | typeof mcpStopPublicRoute export type CliCommandOperation = 'rpc' | 'stream' | 'upload' | 'download' @@ -161,7 +178,50 @@ const COMMANDS = new Map([ ['skill install', skillsInstallPublicUrlRoute], ['skill enable', skillsSetPublicStatusRoute], ['skill disable', skillsSetPublicStatusRoute], - ['skill remove', skillsUninstallPublicRoute] + ['skill remove', skillsUninstallPublicRoute], + ['mcp list', mcpListPublicRoute], + ['mcp add', mcpAddPublicRoute], + ['mcp update', mcpUpdatePublicRoute], + ['mcp enable', mcpSetPublicStatusRoute], + ['mcp disable', mcpSetPublicStatusRoute], + ['mcp start', mcpStartPublicRoute], + ['mcp stop', mcpStopPublicRoute], + ['mcp remove', mcpRemovePublicRoute] +]) + +const LONG_RUNNING_COMMANDS = new Set([ + 'artifact get', + 'model invoke', + 'image generate', + 'video generate', + 'audio speak', + 'audio transcribe', + 'ocr extract', + 'ocr clear-cache', + 'skill install' +]) + +const APPROVED_MUTATION_COMMANDS = new Set([ + 'provider add', + 'provider update', + 'provider set-credential', + 'provider clear-credential', + 'provider remove', + 'model enable', + 'model disable', + 'model config-set', + 'model config-reset', + 'settings set', + 'skill enable', + 'skill disable', + 'skill remove', + 'mcp add', + 'mcp update', + 'mcp enable', + 'mcp disable', + 'mcp start', + 'mcp stop', + 'mcp remove' ]) function parseBoolean(value: string, source: string): boolean { @@ -337,7 +397,15 @@ const COMMAND_DOMAIN_OPTIONS = new Map>([ ['skill install', new Set(['agent', 'file', 'url', 'overwrite'])], ['skill enable', new Set(['agent', 'name'])], ['skill disable', new Set(['agent', 'name'])], - ['skill remove', new Set(['agent', 'name'])] + ['skill remove', new Set(['agent', 'name'])], + ['mcp list', new Set()], + ['mcp add', new Set(['name', 'stdin'])], + ['mcp update', new Set(['name', 'stdin'])], + ['mcp enable', new Set(['name'])], + ['mcp disable', new Set(['name'])], + ['mcp start', new Set(['name'])], + ['mcp stop', new Set(['name'])], + ['mcp remove', new Set(['name'])] ]) const AUDIO_MIME_BY_EXTENSION: Readonly> = { @@ -431,16 +499,11 @@ export function parseCliArguments( let explicitOutputMode: CliOutputMode | undefined let timeoutMs = env[CLI_TIMEOUT_ENV] ? parseTimeout(env[CLI_TIMEOUT_ENV], CLI_TIMEOUT_ENV) - : commandKey === 'model invoke' || - commandKey === 'image generate' || - commandKey === 'video generate' || - commandKey === 'audio speak' || - commandKey === 'audio transcribe' || - commandKey === 'ocr extract' || - commandKey === 'ocr clear-cache' || - commandKey === 'skill install' + : LONG_RUNNING_COMMANDS.has(commandKey) ? DEFAULT_COMPUTE_TIMEOUT_MS - : DEFAULT_CLI_TIMEOUT_MS + : APPROVED_MUTATION_COMMANDS.has(commandKey) + ? DEFAULT_MUTATION_TIMEOUT_MS + : DEFAULT_CLI_TIMEOUT_MS let timeoutSeen = false let helpRequested = false const domainOptions = new Set() @@ -574,6 +637,7 @@ export function parseCliArguments( const skillAgentId = getString('agent') const skillUrl = getString('url') const skillName = getString('name') + const mcpServerName = getString('name') const parsedSettingKeys = settingKeys ?.split(',') .map((key) => key.trim()) @@ -625,6 +689,12 @@ export function parseCliArguments( const isSkillList = commandKey === 'skill list' const isSkillStatus = commandKey === 'skill enable' || commandKey === 'skill disable' const isSkillRemove = commandKey === 'skill remove' + const isMcpList = commandKey === 'mcp list' + const isMcpAdd = commandKey === 'mcp add' + const isMcpUpdate = commandKey === 'mcp update' + const isMcpStatus = commandKey === 'mcp enable' || commandKey === 'mcp disable' + const isMcpRuntime = commandKey === 'mcp start' || commandKey === 'mcp stop' + const isMcpRemove = commandKey === 'mcp remove' const allowedDomainOptions = COMMAND_DOMAIN_OPTIONS.get(commandKey) ?? new Set() const invalidDomainOption = Array.from(domainOptions).find( (option) => !allowedDomainOptions.has(option) @@ -724,6 +794,16 @@ export function parseCliArguments( if (!helpRequested && (isSkillStatus || isSkillRemove) && !skillName) { throw new CliUsageError(`deepchat skill ${verb} requires --name`) } + if ( + !helpRequested && + (isMcpAdd || isMcpUpdate || isMcpStatus || isMcpRuntime || isMcpRemove) && + !mcpServerName + ) { + throw new CliUsageError(`deepchat mcp ${verb} requires --name`) + } + if (!helpRequested && (isMcpAdd || isMcpUpdate) && !readStdin) { + throw new CliUsageError(`deepchat mcp ${verb} requires --stdin`) + } let params: JsonValue = artifactId ? { id: artifactId } : {} if (isProviderList) params = { enabledOnly } @@ -797,6 +877,12 @@ export function parseCliArguments( if (isSkillRemove && skillName) { params = { ...(skillAgentId ? { agentId: skillAgentId } : {}), name: skillName } } + if (isMcpList) params = {} + if ((isMcpAdd || isMcpUpdate) && mcpServerName) params = { serverName: mcpServerName } + if (isMcpStatus && mcpServerName) { + params = { serverName: mcpServerName, enabled: commandKey === 'mcp enable' } + } + if ((isMcpRuntime || isMcpRemove) && mcpServerName) params = { serverName: mcpServerName } if (isModelInvoke && providerId && modelId) { params = { providerId, @@ -964,7 +1050,13 @@ export function formatCliHelp(command?: Pick|--url ) [--agent ] [--overwrite]' : ' --name [--agent ]' - : '' + : command.domain === 'mcp' + ? command.verb === 'list' + ? '' + : command.verb === 'add' || command.verb === 'update' + ? ' --name --stdin' + : ' --name ' + : '' const commandKey = `${command.domain} ${command.verb}` const optionLines = commandKey === 'model invoke' @@ -1056,6 +1148,14 @@ export function formatCliHelp(command?: Pick + `${server.name} ${server.type} ${server.enabled ? 'enabled' : 'disabled'} ${server.running === null ? 'runtime-unknown' : server.running ? 'running' : 'stopped'} ${server.managedBy}${server.metadataTruncated ? ' metadata-truncated' : ''} ${server.description}` + ), + ...(result.truncated ? ['MCP server list truncated'] : []) + ].join('\n') + } + case 'mcp.addPublic': { + const result = contract.output.parse(value) + return `${result.server.name} added; ${result.server.enabled ? 'enabled' : 'disabled'}; runtime ${formatMcpRuntime(result.server.running)}` + } + case 'mcp.updatePublic': { + const result = contract.output.parse(value) + return `${result.server.name} updated; runtime ${formatMcpRuntime(result.server.running)}` + } + case 'mcp.removePublic': { + const result = contract.output.parse(value) + return `${result.serverName} removed` + } + case 'mcp.setPublicStatus': { + const result = contract.output.parse(value) + return `${result.server.name} ${result.server.enabled ? 'enabled' : 'disabled'}; runtime ${formatMcpRuntime(result.server.running)}` + } + case 'mcp.startPublic': { + const result = contract.output.parse(value) + return `${result.server.name} start requested; runtime ${formatMcpRuntime(result.server.running)}` + } + case 'mcp.stopPublic': { + const result = contract.output.parse(value) + return `${result.server.name} stop requested; runtime ${formatMcpRuntime(result.server.running)}` + } case 'models.invoke': { return contract.output.parse(value).text } diff --git a/src/cli/run.ts b/src/cli/run.ts index d909e5905..fbb007857 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto' -import type { JsonValue } from '@shared/contracts/json' +import { JsonValueSchema, type JsonValue } from '@shared/contracts/json' import { LOCAL_CONTROL_AGENT_TOKEN_ENV, createLocalControlFailure, @@ -9,6 +9,7 @@ import { import { artifactsDescribeRoute } from '@shared/contracts/routes/artifacts.routes' import { MediaGenerationEventSchema } from '@shared/contracts/routes/media.routes' import { ModelInvokeEventSchema } from '@shared/contracts/routes/models.routes' +import { PUBLIC_MCP_CONFIG_MAX_BYTES } from '@shared/contracts/routes/mcp.routes' import { PROVIDER_CREDENTIAL_MAX_BYTES } from '@shared/contracts/routes/providers.routes' import { parseCliArguments, formatCliHelp, inferCliOutputMode, type CliOutputMode } from './args' import { @@ -62,6 +63,33 @@ function writeText(output: WritableOutput, value: string): void { output.write(value.endsWith('\n') ? value : `${value}\n`) } +function parseStdinJsonObject(input: string, label: string): Record { + let candidate: unknown + try { + candidate = JSON.parse(input) as unknown + } catch { + throw new CliClientError( + 'invalid_request', + `${label} stdin must be valid JSON`, + CLI_EXIT_CODES.usage + ) + } + const parsed = JsonValueSchema.safeParse(candidate) + if ( + !parsed.success || + !parsed.data || + typeof parsed.data !== 'object' || + Array.isArray(parsed.data) + ) { + throw new CliClientError( + 'invalid_request', + `${label} stdin must be a JSON object`, + CLI_EXIT_CODES.usage + ) + } + return parsed.data +} + function writeClientError( error: CliClientError, outputMode: CliOutputMode, @@ -178,7 +206,9 @@ export async function runCli( controller.signal, parsed.contract.name === 'providers.setCredential' ? PROVIDER_CREDENTIAL_MAX_BYTES - : undefined + : parsed.contract.name === 'mcp.addPublic' || parsed.contract.name === 'mcp.updatePublic' + ? PUBLIC_MCP_CONFIG_MAX_BYTES + : undefined ) if (!params || typeof params !== 'object' || Array.isArray(params)) { throw new CliClientError( @@ -204,24 +234,15 @@ export async function runCli( params = { ...params, value: input.replace(/(?:\r\n|\n)$/, '') } break case 'models.setPublicConfig': { - let config: unknown - try { - config = JSON.parse(input) as unknown - } catch { - throw new CliClientError( - 'invalid_request', - 'Model configuration stdin must be valid JSON', - CLI_EXIT_CODES.usage - ) - } - if (!config || typeof config !== 'object' || Array.isArray(config)) { - throw new CliClientError( - 'invalid_request', - 'Model configuration stdin must be a JSON object', - CLI_EXIT_CODES.usage - ) - } - params = { ...params, config: config as JsonValue } + params = { ...params, config: parseStdinJsonObject(input, 'Model configuration') } + break + } + case 'mcp.addPublic': { + params = { ...params, config: parseStdinJsonObject(input, 'MCP configuration') } + break + } + case 'mcp.updatePublic': { + params = { ...params, updates: parseStdinJsonObject(input, 'MCP update') } break } default: diff --git a/src/main/app/composition.ts b/src/main/app/composition.ts index 8802f19b6..c4cfe38ae 100644 --- a/src/main/app/composition.ts +++ b/src/main/app/composition.ts @@ -225,6 +225,7 @@ import { CliSkillService, createArtifactRoutes, createCliComputeRoutes, + createCliMcpAdminRoutes, createCliProviderModelAdminRoutes, createCliRoutes } from '@/cli' @@ -2411,6 +2412,15 @@ export async function createMainProcessControl(dependencies: { } }) const cliSkillRoutes = cliSkillService.createRoutes() + const cliMcpAdminRoutes = createCliMcpAdminRoutes({ + mcp: mcpService, + recordSettingsActivity: (input) => { + void settingsDatabase.recordSettingsActivity(input).catch((error) => { + console.warn('[SettingsActivity] Failed to record CLI MCP activity:', error) + }) + }, + log: logger + }) routeDispatcher = createRouteDispatcher({ appDatabaseMaintenance: { assertRouteAllowed: (routeName) => assertRouteAllowedDuringDatabaseMaintenance(routeName) @@ -2450,7 +2460,8 @@ export async function createMainProcessControl(dependencies: { artifactRoutes, cliComputeRoutes, cliProviderModelAdminRoutes, - cliSkillRoutes + cliSkillRoutes, + cliMcpAdminRoutes ], settingsWindow: windowPresenter, startupWorkloadCoordinator diff --git a/src/main/cli/index.ts b/src/main/cli/index.ts index 82356daba..5de71d42e 100644 --- a/src/main/cli/index.ts +++ b/src/main/cli/index.ts @@ -8,6 +8,7 @@ export { type CliAudioTranscriptionServiceOptions } from './audioTranscriptionService' export { CliOcrService, type CliOcrServiceOptions } from './ocrService' +export { createCliMcpAdminRoutes, type CliMcpAdminDependencies } from './mcpAdminRoutes' export { CliSkillService, type CliSkillServiceOptions } from './skillService' export { createCliRoutes, type CliRuntimeStatus } from './routes' export { diff --git a/src/main/cli/mcpAdminRoutes.ts b/src/main/cli/mcpAdminRoutes.ts new file mode 100644 index 000000000..f5039e73f --- /dev/null +++ b/src/main/cli/mcpAdminRoutes.ts @@ -0,0 +1,541 @@ +import path from 'node:path' +import { + PUBLIC_MCP_LIST_MAX_ITEMS, + PublicMcpServerNameSchema, + PublicMcpServerSchema, + mcpAddPublicRoute, + mcpListPublicRoute, + mcpRemovePublicRoute, + mcpSetPublicStatusRoute, + mcpStartPublicRoute, + mcpStopPublicRoute, + mcpUpdatePublicRoute, + type PublicMcpServer, + type PublicMcpServerConfigInput, + type PublicMcpServerUpdate, + type SettingsActivityInput +} from '@shared/contracts/routes' +import type { MCPServerConfig, McpServicePort } from '@shared/types/mcp' +import { createRouteMap, type DeepchatRouteMap, type RouteCaller } from '@/routes/routeRegistry' +import { CliRequestError } from './errors' +import { compareStableText, sanitizePublicText } from './publicText' + +const PUBLIC_MCP_DESCRIPTION_BYTES = 1024 +const PUBLIC_MCP_COMMAND_NAME_BYTES = 256 + +type PublicMcpPort = Pick< + McpServicePort, + | 'addMcpServer' + | 'getMcpServers' + | 'isServerRunning' + | 'removeMcpServer' + | 'setMcpServerEnabled' + | 'startServer' + | 'stopServer' + | 'updateMcpServer' +> + +export type CliMcpAdminDependencies = Readonly<{ + mcp: PublicMcpPort + recordSettingsActivity?(input: SettingsActivityInput): void + log?: Pick +}> + +function requireHumanCliCaller(caller: RouteCaller): void { + if (caller.kind !== 'cli' || caller.principal !== 'human') { + throw new CliRequestError( + 'permission_denied', + 'MCP administration requires a human CLI caller', + { + httpStatus: 403 + } + ) + } +} + +function isPluginOwned(config: MCPServerConfig): boolean { + return Boolean(config.ownerPluginId || config.source === 'plugin') +} + +function boundedCount(value: number): Readonly<{ value: number; truncated: boolean }> { + return { + value: Math.min(value, 1_000_000), + truncated: value > 1_000_000 + } +} + +function commandBasename(command: string): string { + return path.posix.basename(path.win32.basename(command)) +} + +function endpointSummary(baseUrl: string | undefined): { + value: PublicMcpServer['endpoint'] + truncated: boolean +} { + if (!baseUrl) return { value: null, truncated: false } + try { + const url = new URL(baseUrl) + return { + value: { + origin: url.origin, + pathPresent: url.pathname !== '/' || Boolean(url.search) + }, + truncated: false + } + } catch { + return { value: null, truncated: true } + } +} + +function toPublicMcpServer( + serverName: string, + config: MCPServerConfig, + running: boolean | null +): PublicMcpServer | null { + const parsedName = PublicMcpServerNameSchema.safeParse(serverName) + if (!parsedName.success) return null + + const description = sanitizePublicText(config.descriptions, PUBLIC_MCP_DESCRIPTION_BYTES) + const rawCommandName = + config.type === 'stdio' && typeof config.command === 'string' + ? commandBasename(config.command) + : '' + const commandName = rawCommandName + ? sanitizePublicText(rawCommandName, PUBLIC_MCP_COMMAND_NAME_BYTES) + : { value: '', truncated: false } + const endpoint = endpointSummary(config.baseUrl) + const argumentCount = boundedCount(Array.isArray(config.args) ? config.args.length : 0) + const environmentEntryCount = boundedCount( + config.env && typeof config.env === 'object' ? Object.keys(config.env).length : 0 + ) + const headerEntryCount = boundedCount( + config.customHeaders && typeof config.customHeaders === 'object' + ? Object.keys(config.customHeaders).length + : 0 + ) + const pluginOwned = isPluginOwned(config) + + const parsed = PublicMcpServerSchema.safeParse({ + name: parsedName.data, + type: config.type, + enabled: config.enabled, + running, + managedBy: pluginOwned ? 'plugin' : config.type === 'inmemory' ? 'deepchat' : 'user', + editable: !pluginOwned && config.type !== 'inmemory', + removable: !pluginOwned && config.type !== 'inmemory', + description: description.value, + commandName: commandName.value || null, + endpoint: endpoint.value, + argumentCount: argumentCount.value, + environmentEntryCount: environmentEntryCount.value, + headerEntryCount: headerEntryCount.value, + authorizationMode: config.authorization?.mode ?? null, + metadataTruncated: + description.truncated || + commandName.truncated || + endpoint.truncated || + argumentCount.truncated || + environmentEntryCount.truncated || + headerEntryCount.truncated + }) + return parsed.success ? parsed.data : null +} + +function toStoredConfig(input: PublicMcpServerConfigInput): MCPServerConfig { + const common = { + descriptions: input.description, + icons: input.icon, + enabled: false, + type: input.type + } as const + if (input.type === 'stdio') { + return { + ...common, + command: input.command, + args: input.args, + env: input.environment, + inheritEnv: input.inheritEnv, + ...(input.customNpmRegistry ? { customNpmRegistry: input.customNpmRegistry } : {}) + } + } + return { + ...common, + command: '', + args: [], + env: {}, + baseUrl: input.baseUrl, + customHeaders: input.headers, + ...(input.type === 'http' && input.authorization ? { authorization: input.authorization } : {}) + } +} + +function hasOwn(value: object, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key) +} + +function toStoredUpdate( + current: MCPServerConfig, + updates: PublicMcpServerUpdate +): Partial { + const targetType = updates.type ?? current.type + if (targetType === 'inmemory') { + throw new CliRequestError( + 'conflict', + 'In-memory MCP servers cannot be edited through the CLI', + { + httpStatus: 409 + } + ) + } + + const stdioOnlyFields = ['command', 'args', 'environment', 'inheritEnv', 'customNpmRegistry'] + const remoteOnlyFields = ['baseUrl', 'headers', 'authorization'] + const invalidField = (targetType === 'stdio' ? remoteOnlyFields : stdioOnlyFields).find((field) => + hasOwn(updates, field) + ) + if (invalidField) { + throw new CliRequestError( + 'invalid_request', + `${invalidField} is not valid for MCP transport ${targetType}` + ) + } + if (targetType === 'sse' && hasOwn(updates, 'authorization')) { + throw new CliRequestError('invalid_request', 'authorization is only valid for HTTP MCP servers') + } + + const typeChanged = targetType !== current.type + if (typeChanged && targetType === 'stdio' && updates.command === undefined) { + throw new CliRequestError('invalid_request', 'Changing an MCP server to stdio requires command') + } + if (typeChanged && targetType !== 'stdio' && updates.baseUrl === undefined) { + throw new CliRequestError( + 'invalid_request', + 'Changing an MCP server to a remote transport requires baseUrl' + ) + } + + const stored: Partial = { + ...(updates.description !== undefined ? { descriptions: updates.description } : {}), + ...(updates.icon !== undefined ? { icons: updates.icon } : {}) + } + if (typeChanged) { + stored.type = targetType + if (targetType === 'stdio') { + stored.command = updates.command! + stored.args = updates.args ?? [] + stored.env = updates.environment ?? {} + stored.inheritEnv = updates.inheritEnv ?? 'minimal' + stored.customNpmRegistry = updates.customNpmRegistry ?? undefined + stored.baseUrl = undefined + stored.customHeaders = undefined + stored.authorization = undefined + } else { + stored.command = '' + stored.args = [] + stored.env = {} + stored.inheritEnv = undefined + stored.customNpmRegistry = undefined + stored.baseUrl = updates.baseUrl! + stored.customHeaders = updates.headers ?? {} + stored.authorization = updates.authorization ?? undefined + } + return stored + } + + if (updates.command !== undefined) stored.command = updates.command + if (updates.args !== undefined) stored.args = updates.args + if (updates.environment !== undefined) stored.env = updates.environment + if (updates.inheritEnv !== undefined) stored.inheritEnv = updates.inheritEnv + if (hasOwn(updates, 'customNpmRegistry')) { + stored.customNpmRegistry = updates.customNpmRegistry ?? undefined + } + if (updates.baseUrl !== undefined) stored.baseUrl = updates.baseUrl + if (updates.headers !== undefined) stored.customHeaders = updates.headers + if (hasOwn(updates, 'authorization')) { + stored.authorization = updates.authorization ?? undefined + } + return stored +} + +export function createCliMcpAdminRoutes(dependencies: CliMcpAdminDependencies): DeepchatRouteMap { + const log = dependencies.log ?? console + const unavailable = (action: string, error: unknown): CliRequestError => { + log.warn(`[CLI] Failed to ${action}`, { + failure: { name: error instanceof Error ? error.name : typeof error } + }) + return new CliRequestError('unavailable', `Could not ${action}`, { + httpStatus: 503, + retriable: true + }) + } + const loadServers = async (): Promise> => { + try { + return await dependencies.mcp.getMcpServers() + } catch (error) { + throw unavailable('read MCP servers', error) + } + } + const requireUserServer = async ( + serverName: string + ): Promise> => { + const config = (await loadServers())[serverName] + if (!config) { + throw new CliRequestError('not_found', 'MCP server was not found', { httpStatus: 404 }) + } + if (isPluginOwned(config)) { + throw new CliRequestError('conflict', 'Plugin-owned MCP server cannot be edited', { + httpStatus: 409 + }) + } + if (config.type === 'inmemory') { + throw new CliRequestError('conflict', 'In-memory MCP server cannot be edited', { + httpStatus: 409 + }) + } + if (config.type !== 'stdio' && config.type !== 'sse' && config.type !== 'http') { + throw new CliRequestError('conflict', 'MCP server uses an unsupported transport', { + httpStatus: 409 + }) + } + return { config } + } + const readRunning = async (serverName: string): Promise => { + try { + return await dependencies.mcp.isServerRunning(serverName) + } catch { + return null + } + } + const summarizeServer = async ( + serverName: string, + config: MCPServerConfig + ): Promise => { + const server = toPublicMcpServer(serverName, config, await readRunning(serverName)) + if (!server) { + throw new CliRequestError('internal_error', 'MCP server has invalid public metadata', { + httpStatus: 500 + }) + } + return server + } + const recordActivity = ( + action: SettingsActivityInput['action'], + serverName: string, + summaryKey: string + ): void => { + try { + dependencies.recordSettingsActivity?.({ + category: 'mcp', + action, + targetType: 'mcp-server', + targetId: serverName, + targetLabel: serverName, + routeName: 'settings-mcp', + summaryKey, + summaryParams: { name: serverName } + }) + } catch (error) { + log.warn('[CLI] Failed to record MCP activity', { + failure: { name: error instanceof Error ? error.name : typeof error } + }) + } + } + + return createRouteMap([ + [ + mcpListPublicRoute.name, + async (rawInput, context) => { + requireHumanCliCaller(context.caller) + mcpListPublicRoute.input.parse(rawInput) + const entries = Object.entries(await loadServers()) + const selected = entries + .filter(([serverName]) => PublicMcpServerNameSchema.safeParse(serverName).success) + .sort(([left], [right]) => compareStableText(left, right)) + .slice(0, PUBLIC_MCP_LIST_MAX_ITEMS) + const servers = await Promise.all( + selected.map(async ([serverName, config]) => { + return toPublicMcpServer(serverName, config, await readRunning(serverName)) + }) + ) + const publicServers = servers.filter((server): server is PublicMcpServer => server !== null) + return mcpListPublicRoute.output.parse({ + servers: publicServers, + truncated: entries.length > publicServers.length + }) + } + ], + [ + mcpAddPublicRoute.name, + async (rawInput, context) => { + requireHumanCliCaller(context.caller) + const input = mcpAddPublicRoute.input.parse(rawInput) + let result: Awaited> + try { + result = await dependencies.mcp.addMcpServer( + input.serverName, + toStoredConfig(input.config) + ) + } catch (error) { + throw unavailable('add the MCP server', error) + } + if (result.status === 'duplicate') { + throw new CliRequestError('conflict', 'MCP server name is already in use', { + httpStatus: 409 + }) + } + recordActivity( + 'created', + input.serverName, + 'settings.controlCenter.activity.mcpServerCreated' + ) + return mcpAddPublicRoute.output.parse({ + server: await summarizeServer(input.serverName, toStoredConfig(input.config)) + }) + } + ], + [ + mcpUpdatePublicRoute.name, + async (rawInput, context) => { + requireHumanCliCaller(context.caller) + const input = mcpUpdatePublicRoute.input.parse(rawInput) + const current = await requireUserServer(input.serverName) + const storedUpdate = toStoredUpdate(current.config, input.updates) + try { + await dependencies.mcp.updateMcpServer(input.serverName, storedUpdate) + } catch (error) { + throw unavailable('update the MCP server', error) + } + const server = await summarizeServer(input.serverName, { + ...current.config, + ...storedUpdate + }) + recordActivity( + 'updated', + input.serverName, + 'settings.controlCenter.activity.mcpServerUpdated' + ) + return mcpUpdatePublicRoute.output.parse({ server }) + } + ], + [ + mcpSetPublicStatusRoute.name, + async (rawInput, context) => { + requireHumanCliCaller(context.caller) + const input = mcpSetPublicStatusRoute.input.parse(rawInput) + const current = await requireUserServer(input.serverName) + try { + await dependencies.mcp.setMcpServerEnabled(input.serverName, input.enabled) + } catch (error) { + let persisted: MCPServerConfig | undefined + try { + persisted = (await dependencies.mcp.getMcpServers())[input.serverName] + } catch { + persisted = undefined + } + const running = await readRunning(input.serverName) + if (persisted?.enabled === input.enabled && current.config.enabled !== input.enabled) { + recordActivity( + input.enabled ? 'enabled' : 'disabled', + input.serverName, + 'settings.controlCenter.activity.mcpServerStatusChanged' + ) + } + log.warn('[CLI] MCP enablement changed incompletely', { + serverName: input.serverName, + enabled: persisted?.enabled ?? null, + running, + failure: { name: error instanceof Error ? error.name : typeof error } + }) + throw new CliRequestError( + 'unavailable', + 'MCP runtime transition failed; inspect the reported persisted state', + { + httpStatus: 503, + retriable: true, + details: { + serverName: input.serverName, + enabled: persisted?.enabled ?? null, + running + } + } + ) + } + recordActivity( + input.enabled ? 'enabled' : 'disabled', + input.serverName, + 'settings.controlCenter.activity.mcpServerStatusChanged' + ) + return mcpSetPublicStatusRoute.output.parse({ + server: await summarizeServer(input.serverName, { + ...current.config, + enabled: input.enabled + }) + }) + } + ], + [ + mcpStartPublicRoute.name, + async (rawInput, context) => { + requireHumanCliCaller(context.caller) + const input = mcpStartPublicRoute.input.parse(rawInput) + const current = await requireUserServer(input.serverName) + try { + await dependencies.mcp.startServer(input.serverName) + } catch (error) { + throw unavailable('start the MCP server', error) + } + recordActivity( + 'enabled', + input.serverName, + 'settings.controlCenter.activity.mcpServerStarted' + ) + return mcpStartPublicRoute.output.parse({ + server: await summarizeServer(input.serverName, current.config) + }) + } + ], + [ + mcpStopPublicRoute.name, + async (rawInput, context) => { + requireHumanCliCaller(context.caller) + const input = mcpStopPublicRoute.input.parse(rawInput) + const current = await requireUserServer(input.serverName) + try { + await dependencies.mcp.stopServer(input.serverName) + } catch (error) { + throw unavailable('stop the MCP server', error) + } + recordActivity( + 'disabled', + input.serverName, + 'settings.controlCenter.activity.mcpServerStopped' + ) + return mcpStopPublicRoute.output.parse({ + server: await summarizeServer(input.serverName, current.config) + }) + } + ], + [ + mcpRemovePublicRoute.name, + async (rawInput, context) => { + requireHumanCliCaller(context.caller) + const input = mcpRemovePublicRoute.input.parse(rawInput) + await requireUserServer(input.serverName) + try { + await dependencies.mcp.removeMcpServer(input.serverName) + } catch (error) { + throw unavailable('remove the MCP server', error) + } + recordActivity( + 'removed', + input.serverName, + 'settings.controlCenter.activity.mcpServerRemoved' + ) + return mcpRemovePublicRoute.output.parse({ + serverName: input.serverName, + removed: true + }) + } + ] + ]) +} diff --git a/src/main/cli/publicText.ts b/src/main/cli/publicText.ts new file mode 100644 index 000000000..f119b4e92 --- /dev/null +++ b/src/main/cli/publicText.ts @@ -0,0 +1,94 @@ +const PUBLIC_TEXT_SCAN_FACTOR = 16 +const PUBLIC_LIST_SCAN_FACTOR = 16 + +export type SanitizedPublicText = Readonly<{ value: string; truncated: boolean }> +export type SanitizedPublicList = Readonly<{ values: string[]; truncated: boolean }> + +export function compareStableText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} + +function isPublicTextControl(codePoint: number): boolean { + return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f) +} + +function isDirectionalControl(codePoint: number): boolean { + return ( + codePoint === 0x061c || + codePoint === 0x200e || + codePoint === 0x200f || + (codePoint >= 0x202a && codePoint <= 0x202e) || + (codePoint >= 0x2066 && codePoint <= 0x2069) + ) +} + +export function sanitizePublicText(value: unknown, maxBytes: number): SanitizedPublicText { + if (typeof value !== 'string') return { value: '', truncated: false } + const output: string[] = [] + let bytes = 0 + let consumedCodeUnits = 0 + let pendingSpace = false + let truncated = false + const maxScannedCodeUnits = maxBytes * PUBLIC_TEXT_SCAN_FACTOR + + for (const character of value) { + consumedCodeUnits += character.length + if (consumedCodeUnits > maxScannedCodeUnits) { + truncated = true + break + } + const codePoint = character.codePointAt(0)! + if (isDirectionalControl(codePoint)) continue + if (isPublicTextControl(codePoint) || character.trim() === '') { + pendingSpace = output.length > 0 + continue + } + + if (pendingSpace) { + if (bytes + 1 > maxBytes) { + truncated = true + break + } + output.push(' ') + bytes += 1 + pendingSpace = false + } + const characterBytes = Buffer.byteLength(character, 'utf8') + if (bytes + characterBytes > maxBytes) { + truncated = true + break + } + output.push(character) + bytes += characterBytes + } + return { + value: output.join(''), + truncated: truncated || consumedCodeUnits < value.length + } +} + +export function sanitizePublicStringList( + value: unknown, + maxItems: number, + maxBytes: number +): SanitizedPublicList { + if (!Array.isArray(value)) return { values: [], truncated: false } + let itemTruncated = false + const maxScannedItems = maxItems * PUBLIC_LIST_SCAN_FACTOR + const scannedValues = value.slice(0, maxScannedItems) + const values = Array.from( + new Set( + scannedValues + .map((entry) => { + const sanitized = sanitizePublicText(entry, maxBytes) + itemTruncated ||= sanitized.truncated + return sanitized.value + }) + .filter((entry) => entry.length > 0) + ) + ).sort(compareStableText) + return { + values: values.slice(0, maxItems), + truncated: itemTruncated || value.length > scannedValues.length || values.length > maxItems + } +} diff --git a/src/main/cli/skillService.ts b/src/main/cli/skillService.ts index 2ac5ea4ae..01c70f926 100644 --- a/src/main/cli/skillService.ts +++ b/src/main/cli/skillService.ts @@ -22,6 +22,7 @@ import { type RouteCaller } from '@/routes/routeRegistry' import { CliRequestError } from './errors' +import { compareStableText, sanitizePublicStringList, sanitizePublicText } from './publicText' import type { CliUploadedInputFile } from './server' const PUBLIC_SKILL_DESCRIPTION_BYTES = 1024 @@ -30,8 +31,6 @@ const PUBLIC_SKILL_PLATFORM_BYTES = 64 const PUBLIC_SKILL_TOOL_BYTES = 128 const PUBLIC_SKILL_PLATFORMS = 32 const PUBLIC_SKILL_TOOLS = 32 -const PUBLIC_TEXT_SCAN_FACTOR = 16 -const PUBLIC_LIST_SCAN_FACTOR = 16 type PublicSkillPort = Pick< SkillServicePort, @@ -68,9 +67,6 @@ function requireHumanCliCaller( } } -type SanitizedText = Readonly<{ value: string; truncated: boolean }> -type SanitizedList = Readonly<{ values: string[]; truncated: boolean }> - async function removeFileIfPresent(filePath: string): Promise { try { await unlink(filePath) @@ -85,95 +81,6 @@ async function retainUploadFile(uploadPath: string): Promise right ? 1 : 0 -} - -function isPublicTextControl(codePoint: number): boolean { - return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f) -} - -function isDirectionalControl(codePoint: number): boolean { - return ( - codePoint === 0x061c || - codePoint === 0x200e || - codePoint === 0x200f || - (codePoint >= 0x202a && codePoint <= 0x202e) || - (codePoint >= 0x2066 && codePoint <= 0x2069) - ) -} - -function sanitizePublicText(value: unknown, maxBytes: number): SanitizedText { - if (typeof value !== 'string') return { value: '', truncated: false } - const output: string[] = [] - let bytes = 0 - let consumedCodeUnits = 0 - let pendingSpace = false - let truncated = false - const maxScannedCodeUnits = maxBytes * PUBLIC_TEXT_SCAN_FACTOR - - for (const character of value) { - consumedCodeUnits += character.length - if (consumedCodeUnits > maxScannedCodeUnits) { - truncated = true - break - } - const codePoint = character.codePointAt(0)! - if (isDirectionalControl(codePoint)) continue - if (isPublicTextControl(codePoint) || character.trim() === '') { - pendingSpace = output.length > 0 - continue - } - - if (pendingSpace) { - if (bytes + 1 > maxBytes) { - truncated = true - break - } - output.push(' ') - bytes += 1 - pendingSpace = false - } - const characterBytes = Buffer.byteLength(character, 'utf8') - if (bytes + characterBytes > maxBytes) { - truncated = true - break - } - output.push(character) - bytes += characterBytes - } - return { - value: output.join(''), - truncated: truncated || consumedCodeUnits < value.length - } -} - -function sanitizePublicStringList( - value: unknown, - maxItems: number, - maxBytes: number -): SanitizedList { - if (!Array.isArray(value)) return { values: [], truncated: false } - let itemTruncated = false - const maxScannedItems = maxItems * PUBLIC_LIST_SCAN_FACTOR - const scannedValues = value.slice(0, maxScannedItems) - const values = Array.from( - new Set( - scannedValues - .map((entry) => { - const sanitized = sanitizePublicText(entry, maxBytes) - itemTruncated ||= sanitized.truncated - return sanitized.value - }) - .filter((entry) => entry.length > 0) - ) - ).sort(compareStableText) - return { - values: values.slice(0, maxItems), - truncated: itemTruncated || value.length > scannedValues.length || values.length > maxItems - } -} - function toPublicSkill(skill: UnifiedSkillItem): PublicSkill { const description = sanitizePublicText(skill.description, PUBLIC_SKILL_DESCRIPTION_BYTES) const category = skill.category diff --git a/src/main/cli/surface.ts b/src/main/cli/surface.ts index ef4ef594d..8cca1dd28 100644 --- a/src/main/cli/surface.ts +++ b/src/main/cli/surface.ts @@ -3,6 +3,7 @@ import { JsonValueSchema, type JsonValue } from '@shared/contracts/json' import { AUDIO_TRANSCRIPTION_MAX_INPUT_BYTES, OCR_EXTRACTION_MAX_INPUT_BYTES, + PUBLIC_MCP_CONFIG_MAX_BYTES, artifactsDeleteRoute, artifactsDescribeRoute, artifactsReadRoute, @@ -19,6 +20,13 @@ import { modelsResetConfigRoute, modelsSetPublicConfigRoute, modelsSetStatusRoute, + mcpAddPublicRoute, + mcpListPublicRoute, + mcpRemovePublicRoute, + mcpSetPublicStatusRoute, + mcpStartPublicRoute, + mcpStopPublicRoute, + mcpUpdatePublicRoute, ocrClearCacheRoute, ocrExtractArtifactRoute, ocrExtractUploadRoute, @@ -47,6 +55,7 @@ import { type LocalControlPrincipal, type LocalControlScope } from '@shared/contracts/localControl' +import { sanitizePublicText } from './publicText' export type LocalControlTransport = 'rpc' | 'stream' | 'upload' | 'download' export type LocalControlApprovalMode = 'never' | 'policy' @@ -205,6 +214,86 @@ function skillUrlDisplay(input: unknown): JsonValue { } } +function mcpConfigProjection(input: unknown, field: 'config' | 'updates'): JsonValue { + const config = jsonObjectField(input, field) + const projection: Record = { + fields: Object.keys(config).sort() + } + if (typeof config.type === 'string') projection.type = config.type + if (typeof config.description === 'string') { + const description = sanitizePublicText(config.description, 512) + projection.description = description.value + projection.descriptionTruncated = description.truncated + } + if (typeof config.command === 'string') { + const commandName = config.command.split(/[\\/]/).at(-1) ?? '' + projection.commandName = sanitizePublicText(commandName, 256).value + } + if (Array.isArray(config.args)) projection.argumentCount = config.args.length + if (typeof config.inheritEnv === 'string') projection.inheritEnv = config.inheritEnv + if (config.environment && typeof config.environment === 'object') { + projection.environment = mcpKeySummary(config.environment) + } + if (config.headers && typeof config.headers === 'object') { + projection.headers = mcpKeySummary(config.headers) + } + if (typeof config.baseUrl === 'string') projection.endpoint = mcpUrlSummary(config.baseUrl) + if (typeof config.customNpmRegistry === 'string') { + projection.npmRegistry = mcpUrlSummary(config.customNpmRegistry) + } else if (config.customNpmRegistry === null) { + projection.npmRegistry = null + } + if (config.authorization && typeof config.authorization === 'object') { + projection.authorization = selectAuditFields(config.authorization, ['mode']) + } else if (config.authorization === null) { + projection.authorization = null + } + return { + ...selectAuditFields(input, ['serverName']), + [field]: projection + } +} + +function mcpConfigAudit(input: unknown, field: 'config' | 'updates'): JsonValue { + const config = jsonObjectField(input, field) + return { + ...selectAuditFields(input, ['serverName']), + fields: Object.keys(config).sort(), + environment: + config.environment && typeof config.environment === 'object' + ? mcpKeySummary(config.environment) + : { count: 0, names: [], truncated: false }, + headers: + config.headers && typeof config.headers === 'object' + ? mcpKeySummary(config.headers) + : { count: 0, names: [], truncated: false } + } +} + +function mcpKeySummary(value: object): JsonValue { + const keys = Object.keys(value).sort() + const names = keys.slice(0, 16).map((key) => sanitizePublicText(key, 128).value) + return { + count: keys.length, + names, + truncated: keys.length > names.length + } +} + +function mcpUrlSummary(value: string): JsonValue { + try { + const url = new URL(value) + const origin = sanitizePublicText(url.origin, 1024) + return { + origin: origin.value, + pathPresent: url.pathname !== '/', + truncated: origin.truncated + } + } catch { + return { valid: false } + } +} + const DIAGNOSTIC_LIMITS = { maxBodyBytes: 16 * 1024, timeoutMs: 5_000 @@ -378,7 +467,7 @@ const CLI_SURFACE_V1_ENTRIES = [ approval: 'policy', auditProjection: (input) => selectAuditFields(input, ['name', 'apiType', 'enabled']), approvalDisplay: (input) => selectAuditFields(input, ['name', 'apiType', 'baseUrl', 'enabled']), - limits: { maxBodyBytes: 16 * 1024, timeoutMs: 30_000 } + limits: APPROVED_MUTATION_LIMITS }, { contract: providersUpdatePublicRoute, @@ -395,7 +484,7 @@ const CLI_SURFACE_V1_ENTRIES = [ ...selectAuditFields(input, ['providerId']), updates: jsonObjectField(input, 'updates') }), - limits: { maxBodyBytes: 16 * 1024, timeoutMs: 30_000 } + limits: APPROVED_MUTATION_LIMITS }, { contract: providersSetCredentialRoute, @@ -406,7 +495,7 @@ const CLI_SURFACE_V1_ENTRIES = [ approval: 'policy', auditProjection: (input) => selectAuditFields(input, ['providerId', 'action', 'kind']), approvalDisplay: (input) => selectAuditFields(input, ['providerId', 'action', 'kind']), - limits: { maxBodyBytes: 128 * 1024, timeoutMs: 30_000 } + limits: { maxBodyBytes: 128 * 1024, timeoutMs: APPROVED_MUTATION_LIMITS.timeoutMs } }, { contract: providersRemoveRoute, @@ -417,7 +506,7 @@ const CLI_SURFACE_V1_ENTRIES = [ approval: 'policy', auditProjection: (input) => selectAuditFields(input, ['providerId']), approvalDisplay: (input) => selectAuditFields(input, ['providerId']), - limits: DIAGNOSTIC_LIMITS + limits: APPROVED_MUTATION_LIMITS }, { contract: modelsListRuntimeRoute, @@ -448,7 +537,7 @@ const CLI_SURFACE_V1_ENTRIES = [ approval: 'policy', auditProjection: (input) => selectAuditFields(input, ['providerId', 'modelId', 'enabled']), approvalDisplay: (input) => selectAuditFields(input, ['providerId', 'modelId', 'enabled']), - limits: DIAGNOSTIC_LIMITS + limits: APPROVED_MUTATION_LIMITS }, { contract: modelsSetPublicConfigRoute, @@ -465,7 +554,7 @@ const CLI_SURFACE_V1_ENTRIES = [ ...selectAuditFields(input, ['providerId', 'modelId']), config: jsonObjectField(input, 'config') }), - limits: { maxBodyBytes: 64 * 1024, timeoutMs: 30_000 } + limits: { maxBodyBytes: 64 * 1024, timeoutMs: APPROVED_MUTATION_LIMITS.timeoutMs } }, { contract: modelsResetConfigRoute, @@ -476,7 +565,7 @@ const CLI_SURFACE_V1_ENTRIES = [ approval: 'policy', auditProjection: (input) => selectAuditFields(input, ['providerId', 'modelId']), approvalDisplay: (input) => selectAuditFields(input, ['providerId', 'modelId']), - limits: DIAGNOSTIC_LIMITS + limits: APPROVED_MUTATION_LIMITS }, { contract: settingsGetPublicRoute, @@ -507,7 +596,7 @@ const CLI_SURFACE_V1_ENTRIES = [ approvalDisplay: (input) => ({ changes: settingChangesForDisplay(input) }), agentInputAllowed: (input) => settingChangeKeys(input).every((key) => PREFERENCE_SETTING_KEYS.has(key)), - limits: DIAGNOSTIC_LIMITS + limits: APPROVED_MUTATION_LIMITS }, { contract: skillsListPublicRoute, @@ -566,6 +655,116 @@ const CLI_SURFACE_V1_ENTRIES = [ approvalDisplay: (input) => selectAuditFields(input, ['agentId', 'name']), limits: APPROVED_MUTATION_LIMITS }, + { + contract: mcpListPublicRoute, + effect: 'read', + callers: ['human'], + scopes: ['mcp:read'], + transport: 'rpc', + approval: 'never', + auditProjection: () => ({}), + limits: DIAGNOSTIC_LIMITS + }, + { + contract: mcpAddPublicRoute, + effect: { + possible: ['supply-chain', 'credential'], + resolve: (input) => { + const config = jsonObjectField(input, 'config') + const environment = config.environment + const headers = config.headers + return (environment && + typeof environment === 'object' && + Object.keys(environment).length > 0) || + (headers && typeof headers === 'object' && Object.keys(headers).length > 0) + ? 'credential' + : 'supply-chain' + } + }, + callers: ['human'], + scopes: ['mcp:write'], + transport: 'rpc', + approval: 'policy', + auditProjection: (input) => mcpConfigAudit(input, 'config'), + approvalDisplay: (input) => mcpConfigProjection(input, 'config'), + limits: { + maxBodyBytes: PUBLIC_MCP_CONFIG_MAX_BYTES + 64 * 1024, + timeoutMs: 5 * 60_000 + } + }, + { + contract: mcpUpdatePublicRoute, + effect: { + possible: ['execution-config', 'security-config', 'supply-chain', 'credential'], + resolve: (input) => { + const fields = new Set(objectFieldKeys(input, 'updates')) + if (fields.has('environment') || fields.has('headers')) return 'credential' + if ( + ['command', 'args', 'type', 'baseUrl', 'customNpmRegistry'].some((field) => + fields.has(field) + ) + ) { + return 'supply-chain' + } + if (fields.has('authorization') || fields.has('inheritEnv')) return 'security-config' + return 'execution-config' + } + }, + callers: ['human'], + scopes: ['mcp:write'], + transport: 'rpc', + approval: 'policy', + auditProjection: (input) => mcpConfigAudit(input, 'updates'), + approvalDisplay: (input) => mcpConfigProjection(input, 'updates'), + limits: { + maxBodyBytes: PUBLIC_MCP_CONFIG_MAX_BYTES + 64 * 1024, + timeoutMs: 5 * 60_000 + } + }, + { + contract: mcpRemovePublicRoute, + effect: 'destructive', + callers: ['human'], + scopes: ['mcp:write'], + transport: 'rpc', + approval: 'policy', + auditProjection: (input) => selectAuditFields(input, ['serverName']), + approvalDisplay: (input) => selectAuditFields(input, ['serverName']), + limits: APPROVED_MUTATION_LIMITS + }, + { + contract: mcpSetPublicStatusRoute, + effect: 'execution-config', + callers: ['human'], + scopes: ['mcp:write'], + transport: 'rpc', + approval: 'policy', + auditProjection: (input) => selectAuditFields(input, ['serverName', 'enabled']), + approvalDisplay: (input) => selectAuditFields(input, ['serverName', 'enabled']), + limits: APPROVED_MUTATION_LIMITS + }, + { + contract: mcpStartPublicRoute, + effect: 'execution-config', + callers: ['human'], + scopes: ['mcp:write'], + transport: 'rpc', + approval: 'policy', + auditProjection: (input) => selectAuditFields(input, ['serverName']), + approvalDisplay: (input) => selectAuditFields(input, ['serverName']), + limits: APPROVED_MUTATION_LIMITS + }, + { + contract: mcpStopPublicRoute, + effect: 'execution-config', + callers: ['human'], + scopes: ['mcp:write'], + transport: 'rpc', + approval: 'policy', + auditProjection: (input) => selectAuditFields(input, ['serverName']), + approvalDisplay: (input) => selectAuditFields(input, ['serverName']), + limits: APPROVED_MUTATION_LIMITS + }, { contract: artifactsDescribeRoute, effect: 'read', diff --git a/src/shared/contracts/routes.ts b/src/shared/contracts/routes.ts index e858b908c..5c42e58bd 100644 --- a/src/shared/contracts/routes.ts +++ b/src/shared/contracts/routes.ts @@ -204,6 +204,7 @@ import { knowledgeValidateFileRoute } from './routes/knowledge.routes' import { + mcpAddPublicRoute, mcpAddServerRoute, mcpAppsAuthorizeMessageRoute, mcpAppsCallToolRoute, @@ -243,11 +244,13 @@ import { mcpGetServersRoute, mcpIsServerRunningRoute, mcpListPromptsRoute, + mcpListPublicRoute, mcpListResourcesRoute, mcpListToolDefinitionsRoute, mcpLogoutServerAuthRoute, mcpReadResourceRoute, mcpRefreshNpmRegistryRoute, + mcpRemovePublicRoute, mcpRemoveServerRoute, mcpRouterGetApiKeyRoute, mcpRouterInstallServerRoute, @@ -258,12 +261,16 @@ import { mcpSetAutoDetectNpmRegistryRoute, mcpSetCustomNpmRegistryRoute, mcpSetEnabledRoute, + mcpSetPublicStatusRoute, mcpSetServerEnabledRoute, + mcpStartPublicRoute, mcpStartServerAuthRoute, mcpStartServerRoute, + mcpStopPublicRoute, mcpStopServerRoute, mcpSubmitSamplingDecisionRoute, mcpSubmitElicitationDecisionRoute, + mcpUpdatePublicRoute, mcpUpdateServerRoute } from './routes/mcp.routes' import { @@ -1088,6 +1095,7 @@ const DEEPCHAT_ROUTE_CATALOG_PART_5 = { [skillSyncGetAgentSkillDetailRoute.name]: skillSyncGetAgentSkillDetailRoute, [skillSyncRepairAgentSkillLinkRoute.name]: skillSyncRepairAgentSkillLinkRoute, [skillSyncRemoveAgentSkillLinkRoute.name]: skillSyncRemoveAgentSkillLinkRoute, + [mcpListPublicRoute.name]: mcpListPublicRoute, [mcpGetServersRoute.name]: mcpGetServersRoute, [mcpGetEnabledRoute.name]: mcpGetEnabledRoute, [mcpGetClientsRoute.name]: mcpGetClientsRoute, @@ -1095,13 +1103,19 @@ const DEEPCHAT_ROUTE_CATALOG_PART_5 = { [mcpListPromptsRoute.name]: mcpListPromptsRoute, [mcpListResourcesRoute.name]: mcpListResourcesRoute, [mcpCallToolRoute.name]: mcpCallToolRoute, + [mcpAddPublicRoute.name]: mcpAddPublicRoute, [mcpAddServerRoute.name]: mcpAddServerRoute, + [mcpUpdatePublicRoute.name]: mcpUpdatePublicRoute, [mcpUpdateServerRoute.name]: mcpUpdateServerRoute, + [mcpRemovePublicRoute.name]: mcpRemovePublicRoute, [mcpRemoveServerRoute.name]: mcpRemoveServerRoute, + [mcpSetPublicStatusRoute.name]: mcpSetPublicStatusRoute, [mcpSetServerEnabledRoute.name]: mcpSetServerEnabledRoute, [mcpSetEnabledRoute.name]: mcpSetEnabledRoute, [mcpIsServerRunningRoute.name]: mcpIsServerRunningRoute, + [mcpStartPublicRoute.name]: mcpStartPublicRoute, [mcpStartServerRoute.name]: mcpStartServerRoute, + [mcpStopPublicRoute.name]: mcpStopPublicRoute, [mcpStopServerRoute.name]: mcpStopServerRoute, [mcpGetServerAuthStatusRoute.name]: mcpGetServerAuthStatusRoute, [mcpGetServerDiagnosticsRoute.name]: mcpGetServerDiagnosticsRoute, diff --git a/src/shared/contracts/routes/mcp.routes.ts b/src/shared/contracts/routes/mcp.routes.ts index fb62661b7..92b03fa42 100644 --- a/src/shared/contracts/routes/mcp.routes.ts +++ b/src/shared/contracts/routes/mcp.routes.ts @@ -90,6 +90,404 @@ const MCPServerConfigUpdateSchema: z.ZodType> = MCPServerConfigObjectSchema.partial().refine(isBoundedServerConfig, { message: 'MCP server configuration exceeds the 3 MiB route limit' }) + +// Leave room for the route envelope inside ApprovalBroker's 1 MiB argument binding. +export const PUBLIC_MCP_CONFIG_MAX_BYTES = 768 * 1024 +export const PUBLIC_MCP_LIST_MAX_ITEMS = 512 + +function isSafePublicMcpDisplayText(value: string): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0)! + if ( + codePoint <= 0x1f || + (codePoint >= 0x7f && codePoint <= 0x9f) || + codePoint === 0x061c || + codePoint === 0x200e || + codePoint === 0x200f || + (codePoint >= 0x202a && codePoint <= 0x202e) || + (codePoint >= 0x2066 && codePoint <= 0x2069) + ) { + return false + } + } + return true +} + +function isBoundedPublicMcpConfig(value: unknown): boolean { + try { + return ( + new TextEncoder().encode(JSON.stringify(value) ?? 'null').byteLength <= + PUBLIC_MCP_CONFIG_MAX_BYTES + ) + } catch { + return false + } +} + +export const PublicMcpServerNameSchema = z + .string() + .min(1) + .max(256) + .refine((value) => value === value.trim(), { + message: 'MCP server name must not have surrounding whitespace' + }) + .refine(isSafePublicMcpDisplayText, { + message: 'MCP server name contains unsafe display characters' + }) + .refine( + (value) => + value !== 'prototype' && !Object.prototype.hasOwnProperty.call(Object.prototype, value), + { message: 'MCP server name conflicts with an object property' } + ) + +const PublicMcpDescriptionSchema = z.string().max(16 * 1024) +const PublicMcpIconSchema = z.string().max(128).refine(isSafePublicMcpDisplayText, { + message: 'MCP server icon contains unsafe display characters' +}) +const PublicMcpCommandSchema = z + .string() + .trim() + .min(1) + .max(4096) + .refine((value) => !value.includes('\0'), { message: 'MCP command must not contain NUL' }) +const PublicMcpArgumentSchema = z + .string() + .max(8192) + .refine((value) => !value.includes('\0'), { message: 'MCP argument must not contain NUL' }) +const PublicMcpEnvironmentSchema = z + .record( + z + .string() + .regex(/^[A-Za-z_][A-Za-z0-9_]*$/) + .max(256), + z + .string() + .max(64 * 1024) + .refine((value) => !value.includes('\0'), { + message: 'MCP environment value must not contain NUL' + }) + ) + .refine((value) => Object.keys(value).length <= 256, { + message: 'MCP environment has too many entries' + }) + .refine( + (value) => { + const keys = Object.keys(value).map((key) => key.toLowerCase()) + return new Set(keys).size === keys.length + }, + { message: 'MCP environment contains case-insensitive duplicate names' } + ) +const PublicMcpHeadersSchema = z + .record( + z + .string() + .min(1) + .max(128) + .regex(/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/), + z + .string() + .max(64 * 1024) + .refine((value) => !value.includes('\0') && !value.includes('\r') && !value.includes('\n'), { + message: 'MCP header value contains an unsafe character' + }) + ) + .refine((value) => Object.keys(value).length <= 256, { + message: 'MCP headers have too many entries' + }) + .refine( + (value) => { + const keys = Object.keys(value).map((key) => key.toLowerCase()) + return new Set(keys).size === keys.length + }, + { message: 'MCP headers contain case-insensitive duplicate names' } + ) + +const PublicMcpSecureUrlSchema = z + .url() + .max(8192) + .superRefine((value, context) => { + let url: URL + try { + url = new URL(value) + } catch { + context.addIssue({ code: 'custom', message: 'MCP URL is invalid' }) + return + } + const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname.toLowerCase()) + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) { + context.addIssue({ + code: 'custom', + message: 'MCP URL must use HTTPS or loopback HTTP' + }) + } + if (url.username || url.password || url.search || url.hash) { + context.addIssue({ + code: 'custom', + message: 'MCP URL must not contain credentials, query parameters, or a fragment' + }) + } + }) + +const PublicMcpAuthorizationConfigSchema = z + .object({ + mode: z.enum([ + 'none', + 'interactive', + 'client_credentials', + 'private_key_jwt', + 'cross_app_access' + ]), + protectedResourceUrl: PublicMcpSecureUrlSchema.optional(), + authorizationServerIssuer: PublicMcpSecureUrlSchema.optional(), + clientMetadataUrl: PublicMcpSecureUrlSchema.optional(), + clientId: z.string().min(1).max(2048).refine(isSafePublicMcpDisplayText).optional(), + scopes: z + .array( + z + .string() + .min(1) + .max(512) + .regex(/^[\x21\x23-\x5b\x5d-\x7e]+$/) + ) + .max(128) + .optional(), + identityProfileId: z.string().min(1).max(512).refine(isSafePublicMcpDisplayText).optional(), + keyAlgorithm: z.enum(['RS256', 'ES256']).optional() + }) + .strict() + .superRefine((value, context) => { + const machineMode = + value.mode === 'client_credentials' || + value.mode === 'private_key_jwt' || + value.mode === 'cross_app_access' + if (!machineMode) return + + for (const field of [ + 'protectedResourceUrl', + 'authorizationServerIssuer', + 'clientId' + ] as const) { + if (!value[field]) { + context.addIssue({ + code: 'custom', + path: [field], + message: `${field} is required for machine authorization` + }) + } + } + if (value.mode === 'private_key_jwt' && !value.keyAlgorithm) { + context.addIssue({ + code: 'custom', + path: ['keyAlgorithm'], + message: 'keyAlgorithm is required for private_key_jwt authorization' + }) + } + if (value.mode === 'cross_app_access' && !value.identityProfileId) { + context.addIssue({ + code: 'custom', + path: ['identityProfileId'], + message: 'identityProfileId is required for cross_app_access authorization' + }) + } + }) + +const PublicMcpCommonConfigShape = { + description: PublicMcpDescriptionSchema.optional().default(''), + icon: PublicMcpIconSchema.optional().default('') +} + +const PublicMcpStdioConfigSchema = z + .object({ + ...PublicMcpCommonConfigShape, + type: z.literal('stdio'), + command: PublicMcpCommandSchema, + args: z.array(PublicMcpArgumentSchema).max(256).optional().default([]), + environment: PublicMcpEnvironmentSchema.optional().default({}), + inheritEnv: z.enum(['legacy', 'minimal']).optional().default('minimal'), + customNpmRegistry: PublicMcpSecureUrlSchema.optional() + }) + .strict() + +const PublicMcpSseConfigSchema = z + .object({ + ...PublicMcpCommonConfigShape, + type: z.literal('sse'), + baseUrl: PublicMcpSecureUrlSchema, + headers: PublicMcpHeadersSchema.optional().default({}) + }) + .strict() + +const PublicMcpHttpConfigSchema = z + .object({ + ...PublicMcpCommonConfigShape, + type: z.literal('http'), + baseUrl: PublicMcpSecureUrlSchema, + headers: PublicMcpHeadersSchema.optional().default({}), + authorization: PublicMcpAuthorizationConfigSchema.optional() + }) + .strict() + +export const PublicMcpServerConfigInputSchema = z + .discriminatedUnion('type', [ + PublicMcpStdioConfigSchema, + PublicMcpSseConfigSchema, + PublicMcpHttpConfigSchema + ]) + .refine(isBoundedPublicMcpConfig, { + message: 'Public MCP server configuration exceeds its byte limit' + }) + +export const PublicMcpServerUpdateSchema = z + .object({ + type: z.enum(['stdio', 'sse', 'http']).optional(), + description: PublicMcpDescriptionSchema.optional(), + icon: PublicMcpIconSchema.optional(), + command: PublicMcpCommandSchema.optional(), + args: z.array(PublicMcpArgumentSchema).max(256).optional(), + environment: PublicMcpEnvironmentSchema.optional(), + inheritEnv: z.enum(['legacy', 'minimal']).optional(), + baseUrl: PublicMcpSecureUrlSchema.optional(), + headers: PublicMcpHeadersSchema.optional(), + authorization: PublicMcpAuthorizationConfigSchema.nullable().optional(), + customNpmRegistry: PublicMcpSecureUrlSchema.nullable().optional() + }) + .strict() + .superRefine((value, context) => { + const stdioFields = ['command', 'args', 'environment', 'inheritEnv', 'customNpmRegistry'] + const remoteFields = ['baseUrl', 'headers', 'authorization'] + const hasStdioField = stdioFields.some((field) => Object.hasOwn(value, field)) + const hasRemoteField = remoteFields.some((field) => Object.hasOwn(value, field)) + if (hasStdioField && hasRemoteField) { + context.addIssue({ + code: 'custom', + message: 'MCP update cannot mix stdio and remote transport fields' + }) + } + if (value.type === 'stdio' && hasRemoteField) { + context.addIssue({ + code: 'custom', + message: 'Remote transport fields are not valid for stdio MCP servers' + }) + } + if (value.type !== undefined && value.type !== 'stdio' && hasStdioField) { + context.addIssue({ + code: 'custom', + message: 'Stdio transport fields are not valid for remote MCP servers' + }) + } + if (value.type === 'sse' && Object.hasOwn(value, 'authorization')) { + context.addIssue({ + code: 'custom', + message: 'Authorization settings are only valid for HTTP MCP servers' + }) + } + }) + .refine((value) => Object.keys(value).length > 0, { + message: 'At least one MCP server update is required' + }) + .refine(isBoundedPublicMcpConfig, { + message: 'Public MCP server update exceeds its byte limit' + }) + +export const PublicMcpServerSchema = z + .object({ + name: PublicMcpServerNameSchema, + type: z.enum(['sse', 'stdio', 'inmemory', 'http']), + enabled: z.boolean(), + running: z.boolean().nullable(), + managedBy: z.enum(['deepchat', 'plugin', 'user']), + editable: z.boolean(), + removable: z.boolean(), + description: z.string().max(1024), + commandName: z.string().max(256).nullable(), + endpoint: z + .object({ + origin: z.string().max(4096), + pathPresent: z.boolean() + }) + .strict() + .nullable(), + argumentCount: z.number().int().nonnegative().max(1_000_000), + environmentEntryCount: z.number().int().nonnegative().max(1_000_000), + headerEntryCount: z.number().int().nonnegative().max(1_000_000), + authorizationMode: z + .enum(['none', 'interactive', 'client_credentials', 'private_key_jwt', 'cross_app_access']) + .nullable(), + metadataTruncated: z.boolean() + }) + .strict() + +export const mcpListPublicRoute = defineRouteContract({ + name: 'mcp.listPublic', + input: z.object({}).strict().default({}), + output: z + .object({ + servers: z.array(PublicMcpServerSchema).max(PUBLIC_MCP_LIST_MAX_ITEMS), + truncated: z.boolean() + }) + .strict() +}) + +export const mcpAddPublicRoute = defineRouteContract({ + name: 'mcp.addPublic', + input: z + .object({ + serverName: PublicMcpServerNameSchema, + config: PublicMcpServerConfigInputSchema + }) + .strict(), + output: z.object({ server: PublicMcpServerSchema }).strict() +}) + +export const mcpUpdatePublicRoute = defineRouteContract({ + name: 'mcp.updatePublic', + input: z + .object({ + serverName: PublicMcpServerNameSchema, + updates: PublicMcpServerUpdateSchema + }) + .strict(), + output: z.object({ server: PublicMcpServerSchema }).strict() +}) + +export const mcpSetPublicStatusRoute = defineRouteContract({ + name: 'mcp.setPublicStatus', + input: z + .object({ + serverName: PublicMcpServerNameSchema, + enabled: z.boolean() + }) + .strict(), + output: z.object({ server: PublicMcpServerSchema }).strict() +}) + +export const mcpStartPublicRoute = defineRouteContract({ + name: 'mcp.startPublic', + input: z.object({ serverName: PublicMcpServerNameSchema }).strict(), + output: z.object({ server: PublicMcpServerSchema }).strict() +}) + +export const mcpStopPublicRoute = defineRouteContract({ + name: 'mcp.stopPublic', + input: z.object({ serverName: PublicMcpServerNameSchema }).strict(), + output: z.object({ server: PublicMcpServerSchema }).strict() +}) + +export const mcpRemovePublicRoute = defineRouteContract({ + name: 'mcp.removePublic', + input: z.object({ serverName: PublicMcpServerNameSchema }).strict(), + output: z + .object({ + serverName: PublicMcpServerNameSchema, + removed: z.literal(true) + }) + .strict() +}) + +export type PublicMcpServer = z.infer +export type PublicMcpServerConfigInput = z.infer +export type PublicMcpServerUpdate = z.infer + const McpClientSchema = z.custom() const MCPToolDefinitionSchema = z.custom() const PromptListEntrySchema = z.custom() diff --git a/test/main/cli/args.test.ts b/test/main/cli/args.test.ts index 3a7306954..9a76ebd90 100644 --- a/test/main/cli/args.test.ts +++ b/test/main/cli/args.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest' import { CLI_OUTPUT_ENV, CLI_TIMEOUT_ENV, + DEFAULT_COMPUTE_TIMEOUT_MS, + DEFAULT_MUTATION_TIMEOUT_MS, formatCliHelp, parseCliArguments } from '../../../src/cli/args' @@ -624,4 +626,71 @@ describe('CLI argument grammar', () => { ) expect(formatCliHelp()).toContain('skill remove') }) + + it('parses public MCP administration without accepting inline configuration', () => { + expect(parseCliArguments(['mcp', 'list'], {})).toMatchObject({ + operation: 'rpc', + contract: { name: 'mcp.listPublic' }, + params: {} + }) + expect( + parseCliArguments(['mcp', 'add', '--name', 'local-server', '--stdin'], {}) + ).toMatchObject({ + contract: { name: 'mcp.addPublic' }, + params: { serverName: 'local-server' }, + readStdin: true, + timeoutMs: DEFAULT_MUTATION_TIMEOUT_MS + }) + expect( + parseCliArguments(['mcp', 'update', '--name=local-server', '--stdin'], {}) + ).toMatchObject({ + contract: { name: 'mcp.updatePublic' }, + params: { serverName: 'local-server' }, + readStdin: true + }) + expect(parseCliArguments(['mcp', 'disable', '--name', 'local-server'], {})).toMatchObject({ + contract: { name: 'mcp.setPublicStatus' }, + params: { serverName: 'local-server', enabled: false } + }) + expect(parseCliArguments(['mcp', 'start', '--name', 'local-server'], {})).toMatchObject({ + contract: { name: 'mcp.startPublic' }, + params: { serverName: 'local-server' } + }) + expect(parseCliArguments(['mcp', 'stop', '--name', 'local-server'], {})).toMatchObject({ + contract: { name: 'mcp.stopPublic' }, + params: { serverName: 'local-server' } + }) + expect(parseCliArguments(['mcp', 'remove', '--name', 'local-server'], {})).toMatchObject({ + contract: { name: 'mcp.removePublic' }, + params: { serverName: 'local-server' } + }) + + expect(() => parseCliArguments(['mcp', 'add', '--name', 'local-server'], {})).toThrow( + 'requires --stdin' + ) + expect(() => parseCliArguments(['mcp', 'update', '--stdin'], {})).toThrow('requires --name') + expect(() => parseCliArguments(['mcp', 'list', '--name', 'unexpected'], {})).toThrow( + '--name is not valid' + ) + }) + + it('leaves enough time for approvals and large artifact delivery by default', () => { + expect( + parseCliArguments(['provider', 'remove', '--provider', 'provider-1'], {}).timeoutMs + ).toBe(DEFAULT_MUTATION_TIMEOUT_MS) + expect(parseCliArguments(['skill', 'enable', '--name', 'skill-1'], {}).timeoutMs).toBe( + DEFAULT_MUTATION_TIMEOUT_MS + ) + expect( + parseCliArguments( + ['artifact', 'get', '--id', 'artifact_identifier_123', '--out', './output.bin'], + {} + ).timeoutMs + ).toBe(DEFAULT_COMPUTE_TIMEOUT_MS) + }) + + it('keeps MCP commands discoverable', () => { + expect(formatCliHelp({ domain: 'mcp', verb: 'add' })).toContain('--name --stdin') + expect(formatCliHelp()).toContain('mcp remove') + }) }) diff --git a/test/main/cli/client.test.ts b/test/main/cli/client.test.ts index b2f84b9c3..3a4faaab4 100644 --- a/test/main/cli/client.test.ts +++ b/test/main/cli/client.test.ts @@ -584,4 +584,97 @@ describe('bundled CLI client', () => { expect(JSON.parse(stdout.read())).toEqual(config) expect(stderr.read()).toBe('') }) + + it('injects bounded MCP configuration JSON under the typed route field', async () => { + const stdout = captureOutput() + const stderr = captureOutput() + const config = { + type: 'stdio', + command: 'npx', + args: ['server-package'], + environment: { SERVER_TOKEN: 'private-value' } + } + const invokeRpc = vi.fn(async (invocation) => + LocalControlRpcResponseSchema.parse({ + protocolVersion: 1, + surfaceVersion: 1, + id: invocation.id, + ok: true, + result: { + server: { + name: 'local-server', + type: 'stdio', + enabled: false, + running: false, + managedBy: 'user', + editable: true, + removable: true, + description: '', + commandName: 'npx', + endpoint: null, + argumentCount: 1, + environmentEntryCount: 1, + headerEntryCount: 0, + authorizationMode: null, + metadataTruncated: false + } + } + }) + ) + + await expect( + runCli(['mcp', 'add', '--name', 'local-server', '--stdin'], { + env: {}, + stdin: Readable.from([JSON.stringify(config)]), + stdout: stdout.stream, + stderr: stderr.stream, + randomId: () => 'request-1', + loadDescriptor: async () => testDescriptor, + invokeRpc + }) + ).resolves.toBe(0) + + expect(invokeRpc).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'mcp.addPublic', + params: { + serverName: 'local-server', + config: { + ...config, + description: '', + icon: '', + inheritEnv: 'minimal' + } + } + }) + ) + expect(stdout.read()).toBe('local-server added; disabled; runtime stopped\n') + expect(stdout.read()).not.toContain('private-value') + expect(stderr.read()).toBe('') + }) + + it('rejects non-object MCP stdin before transport invocation', async () => { + const stdout = captureOutput() + const stderr = captureOutput() + const invokeRpc = vi.fn() + + await expect( + runCli(['mcp', 'update', '--name', 'local-server', '--stdin', '--json'], { + env: {}, + stdin: Readable.from(['[]']), + stdout: stdout.stream, + stderr: stderr.stream, + randomId: () => 'request-1', + loadDescriptor: async () => testDescriptor, + invokeRpc + }) + ).resolves.toBe(2) + + expect(LocalControlRpcResponseSchema.parse(JSON.parse(stdout.read()))).toMatchObject({ + ok: false, + error: { code: 'invalid_request' } + }) + expect(stderr.read()).toBe('') + expect(invokeRpc).not.toHaveBeenCalled() + }) }) diff --git a/test/main/cli/mcpAdminRoutes.test.ts b/test/main/cli/mcpAdminRoutes.test.ts new file mode 100644 index 000000000..94c2fabb5 --- /dev/null +++ b/test/main/cli/mcpAdminRoutes.test.ts @@ -0,0 +1,434 @@ +import { describe, expect, it, vi } from 'vitest' +import { + mcpAddPublicRoute, + mcpListPublicRoute, + mcpRemovePublicRoute, + mcpSetPublicStatusRoute, + mcpStartPublicRoute, + mcpStopPublicRoute, + mcpUpdatePublicRoute +} from '@shared/contracts/routes' +import type { MCPServerConfig, McpServicePort } from '@shared/types/mcp' +import { createCliMcpAdminRoutes } from '@/cli/mcpAdminRoutes' +import type { CliRouteCaller, RouteContext } from '@/routes/routeRegistry' + +const caller: CliRouteCaller = { + kind: 'cli', + principal: 'human', + connectionId: 'connection-1', + scopes: ['mcp:read', 'mcp:write'] +} + +function stdioConfig(overrides: Partial = {}): MCPServerConfig { + return { + type: 'stdio', + command: '/private/bin/npx', + args: ['--yes', 'private-package'], + env: { PRIVATE_TOKEN: 'super-secret' }, + descriptions: 'Server\n\u001b[31m description', + icons: 'terminal', + enabled: false, + inheritEnv: 'minimal', + ...overrides + } +} + +function createHarness(initialServers: Record = {}) { + const servers = new Map(Object.entries(initialServers)) + const running = new Map() + const getMcpServers = vi.fn(async () => Object.fromEntries(servers)) + const isServerRunning = vi.fn(async (serverName: string) => running.get(serverName) ?? false) + const addMcpServer = vi.fn(async (serverName: string, config: MCPServerConfig) => { + if (servers.has(serverName)) return { status: 'duplicate' as const } + servers.set(serverName, config) + return { status: 'added' as const } + }) + const updateMcpServer = vi.fn(async (serverName: string, updates: Partial) => { + const current = servers.get(serverName) + if (!current) throw new Error('missing') + servers.set(serverName, { ...current, ...updates }) + }) + const setMcpServerEnabled = vi.fn(async (serverName: string, enabled: boolean) => { + const current = servers.get(serverName) + if (!current) throw new Error('missing') + servers.set(serverName, { ...current, enabled }) + running.set(serverName, enabled) + }) + const startServer = vi.fn(async (serverName: string) => { + running.set(serverName, true) + }) + const stopServer = vi.fn(async (serverName: string) => { + running.set(serverName, false) + }) + const removeMcpServer = vi.fn(async (serverName: string) => { + servers.delete(serverName) + running.delete(serverName) + }) + const recordSettingsActivity = vi.fn() + const log = { warn: vi.fn() } + const routes = createCliMcpAdminRoutes({ + mcp: { + getMcpServers, + isServerRunning, + addMcpServer, + updateMcpServer, + setMcpServerEnabled, + startServer, + stopServer, + removeMcpServer + } as Pick< + McpServicePort, + | 'getMcpServers' + | 'isServerRunning' + | 'addMcpServer' + | 'updateMcpServer' + | 'setMcpServerEnabled' + | 'startServer' + | 'stopServer' + | 'removeMcpServer' + >, + recordSettingsActivity, + log + }) + const invoke = async (method: string, input: unknown, context: RouteContext = { caller }) => { + const route = routes.get(method as never) + if (!route) throw new Error(`Missing route: ${method}`) + return await route(input, context) + } + + return { + servers, + running, + getMcpServers, + isServerRunning, + addMcpServer, + updateMcpServer, + setMcpServerEnabled, + startServer, + stopServer, + removeMcpServer, + recordSettingsActivity, + log, + invoke + } +} + +describe('CLI MCP administration routes', () => { + it('accepts only bounded public transports and structurally complete authorization', () => { + expect( + mcpAddPublicRoute.input.safeParse({ + serverName: 'local-server', + config: { type: 'stdio', command: 'npx' } + }).success + ).toBe(true) + for (const serverName of ['__proto__', 'constructor', 'toString', 'prototype']) { + expect( + mcpAddPublicRoute.input.safeParse({ + serverName, + config: { type: 'stdio', command: 'npx' } + }).success + ).toBe(false) + } + expect( + mcpAddPublicRoute.input.safeParse({ + serverName: 'internal', + config: { type: 'inmemory', command: '' } + }).success + ).toBe(false) + expect( + mcpUpdatePublicRoute.input.safeParse({ + serverName: 'server', + updates: { command: 'npx', baseUrl: 'https://mcp.example/api' } + }).success + ).toBe(false) + expect( + mcpAddPublicRoute.input.safeParse({ + serverName: 'host-owned', + config: { type: 'stdio', command: 'npx', ownerPluginId: 'plugin-private' } + }).success + ).toBe(false) + for (const config of [ + { type: 'http', baseUrl: 'not-a-url' }, + { type: 'http', baseUrl: 'http://mcp.example/api' }, + { type: 'http', baseUrl: 'https://user:secret@mcp.example/api' }, + { type: 'http', baseUrl: 'https://mcp.example/api?token=secret' }, + { + type: 'sse', + baseUrl: 'https://mcp.example/sse', + authorization: { mode: 'interactive' } + }, + { + type: 'http', + baseUrl: 'https://mcp.example/api', + headers: { Authorization: 'Bearer secret\r\nX-Injected: true' } + }, + { + type: 'http', + baseUrl: 'https://mcp.example/api', + authorization: { mode: 'client_credentials', clientId: 'client-1' } + }, + { + type: 'http', + baseUrl: 'https://mcp.example/api', + headers: { Authorization: 'first', authorization: 'second' } + } + ]) { + expect( + mcpAddPublicRoute.input.safeParse({ serverName: 'unsafe-server', config }).success + ).toBe(false) + } + expect( + mcpAddPublicRoute.input.safeParse({ + serverName: 'machine-server', + config: { + type: 'http', + baseUrl: 'https://mcp.example/api', + authorization: { + mode: 'private_key_jwt', + clientId: 'client-1', + protectedResourceUrl: 'https://mcp.example/', + authorizationServerIssuer: 'https://identity.example/', + keyAlgorithm: 'ES256' + } + } + }).success + ).toBe(true) + expect( + mcpAddPublicRoute.input.safeParse({ + serverName: 'oversized-server', + config: { + type: 'stdio', + command: 'npx', + environment: Object.fromEntries( + Array.from({ length: 13 }, (_, index) => [`VALUE_${index}`, 'x'.repeat(64 * 1024)]) + ) + } + }).success + ).toBe(false) + }) + + it('lists deterministic redacted summaries without paths, arguments, headers, or secrets', async () => { + const harness = createHarness({ + user: stdioConfig(), + plugin: stdioConfig({ ownerPluginId: 'private-plugin', source: 'plugin' }), + builtin: stdioConfig({ type: 'inmemory', command: '', args: [], env: {} }), + remote: { + ...stdioConfig(), + type: 'http', + command: '', + args: [], + env: {}, + baseUrl: 'https://mcp.example/private/path', + customHeaders: { Authorization: 'Bearer super-secret' }, + authorization: { + mode: 'client_credentials', + clientId: 'private-client-id', + protectedResourceUrl: 'https://mcp.example/', + authorizationServerIssuer: 'https://identity.example/' + } + } + }) + harness.running.set('user', true) + harness.isServerRunning.mockRejectedValueOnce(new Error('runtime secret')) + + const result = (await harness.invoke(mcpListPublicRoute.name, {})) as { + servers: Array> + truncated: boolean + } + + expect(result.servers.map((server) => server.name)).toEqual([ + 'builtin', + 'plugin', + 'remote', + 'user' + ]) + expect(result.servers.find((server) => server.name === 'builtin')).toMatchObject({ + managedBy: 'deepchat', + editable: false, + removable: false, + running: null + }) + expect(result.servers.find((server) => server.name === 'plugin')).toMatchObject({ + managedBy: 'plugin', + editable: false, + removable: false + }) + expect(result.servers.find((server) => server.name === 'user')).toMatchObject({ + commandName: 'npx', + argumentCount: 2, + environmentEntryCount: 1 + }) + expect(result.servers.find((server) => server.name === 'remote')).toMatchObject({ + endpoint: { origin: 'https://mcp.example', pathPresent: true }, + headerEntryCount: 1, + authorizationMode: 'client_credentials' + }) + const serialized = JSON.stringify(result) + for (const secret of [ + '/private/bin', + 'private-package', + 'super-secret', + 'private-plugin', + 'private-client-id', + '/private/path' + ]) { + expect(serialized).not.toContain(secret) + } + }) + + it('adds disabled servers, defaults to minimal environment, and rejects duplicate names', async () => { + const harness = createHarness() + + await expect( + harness.invoke(mcpAddPublicRoute.name, { + serverName: 'new-server', + config: { type: 'stdio', command: 'npx', args: ['server-package'] } + }) + ).resolves.toMatchObject({ + server: { + name: 'new-server', + type: 'stdio', + enabled: false, + commandName: 'npx' + } + }) + expect(harness.servers.get('new-server')).toMatchObject({ + enabled: false, + inheritEnv: 'minimal', + env: {}, + args: ['server-package'] + }) + await expect( + harness.invoke(mcpAddPublicRoute.name, { + serverName: 'new-server', + config: { type: 'stdio', command: 'other-command' } + }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(harness.recordSettingsActivity).toHaveBeenCalledOnce() + }) + + it('preserves unmentioned secrets and clears incompatible fields on transport changes', async () => { + const harness = createHarness({ + server: stdioConfig({ customNpmRegistry: 'https://registry.example/npm' }) + }) + + await harness.invoke(mcpUpdatePublicRoute.name, { + serverName: 'server', + updates: { description: 'Updated description' } + }) + expect(harness.servers.get('server')).toMatchObject({ + env: { PRIVATE_TOKEN: 'super-secret' }, + args: ['--yes', 'private-package'], + customNpmRegistry: 'https://registry.example/npm' + }) + + await harness.invoke(mcpUpdatePublicRoute.name, { + serverName: 'server', + updates: { + type: 'http', + baseUrl: 'https://mcp.example/api', + headers: { Authorization: 'Bearer replacement' } + } + }) + expect(harness.servers.get('server')).toMatchObject({ + type: 'http', + command: '', + args: [], + env: {}, + baseUrl: 'https://mcp.example/api', + customHeaders: { Authorization: 'Bearer replacement' } + }) + expect(harness.servers.get('server')?.customNpmRegistry).toBeUndefined() + expect(harness.servers.get('server')?.inheritEnv).toBeUndefined() + + await expect( + harness.invoke(mcpUpdatePublicRoute.name, { + serverName: 'server', + updates: { command: 'npx' } + }) + ).rejects.toMatchObject({ code: 'invalid_request' }) + }) + + it('blocks plugin and in-memory mutations and rejects non-human callers', async () => { + const harness = createHarness({ + plugin: stdioConfig({ ownerPluginId: 'plugin-1' }), + builtin: stdioConfig({ type: 'inmemory', command: '', args: [], env: {} }) + }) + + for (const [method, input] of [ + [mcpUpdatePublicRoute.name, { serverName: 'plugin', updates: { description: 'x' } }], + [mcpRemovePublicRoute.name, { serverName: 'builtin' }], + [mcpStartPublicRoute.name, { serverName: 'plugin' }], + [mcpStopPublicRoute.name, { serverName: 'builtin' }] + ] as const) { + await expect(harness.invoke(method, input)).rejects.toMatchObject({ code: 'conflict' }) + } + await expect( + harness.invoke( + mcpListPublicRoute.name, + {}, + { caller: { kind: 'renderer', webContentsId: 1, windowId: 1 } } + ) + ).rejects.toMatchObject({ code: 'permission_denied' }) + const agentCaller: CliRouteCaller = { + ...caller, + principal: 'agent', + conversationId: 'conversation-1', + expiresAt: Date.now() + 60_000 + } + await expect( + harness.invoke(mcpRemovePublicRoute.name, { serverName: 'plugin' }, { caller: agentCaller }) + ).rejects.toMatchObject({ code: 'permission_denied' }) + expect(harness.removeMcpServer).not.toHaveBeenCalled() + }) + + it('controls user-owned runtime state and removal through public adapters', async () => { + const harness = createHarness({ server: stdioConfig() }) + + await expect( + harness.invoke(mcpSetPublicStatusRoute.name, { serverName: 'server', enabled: true }) + ).resolves.toMatchObject({ + server: { name: 'server', enabled: true, running: true } + }) + await expect( + harness.invoke(mcpStopPublicRoute.name, { serverName: 'server' }) + ).resolves.toMatchObject({ server: { enabled: true, running: false } }) + await expect( + harness.invoke(mcpStartPublicRoute.name, { serverName: 'server' }) + ).resolves.toMatchObject({ server: { enabled: true, running: true } }) + await expect( + harness.invoke(mcpRemovePublicRoute.name, { serverName: 'server' }) + ).resolves.toEqual({ serverName: 'server', removed: true }) + + expect(harness.setMcpServerEnabled).toHaveBeenCalledWith('server', true) + expect(harness.stopServer).toHaveBeenCalledWith('server') + expect(harness.startServer).toHaveBeenCalledWith('server') + expect(harness.removeMcpServer).toHaveBeenCalledWith('server') + expect(harness.recordSettingsActivity).toHaveBeenCalledTimes(4) + }) + + it('reports persisted state when enablement only partially succeeds', async () => { + const harness = createHarness({ server: stdioConfig() }) + harness.setMcpServerEnabled.mockImplementationOnce(async (serverName, enabled) => { + harness.servers.set(serverName, { ...harness.servers.get(serverName)!, enabled }) + throw new Error('start failed with PRIVATE_TOKEN=super-secret') + }) + + const failure = await harness + .invoke(mcpSetPublicStatusRoute.name, { serverName: 'server', enabled: true }) + .catch((error: unknown) => error) + + expect(failure).toMatchObject({ + code: 'unavailable', + options: { + details: { serverName: 'server', enabled: true, running: false } + } + }) + expect(String((failure as Error).message)).not.toContain('PRIVATE_TOKEN') + expect(harness.log.warn).toHaveBeenCalledWith( + '[CLI] MCP enablement changed incompletely', + expect.not.objectContaining({ error: expect.stringContaining('super-secret') }) + ) + expect(harness.recordSettingsActivity).toHaveBeenCalledOnce() + }) +}) diff --git a/test/main/cli/surface.test.ts b/test/main/cli/surface.test.ts index 78276f74d..97afe73f7 100644 --- a/test/main/cli/surface.test.ts +++ b/test/main/cli/surface.test.ts @@ -22,6 +22,13 @@ describe('CLI surface V1', () => { 'cli.status', 'cli.version', 'images.generate', + 'mcp.addPublic', + 'mcp.listPublic', + 'mcp.removePublic', + 'mcp.setPublicStatus', + 'mcp.startPublic', + 'mcp.stopPublic', + 'mcp.updatePublic', 'models.getPublicConfig', 'models.invoke', 'models.listRuntime', @@ -58,6 +65,8 @@ describe('CLI surface V1', () => { it('denies methods that are not explicitly listed', () => { expect(getCliSurfaceEntry('settings.getSnapshot')).toBeUndefined() expect(getCliSurfaceEntry('mcp.callTool')).toBeUndefined() + expect(getCliSurfaceEntry('mcp.getServers')).toBeUndefined() + expect(getCliSurfaceEntry('mcp.credentials.set')).toBeUndefined() expect(getCliSurfaceEntry('databaseSecurity.disable')).toBeUndefined() expect(getCliSurfaceEntry('approvals.resolve')).toBeUndefined() }) @@ -155,6 +164,77 @@ describe('CLI surface V1', () => { ) }) + it('keeps MCP secret values and command arguments out of bounded metadata', () => { + const entry = getCliSurfaceEntry('mcp.addPublic')! + const input = { + serverName: 'private-server', + config: { + type: 'http', + baseUrl: 'https://mcp.example/private/path', + description: 'description'.repeat(2_000), + args: ['--token', 'argument-secret'], + environment: { PRIVATE_TOKEN: 'environment-secret' }, + headers: { Authorization: 'header-secret' }, + authorization: { + mode: 'client_credentials', + clientId: 'private-client-id' + } + } + } + + const approval = entry.approvalDisplay?.(input) + const audit = entry.auditProjection?.(input) + const serialized = JSON.stringify({ approval, audit }) + expect(approval).toMatchObject({ + serverName: 'private-server', + config: { + type: 'http', + argumentCount: 2, + endpoint: { origin: 'https://mcp.example', pathPresent: true }, + environment: { count: 1, names: ['PRIVATE_TOKEN'] }, + headers: { count: 1, names: ['Authorization'] }, + authorization: { mode: 'client_credentials' } + } + }) + for (const secret of [ + 'argument-secret', + 'environment-secret', + 'header-secret', + 'private-client-id', + '/private/path' + ]) { + expect(serialized).not.toContain(secret) + } + expect(Buffer.byteLength(JSON.stringify(approval), 'utf8')).toBeLessThan(16 * 1024) + expect(resolveCliSurfaceEffect(entry, input)).toBe('credential') + expect( + resolveCliSurfaceEffect(entry, { + serverName: 'public-server', + config: { type: 'stdio', command: 'npx', environment: {} } + }) + ).toBe('supply-chain') + }) + + it('gives every approval route enough server time for renderer confirmation', () => { + for (const capability of listCliSurfaceCapabilities()) { + if (capability.approval !== 'policy') continue + expect(capability.timeoutMs, capability.method).toBeGreaterThanOrEqual(2 * 60_000) + } + }) + + it('classifies MCP updates by their highest-impact field', () => { + const entry = getCliSurfaceEntry('mcp.updatePublic')! + + expect(resolveCliSurfaceEffect(entry, { updates: { description: 'renamed' } })).toBe( + 'execution-config' + ) + expect(resolveCliSurfaceEffect(entry, { updates: { authorization: null } })).toBe( + 'security-config' + ) + expect(resolveCliSurfaceEffect(entry, { updates: { command: 'npx' } })).toBe('supply-chain') + expect(resolveCliSurfaceEffect(entry, { updates: { headers: {} } })).toBe('credential') + }) + it('publishes stable sorted capability metadata', () => { expect(listCliSurfaceCapabilities()).toEqual([ expect.objectContaining({ @@ -189,6 +269,38 @@ describe('CLI surface V1', () => { possibleEffects: ['compute'], transport: 'stream' }), + expect.objectContaining({ + method: 'mcp.addPublic', + possibleEffects: ['supply-chain', 'credential'], + callers: ['human'], + approval: 'policy' + }), + expect.objectContaining({ method: 'mcp.listPublic', possibleEffects: ['read'] }), + expect.objectContaining({ + method: 'mcp.removePublic', + possibleEffects: ['destructive'], + approval: 'policy' + }), + expect.objectContaining({ + method: 'mcp.setPublicStatus', + possibleEffects: ['execution-config'], + approval: 'policy' + }), + expect.objectContaining({ + method: 'mcp.startPublic', + possibleEffects: ['execution-config'], + approval: 'policy' + }), + expect.objectContaining({ + method: 'mcp.stopPublic', + possibleEffects: ['execution-config'], + approval: 'policy' + }), + expect.objectContaining({ + method: 'mcp.updatePublic', + possibleEffects: ['execution-config', 'security-config', 'supply-chain', 'credential'], + approval: 'policy' + }), expect.objectContaining({ method: 'models.getPublicConfig', possibleEffects: ['read'] }), expect.objectContaining({ method: 'models.invoke', @@ -305,6 +417,12 @@ describe('CLI surface V1', () => { .filter((capability) => capability.approval === 'policy') .map((capability) => capability.method) ).toEqual([ + 'mcp.addPublic', + 'mcp.removePublic', + 'mcp.setPublicStatus', + 'mcp.startPublic', + 'mcp.stopPublic', + 'mcp.updatePublic', 'models.resetConfig', 'models.setPublicConfig', 'models.setStatus', From e9ae0bc342965264968e9180bb25c3a7d78c718d Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 16:17:37 +0800 Subject: [PATCH 21/51] feat(events): add targeted run event hub --- src/main/app/composition.ts | 43 +- src/main/desktop/sessionBinding.ts | 6 + src/main/events/sessionEventRouter.ts | 140 ++++++ src/main/events/typedEventHub.ts | 400 ++++++++++++++++++ .../data/tables/deepchatSessionMetadata.ts | 3 + src/shared/contracts/common.ts | 17 +- src/shared/types/agent-interface.d.ts | 16 +- test/main/desktop/sessionBinding.test.ts | 3 + test/main/events/typedEventHub.test.ts | 273 ++++++++++++ .../tables/deepchatSessionMetadata.test.ts | 56 +++ 10 files changed, 941 insertions(+), 16 deletions(-) create mode 100644 src/main/events/sessionEventRouter.ts create mode 100644 src/main/events/typedEventHub.ts create mode 100644 test/main/events/typedEventHub.test.ts create mode 100644 test/main/session/data/tables/deepchatSessionMetadata.test.ts diff --git a/src/main/app/composition.ts b/src/main/app/composition.ts index c4cfe38ae..777e9eb17 100644 --- a/src/main/app/composition.ts +++ b/src/main/app/composition.ts @@ -240,6 +240,8 @@ import { } from './startupMigrations/sessionDataMigrations' import { activateAppOnMac } from '@/lib/activateApp' import { SessionRuntimeEvents } from '@/session/runtimeEvents' +import { TypedEventHub } from '@/events/typedEventHub' +import { SessionEventRouter } from '@/events/sessionEventRouter' import { createMemoryProviderBindings } from './memoryProviderBindings' import { EpisodeRegistry, @@ -385,6 +387,27 @@ export async function createMainProcessControl(dependencies: { dependencies.onWindowCreated, startupWorkloadCoordinator ) + // No CLI sessions can exist before AppSessionService is ready, so startup events retain the + // renderer compatibility path. The installed resolver below fails closed for missing sessions. + let resolveSessionRunId = (_sessionId: string): string | null | undefined => null + let resolveBoundRendererIds = (_sessionId: string): readonly number[] => [] + const typedEventHub = new TypedEventHub({ + renderer: { + broadcast: (envelope) => windowPresenter.sendToAllWindows(DEEPCHAT_EVENT_CHANNEL, envelope), + send: (webContentsId, envelope) => + (windowPresenter as WindowPresenter).sendToWebContents( + webContentsId, + DEEPCHAT_EVENT_CHANNEL, + envelope + ) + }, + log: logger + }) + const sessionEventRouter = new SessionEventRouter({ + hub: typedEventHub, + resolveSessionRunId: (sessionId) => resolveSessionRunId(sessionId), + getBoundRendererIds: (sessionId) => resolveBoundRendererIds(sessionId) + }) const artifactSpool = new ArtifactSpool({ directory: path.join(app.getPath('userData'), 'local-control', 'artifacts'), log: logger @@ -487,10 +510,7 @@ export async function createMainProcessControl(dependencies: { } } const publishDeepchatEvent = (name: DeepchatEventName, payload: unknown): void => { - windowPresenter.sendToAllWindows( - DEEPCHAT_EVENT_CHANNEL, - createDeepchatEventEnvelope(name, payload) - ) + sessionEventRouter.publish(name, payload) } dependencies.mcpAppSandboxRegistry.setConsentPublisher((windowId, payload) => { windowPresenter.sendToWindow( @@ -553,6 +573,18 @@ export async function createMainProcessControl(dependencies: { appSessionService = new AppSessionService(projectDatabase, sessionData.database, () => projectService.notifyEnvironmentProjectionChanged() ) + resolveSessionRunId = (sessionId) => { + const visited = new Set() + let currentSessionId: string | null = sessionId + while (currentSessionId && visited.size < 32 && !visited.has(currentSessionId)) { + visited.add(currentSessionId) + const session = appSessionService.get(currentSessionId) + if (!session) return undefined + if (session.metadata?.source === 'cli_run') return session.id + currentSessionId = session.parentSessionId ?? null + } + return currentSessionId ? undefined : null + } sessionDataMigrationSQLite = { get appSettingsTable() { return settingsDatabase.appSettingsTable @@ -1467,6 +1499,8 @@ export async function createMainProcessControl(dependencies: { ui: sessionUiPort }) desktopSessionBinding = new DesktopSessionBinding(sessionQuery) + resolveBoundRendererIds = (sessionId) => + desktopSessionBinding.getWebContentsIdsForSession(sessionId) tabPresenter = new TabPresenter(windowPresenter, desktopSessionBinding, () => deeplinkService.processStartupUrl() ) @@ -2027,6 +2061,7 @@ export async function createMainProcessControl(dependencies: { async function destroy(): Promise { await runDestroyStep('cliServer.stop', () => cliServer.stop()) + await runDestroyStep('typedEventHub.close', () => typedEventHub.close()) await runDestroyStep('cliMutationGuard.clear', () => cliMutationGuard.clear()) await runDestroyStep('cliAuditLog.close', () => cliAuditLog.close()) await runDestroyStep('artifactSpool.close', () => artifactSpool.close()) diff --git a/src/main/desktop/sessionBinding.ts b/src/main/desktop/sessionBinding.ts index f4da3b18f..de0dab33c 100644 --- a/src/main/desktop/sessionBinding.ts +++ b/src/main/desktop/sessionBinding.ts @@ -27,6 +27,12 @@ export class DesktopSessionBinding { return this.bindings.get(webContentsId) ?? null } + getWebContentsIdsForSession(sessionId: string): number[] { + return Array.from(this.bindings.entries()).flatMap(([webContentsId, boundSessionId]) => + boundSessionId === sessionId ? [webContentsId] : [] + ) + } + async activate(webContentsId: number, sessionId: string): Promise { this.bind(webContentsId, sessionId) this.projection.notify({ diff --git a/src/main/events/sessionEventRouter.ts b/src/main/events/sessionEventRouter.ts new file mode 100644 index 000000000..bd7beec92 --- /dev/null +++ b/src/main/events/sessionEventRouter.ts @@ -0,0 +1,140 @@ +import { sessionsUpdatedEvent, type DeepchatEventName } from '@shared/contracts/events' +import type { TypedEventHub } from './typedEventHub' + +const RUN_STREAM_EVENTS = new Set([ + 'chat.stream.updated', + 'chat.stream.completed', + 'chat.stream.failed', + 'chat.plan.updated', + 'sessions.status.changed', + 'sessions.compaction.changed', + 'sessions.acp.modes.ready', + 'sessions.acp.commands.ready', + 'sessions.acp.configOptions.ready' +]) + +type SessionEventRouterOptions = Readonly<{ + hub: TypedEventHub + // string: CLI run root; null: known renderer session; undefined: unknown/deleted session. + resolveSessionRunId(sessionId: string): string | null | undefined + getBoundRendererIds(sessionId: string): readonly number[] +}> + +const MAX_OWNERSHIP_CACHE_ENTRIES = 4_096 + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function sessionIdsForEvent(name: DeepchatEventName, payload: unknown): string[] { + if (!isRecord(payload)) return [] + if (name === 'sessions.updated') { + return Array.isArray(payload.sessionIds) + ? payload.sessionIds.filter((value): value is string => typeof value === 'string') + : [] + } + if (typeof payload.sessionId === 'string') return [payload.sessionId] + if (typeof payload.conversationId === 'string') return [payload.conversationId] + return [] +} + +export class SessionEventRouter { + private readonly ownershipCache = new Map() + + constructor(private readonly options: SessionEventRouterOptions) {} + + publish(name: DeepchatEventName, payload: unknown): void { + const sessionIds = sessionIdsForEvent(name, payload) + const ownership = sessionIds.map((sessionId) => ({ + sessionId, + runId: this.resolveSessionRunId(sessionId) + })) + const cliRunOwnership = ownership.flatMap(({ sessionId, runId }) => { + return runId ? [{ sessionId, runId }] : [] + }) + const unknownSessionIds = ownership.flatMap(({ sessionId, runId }) => + runId === undefined ? [sessionId] : [] + ) + if (cliRunOwnership.length === 0 && unknownSessionIds.length === 0) { + this.options.hub.publish(name, payload, { kind: 'renderer-all' }) + return + } + + if (name === 'sessions.updated') { + this.publishSessionsUpdated( + sessionsUpdatedEvent.payload.parse(payload), + cliRunOwnership.map(({ sessionId }) => sessionId), + unknownSessionIds + ) + return + } + + if (cliRunOwnership.length === 0) return + + if (RUN_STREAM_EVENTS.has(name)) { + for (const runId of new Set(cliRunOwnership.map((ownership) => ownership.runId))) { + this.options.hub.publish(name, payload, { kind: 'run', runId }) + } + } + this.publishToBoundRenderers( + name, + payload, + cliRunOwnership.map(({ sessionId }) => sessionId) + ) + } + + private resolveSessionRunId(sessionId: string): string | null | undefined { + if (this.ownershipCache.has(sessionId)) { + const cached = this.ownershipCache.get(sessionId) + this.ownershipCache.delete(sessionId) + this.ownershipCache.set(sessionId, cached) + return cached + } + + const runId = this.options.resolveSessionRunId(sessionId) + this.ownershipCache.set(sessionId, runId) + while (this.ownershipCache.size > MAX_OWNERSHIP_CACHE_ENTRIES) { + const oldest = this.ownershipCache.keys().next().value + if (oldest === undefined) break + this.ownershipCache.delete(oldest) + } + return runId + } + + private publishSessionsUpdated( + payload: ReturnType, + cliRunSessionIds: readonly string[], + unknownSessionIds: readonly string[] + ): void { + const cliRunIds = new Set(cliRunSessionIds) + const unknownIds = new Set(unknownSessionIds) + const rendererSessionIds = payload.sessionIds.filter( + (sessionId) => !cliRunIds.has(sessionId) && !unknownIds.has(sessionId) + ) + if (rendererSessionIds.length > 0) { + this.options.hub.publish( + 'sessions.updated', + { ...payload, sessionIds: rendererSessionIds }, + { kind: 'renderer-all' } + ) + } + for (const sessionId of cliRunIds) { + this.publishToBoundRenderers('sessions.updated', { ...payload, sessionIds: [sessionId] }, [ + sessionId + ]) + } + } + + private publishToBoundRenderers( + name: DeepchatEventName, + payload: unknown, + sessionIds: readonly string[] + ): void { + const rendererIds = new Set( + sessionIds.flatMap((sessionId) => [...this.options.getBoundRendererIds(sessionId)]) + ) + for (const webContentsId of rendererIds) { + this.options.hub.publish(name, payload, { kind: 'renderer', webContentsId }) + } + } +} diff --git a/src/main/events/typedEventHub.ts b/src/main/events/typedEventHub.ts new file mode 100644 index 000000000..db69985f9 --- /dev/null +++ b/src/main/events/typedEventHub.ts @@ -0,0 +1,400 @@ +import { randomUUID } from 'node:crypto' +import { + createDeepchatEventEnvelope, + type DeepchatEventEnvelope, + type DeepchatEventName +} from '@shared/contracts/events' +import { JsonValueSchema, type JsonValue } from '@shared/contracts/json' + +export type TypedEventTarget = + | Readonly<{ kind: 'renderer-all' }> + | Readonly<{ kind: 'renderer'; webContentsId: number }> + | Readonly<{ kind: 'cli-connection'; connectionId: string }> + | Readonly<{ kind: 'request'; connectionId: string; requestId: string }> + | Readonly<{ kind: 'run'; runId: string }> + | Readonly<{ kind: 'internal'; subscriberId: string }> + +export type TypedEventStreamTarget = Exclude< + TypedEventTarget, + { kind: 'renderer-all' } | { kind: 'renderer' } +> + +export type TypedEventRecord = Readonly<{ + target: TypedEventStreamTarget + sequence: number + cursor: string + timestamp: number + event: DeepchatEventName + data: JsonValue +}> + +export type TypedEventRecoveryReason = + | 'cursor_missing' + | 'cursor_expired' + | 'cursor_ahead' + | 'server_restarted' + +export type TypedEventSubscription = Readonly<{ + initialCursor: string + recoveryReason: TypedEventRecoveryReason | null + events: AsyncIterable + close(): void +}> + +export class TypedEventHubOverflowError extends Error { + constructor(message = 'Event subscriber queue overflowed') { + super(message) + this.name = 'TypedEventHubOverflowError' + } +} + +export class TypedEventHubCapacityError extends Error { + constructor(message = 'Event subscriber capacity is exhausted') { + super(message) + this.name = 'TypedEventHubCapacityError' + } +} + +export type TypedEventHubOptions = Readonly<{ + renderer: Readonly<{ + broadcast(envelope: DeepchatEventEnvelope): void | Promise + send(webContentsId: number, envelope: DeepchatEventEnvelope): void | Promise + }> + epoch?: string + now?: () => number + maxStreams?: number + maxSubscribers?: number + maxRetainedEvents?: number + maxRetainedBytes?: number + maxSubscriberEvents?: number + maxSubscriberBytes?: number + log?: Pick +}> + +type StreamState = { + target: TypedEventStreamTarget + sequence: number + retained: TypedEventRecord[] + retainedBytes: number + subscribers: Set + lastUsedAt: number +} + +type PendingNext = Readonly<{ + resolve(value: IteratorResult): void + reject(error: Error): void +}> + +const DEFAULT_MAX_STREAMS = 128 +const DEFAULT_MAX_SUBSCRIBERS = 64 +const DEFAULT_MAX_RETAINED_EVENTS = 256 +const DEFAULT_MAX_RETAINED_BYTES = 4 * 1024 * 1024 +const DEFAULT_MAX_SUBSCRIBER_EVENTS = 64 +const DEFAULT_MAX_SUBSCRIBER_BYTES = 1024 * 1024 + +function targetKey(target: TypedEventStreamTarget): string { + const field = (value: string): string => `${value.length}:${value}` + switch (target.kind) { + case 'cli-connection': + return `connection:${field(target.connectionId)}` + case 'request': + return `request:${field(target.connectionId)}:${field(target.requestId)}` + case 'run': + return `run:${field(target.runId)}` + case 'internal': + return `internal:${field(target.subscriberId)}` + } +} + +function recordSize(record: TypedEventRecord): number { + return Buffer.byteLength(JSON.stringify(record), 'utf8') +} + +class EventSubscriber implements AsyncIterator, AsyncIterable { + private readonly queue: Array<{ record: TypedEventRecord; bytes: number }> = [] + private queuedBytes = 0 + private pending: PendingNext | null = null + private closed = false + private failure: Error | null = null + + constructor( + initialRecords: readonly TypedEventRecord[], + private readonly limits: { maxEvents: number; maxBytes: number }, + private readonly onClose: () => void + ) { + for (const record of initialRecords) { + const bytes = recordSize(record) + this.queue.push({ record, bytes }) + this.queuedBytes += bytes + } + } + + [Symbol.asyncIterator](): AsyncIterator { + return this + } + + next(): Promise> { + if (this.failure) return Promise.reject(this.failure) + const queued = this.queue.shift() + if (queued) { + this.queuedBytes -= queued.bytes + return Promise.resolve({ value: queued.record, done: false }) + } + if (this.closed) return Promise.resolve({ value: undefined, done: true }) + if (this.pending) { + return Promise.reject(new Error('Concurrent event subscription reads are not supported')) + } + return new Promise>((resolve, reject) => { + this.pending = { resolve, reject } + }) + } + + return(): Promise> { + this.close() + return Promise.resolve({ value: undefined, done: true }) + } + + enqueue(record: TypedEventRecord): void { + if (this.closed || this.failure) return + const bytes = recordSize(record) + if (bytes > this.limits.maxBytes) { + this.fail(new TypedEventHubOverflowError('Event exceeds the subscriber byte limit')) + return + } + if (this.pending) { + const pending = this.pending + this.pending = null + pending.resolve({ value: record, done: false }) + return + } + + if ( + this.queue.length >= this.limits.maxEvents || + this.queuedBytes + bytes > this.limits.maxBytes + ) { + this.fail(new TypedEventHubOverflowError()) + return + } + this.queue.push({ record, bytes }) + this.queuedBytes += bytes + } + + fail(error: Error): void { + if (this.closed || this.failure) return + this.failure = error + this.queue.length = 0 + this.queuedBytes = 0 + const pending = this.pending + this.pending = null + this.onClose() + pending?.reject(error) + } + + close(): void { + if (this.closed) return + this.closed = true + this.queue.length = 0 + this.queuedBytes = 0 + const pending = this.pending + this.pending = null + this.onClose() + pending?.resolve({ value: undefined, done: true }) + } +} + +export class TypedEventHub { + private readonly epoch: string + private readonly now: () => number + private readonly maxStreams: number + private readonly maxSubscribers: number + private readonly maxRetainedEvents: number + private readonly maxRetainedBytes: number + private readonly maxSubscriberEvents: number + private readonly maxSubscriberBytes: number + private readonly log: Pick + private readonly streams = new Map() + private subscriberCount = 0 + + constructor(private readonly options: TypedEventHubOptions) { + this.epoch = options.epoch ?? randomUUID() + this.now = options.now ?? Date.now + this.maxStreams = options.maxStreams ?? DEFAULT_MAX_STREAMS + this.maxSubscribers = options.maxSubscribers ?? DEFAULT_MAX_SUBSCRIBERS + this.maxRetainedEvents = options.maxRetainedEvents ?? DEFAULT_MAX_RETAINED_EVENTS + this.maxRetainedBytes = options.maxRetainedBytes ?? DEFAULT_MAX_RETAINED_BYTES + this.maxSubscriberEvents = options.maxSubscriberEvents ?? DEFAULT_MAX_SUBSCRIBER_EVENTS + this.maxSubscriberBytes = options.maxSubscriberBytes ?? DEFAULT_MAX_SUBSCRIBER_BYTES + this.log = options.log ?? console + } + + publish(name: DeepchatEventName, payload: unknown, target: TypedEventTarget): void { + const envelope = createDeepchatEventEnvelope(name, payload) + if (target.kind === 'renderer-all') { + this.deliver(() => this.options.renderer.broadcast(envelope), name, target) + return + } + if (target.kind === 'renderer') { + this.deliver(() => this.options.renderer.send(target.webContentsId, envelope), name, target) + return + } + + const data = JsonValueSchema.parse(envelope.payload) + const state = this.getOrCreateStream(target) + state.sequence += 1 + state.lastUsedAt = this.now() + const record: TypedEventRecord = { + target, + sequence: state.sequence, + cursor: this.cursor(state.sequence), + timestamp: state.lastUsedAt, + event: name, + data + } + const bytes = recordSize(record) + + if (bytes <= this.maxRetainedBytes) { + state.retained.push(record) + state.retainedBytes += bytes + while ( + state.retained.length > this.maxRetainedEvents || + state.retainedBytes > this.maxRetainedBytes + ) { + const removed = state.retained.shift() + if (!removed) break + state.retainedBytes -= recordSize(removed) + } + } + + for (const subscriber of Array.from(state.subscribers)) subscriber.enqueue(record) + } + + subscribe( + target: TypedEventStreamTarget, + options: { afterCursor?: string; signal?: AbortSignal } = {} + ): TypedEventSubscription { + if (this.subscriberCount >= this.maxSubscribers) throw new TypedEventHubCapacityError() + const state = this.getOrCreateStream(target) + const recovery = this.resolveRecovery(state, options.afterCursor) + let initialRecords = + recovery.sequence === null + ? [] + : state.retained.filter((item) => item.sequence > recovery.sequence!) + if (!this.recordsFitSubscriber(initialRecords)) { + recovery.reason = 'cursor_expired' + recovery.sequence = null + initialRecords = [] + } + + let subscriber!: EventSubscriber + let abortListener: (() => void) | undefined + const close = () => { + if (!state.subscribers.delete(subscriber)) return + this.subscriberCount -= 1 + if (abortListener) options.signal?.removeEventListener('abort', abortListener) + this.trimStreamCapacity() + } + subscriber = new EventSubscriber( + initialRecords, + { maxEvents: this.maxSubscriberEvents, maxBytes: this.maxSubscriberBytes }, + close + ) + state.subscribers.add(subscriber) + state.lastUsedAt = this.now() + this.subscriberCount += 1 + if (options.signal?.aborted) subscriber.close() + else { + abortListener = () => subscriber.close() + options.signal?.addEventListener('abort', abortListener, { once: true }) + } + + return { + initialCursor: this.cursor(state.sequence), + recoveryReason: recovery.reason, + events: subscriber, + close: () => subscriber.close() + } + } + + close(): void { + for (const state of this.streams.values()) { + for (const subscriber of Array.from(state.subscribers)) subscriber.close() + } + this.streams.clear() + } + + private deliver( + action: () => void | Promise, + name: DeepchatEventName, + target: TypedEventTarget + ): void { + try { + void Promise.resolve(action()).catch((error) => { + this.log.warn('[TypedEventHub] Renderer delivery failed', { name, target, error }) + }) + } catch (error) { + this.log.warn('[TypedEventHub] Renderer delivery failed', { name, target, error }) + } + } + + private cursor(sequence: number): string { + return `${this.epoch}:${sequence}` + } + + private getOrCreateStream(target: TypedEventStreamTarget): StreamState { + const key = targetKey(target) + const existing = this.streams.get(key) + if (existing) return existing + + this.trimStreamCapacity(1) + const state: StreamState = { + target, + sequence: 0, + retained: [], + retainedBytes: 0, + subscribers: new Set(), + lastUsedAt: this.now() + } + this.streams.set(key, state) + return state + } + + private resolveRecovery( + state: StreamState, + cursor: string | undefined + ): { reason: TypedEventRecoveryReason | null; sequence: number | null } { + if (!cursor) return { reason: 'cursor_missing', sequence: null } + const separator = cursor.lastIndexOf(':') + const epoch = separator > 0 ? cursor.slice(0, separator) : '' + const rawSequence = separator > 0 ? cursor.slice(separator + 1) : '' + const sequence = /^(?:0|[1-9][0-9]*)$/.test(rawSequence) ? Number(rawSequence) : Number.NaN + if (epoch !== this.epoch) return { reason: 'server_restarted', sequence: null } + if (!Number.isSafeInteger(sequence) || sequence < 0) { + return { reason: 'cursor_expired', sequence: null } + } + if (sequence > state.sequence) return { reason: 'cursor_ahead', sequence: null } + + const oldestSequence = state.retained[0]?.sequence ?? state.sequence + 1 + if (sequence < oldestSequence - 1) return { reason: 'cursor_expired', sequence: null } + return { reason: null, sequence } + } + + private recordsFitSubscriber(records: readonly TypedEventRecord[]): boolean { + if (records.length > this.maxSubscriberEvents) return false + let bytes = 0 + for (const record of records) { + bytes += recordSize(record) + if (bytes > this.maxSubscriberBytes) return false + } + return true + } + + private trimStreamCapacity(additionalStreams = 0): void { + while (this.streams.size + additionalStreams > this.maxStreams) { + const candidate = Array.from(this.streams.entries()) + .filter(([, state]) => state.subscribers.size === 0) + .sort((left, right) => left[1].lastUsedAt - right[1].lastUsedAt)[0] + if (!candidate) return + this.streams.delete(candidate[0]) + } + } +} diff --git a/src/main/session/data/tables/deepchatSessionMetadata.ts b/src/main/session/data/tables/deepchatSessionMetadata.ts index c7741613f..2d90bdbc6 100644 --- a/src/main/session/data/tables/deepchatSessionMetadata.ts +++ b/src/main/session/data/tables/deepchatSessionMetadata.ts @@ -84,6 +84,9 @@ export class DeepChatSessionMetadataTable extends BaseTable { scheduledAt: parsed.scheduledAt } } + if (row.source === 'cli_run' && parsed.source === 'cli_run') { + return { source: 'cli_run' } + } } catch { return null } diff --git a/src/shared/contracts/common.ts b/src/shared/contracts/common.ts index f27605d8d..a28052bdc 100644 --- a/src/shared/contracts/common.ts +++ b/src/shared/contracts/common.ts @@ -395,12 +395,17 @@ export const SessionWithStateSchema = z.object({ updatedAt: TimestampMsSchema, revision: RevisionSchema.optional(), metadata: z - .object({ - source: z.literal('cron_job'), - cronJobId: EntityIdSchema, - cronJobRunId: EntityIdSchema, - scheduledAt: TimestampMsSchema - }) + .discriminatedUnion('source', [ + z.object({ + source: z.literal('cron_job'), + cronJobId: EntityIdSchema, + cronJobRunId: EntityIdSchema, + scheduledAt: TimestampMsSchema + }), + z.object({ + source: z.literal('cli_run') + }) + ]) .nullable() .optional(), status: SessionStatusSchema, diff --git a/src/shared/types/agent-interface.d.ts b/src/shared/types/agent-interface.d.ts index bffeab9c5..b10c6cef2 100644 --- a/src/shared/types/agent-interface.d.ts +++ b/src/shared/types/agent-interface.d.ts @@ -829,12 +829,16 @@ export interface CreateDetachedSessionInput { metadata?: SessionMetadata | null } -export type SessionMetadata = { - source: 'cron_job' - cronJobId: string - cronJobRunId: string - scheduledAt: number -} +export type SessionMetadata = + | { + source: 'cron_job' + cronJobId: string + cronJobRunId: string + scheduledAt: number + } + | { + source: 'cli_run' + } // ---- Project Types ---- diff --git a/test/main/desktop/sessionBinding.test.ts b/test/main/desktop/sessionBinding.test.ts index 6d5b33a46..d6dedb8be 100644 --- a/test/main/desktop/sessionBinding.test.ts +++ b/test/main/desktop/sessionBinding.test.ts @@ -13,14 +13,17 @@ describe('DesktopSessionBinding', () => { await binding.activate(1, 'missing') await binding.activate(2, 'available') + binding.bind(3, 'available') expect(binding.getActiveId(1)).toBe('missing') expect(binding.getActiveId(2)).toBe('available') + expect(binding.getWebContentsIdsForSession('available')).toEqual([2, 3]) await expect(binding.getActive(1)).resolves.toBeNull() expect(binding.getActiveId(1)).toBeNull() await binding.deactivate(2) expect(binding.getActiveId(2)).toBeNull() + expect(binding.getWebContentsIdsForSession('available')).toEqual([3]) expect(projection.notify).toHaveBeenLastCalledWith({ sessionIds: [], reason: 'deactivated', diff --git a/test/main/events/typedEventHub.test.ts b/test/main/events/typedEventHub.test.ts new file mode 100644 index 000000000..8ca1a512a --- /dev/null +++ b/test/main/events/typedEventHub.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, it, vi } from 'vitest' +import { SessionEventRouter } from '@/events/sessionEventRouter' +import { + TypedEventHub, + TypedEventHubOverflowError, + type TypedEventRecord +} from '@/events/typedEventHub' + +function createHub(options: Partial[0]> = {}): { + hub: TypedEventHub + broadcast: ReturnType + send: ReturnType +} { + const broadcast = vi.fn() + const send = vi.fn() + return { + hub: new TypedEventHub({ + renderer: { broadcast, send }, + epoch: 'test-epoch', + now: () => 123, + ...options + }), + broadcast, + send + } +} + +async function nextEvent(events: AsyncIterable): Promise { + const result = await events[Symbol.asyncIterator]().next() + if (result.done) throw new Error('Expected an event') + return result.value +} + +describe('TypedEventHub', () => { + it('delivers renderer targets without creating stream subscriptions', () => { + const { hub, broadcast, send } = createHub() + + hub.publish( + 'sessions.status.changed', + { sessionId: 'session-1', status: 'generating', version: 1 }, + { kind: 'renderer-all' } + ) + hub.publish( + 'sessions.status.changed', + { sessionId: 'session-2', status: 'idle', version: 2 }, + { kind: 'renderer', webContentsId: 7 } + ) + + expect(broadcast).toHaveBeenCalledWith({ + name: 'sessions.status.changed', + payload: { sessionId: 'session-1', status: 'generating', version: 1 } + }) + expect(send).toHaveBeenCalledWith(7, { + name: 'sessions.status.changed', + payload: { sessionId: 'session-2', status: 'idle', version: 2 } + }) + }) + + it('isolates run targets and replays retained events after a cursor', async () => { + const { hub } = createHub() + const runOne = hub.subscribe({ kind: 'run', runId: 'run-1' }) + const runTwo = hub.subscribe({ kind: 'run', runId: 'run-2' }) + + hub.publish( + 'sessions.status.changed', + { sessionId: 'run-1', status: 'generating', version: 1 }, + { kind: 'run', runId: 'run-1' } + ) + const first = await nextEvent(runOne.events) + expect(first.cursor).toBe('test-epoch:1') + + const replay = hub.subscribe( + { kind: 'run', runId: 'run-1' }, + { afterCursor: runOne.initialCursor } + ) + expect(replay.recoveryReason).toBeNull() + await expect(nextEvent(replay.events)).resolves.toEqual(first) + + const runTwoIterator = runTwo.events[Symbol.asyncIterator]() + const pendingRunTwo = runTwoIterator.next() + runTwo.close() + await expect(pendingRunTwo).resolves.toEqual({ value: undefined, done: true }) + }) + + it('requires a snapshot when a cursor belongs to another process epoch', () => { + const { hub } = createHub() + const subscription = hub.subscribe( + { kind: 'run', runId: 'run-1' }, + { afterCursor: 'old-epoch:42' } + ) + + expect(subscription.recoveryReason).toBe('server_restarted') + expect(subscription.initialCursor).toBe('test-epoch:0') + }) + + it('terminates a slow subscriber instead of growing its queue', async () => { + const { hub } = createHub({ maxSubscriberEvents: 1, maxSubscriberBytes: 64 * 1024 }) + const subscription = hub.subscribe({ kind: 'run', runId: 'run-1' }) + + hub.publish( + 'sessions.status.changed', + { sessionId: 'run-1', status: 'generating', version: 1 }, + { kind: 'run', runId: 'run-1' } + ) + hub.publish( + 'sessions.status.changed', + { sessionId: 'run-1', status: 'idle', version: 2 }, + { kind: 'run', runId: 'run-1' } + ) + + await expect(nextEvent(subscription.events)).rejects.toBeInstanceOf(TypedEventHubOverflowError) + }) + + it('rejects an oversized event even while the subscriber is waiting', async () => { + const { hub } = createHub({ maxSubscriberBytes: 256 }) + const subscription = hub.subscribe({ kind: 'run', runId: 'run-1' }) + const pending = nextEvent(subscription.events) + + hub.publish( + 'chat.stream.failed', + { + requestId: 'request-1', + sessionId: 'run-1', + messageId: 'message-1', + failedAt: 123, + error: 'x'.repeat(512) + }, + { kind: 'run', runId: 'run-1' } + ) + + await expect(pending).rejects.toBeInstanceOf(TypedEventHubOverflowError) + }) +}) + +describe('SessionEventRouter', () => { + it('keeps CLI run content off all-window broadcasts', async () => { + const { hub, broadcast, send } = createHub() + const router = new SessionEventRouter({ + hub, + resolveSessionRunId: (sessionId) => (sessionId === 'cli-run' ? 'cli-run' : null), + getBoundRendererIds: (sessionId) => (sessionId === 'cli-run' ? [9] : []) + }) + const subscription = hub.subscribe({ kind: 'run', runId: 'cli-run' }) + + router.publish('chat.stream.updated', { + kind: 'snapshot', + requestId: 'request-1', + sessionId: 'cli-run', + messageId: 'message-1', + updatedAt: 123, + blocks: [] + }) + + expect(broadcast).not.toHaveBeenCalled() + expect(send).toHaveBeenCalledWith(9, { + name: 'chat.stream.updated', + payload: { + kind: 'snapshot', + requestId: 'request-1', + sessionId: 'cli-run', + messageId: 'message-1', + updatedAt: 123, + blocks: [] + } + }) + await expect(nextEvent(subscription.events)).resolves.toMatchObject({ + target: { kind: 'run', runId: 'cli-run' }, + event: 'chat.stream.updated' + }) + }) + + it('filters CLI run IDs from mixed session-list notifications', () => { + const { hub, broadcast, send } = createHub() + const router = new SessionEventRouter({ + hub, + resolveSessionRunId: (sessionId) => (sessionId === 'cli-run' ? 'cli-run' : null), + getBoundRendererIds: () => [9] + }) + + router.publish('sessions.updated', { + sessionIds: ['normal', 'cli-run'], + reason: 'updated' + }) + + expect(broadcast).toHaveBeenCalledWith({ + name: 'sessions.updated', + payload: { sessionIds: ['normal'], reason: 'updated' } + }) + expect(send).toHaveBeenCalledWith(9, { + name: 'sessions.updated', + payload: { sessionIds: ['cli-run'], reason: 'updated' } + }) + }) + + it('suppresses full transcript notifications from CLI event streams', async () => { + const { hub, broadcast, send } = createHub() + const router = new SessionEventRouter({ + hub, + resolveSessionRunId: () => 'cli-run', + getBoundRendererIds: () => [9] + }) + const subscription = hub.subscribe({ kind: 'run', runId: 'cli-run' }) + + router.publish('sessions.messages.changed', { + sessionId: 'cli-run', + messages: [], + version: 1 + }) + + expect(broadcast).not.toHaveBeenCalled() + expect(send).toHaveBeenCalledTimes(1) + const iterator = subscription.events[Symbol.asyncIterator]() + const pending = iterator.next() + subscription.close() + await expect(pending).resolves.toEqual({ value: undefined, done: true }) + }) + + it('routes descendant session events to the root CLI run', async () => { + const { hub, broadcast } = createHub() + const resolveSessionRunId = vi.fn((sessionId: string) => + sessionId === 'child-session' ? 'root-run' : null + ) + const router = new SessionEventRouter({ + hub, + resolveSessionRunId, + getBoundRendererIds: () => [] + }) + const subscription = hub.subscribe({ kind: 'run', runId: 'root-run' }) + + router.publish('chat.stream.completed', { + requestId: 'request-1', + sessionId: 'child-session', + messageId: 'message-1', + completedAt: 123 + }) + router.publish('sessions.status.changed', { + sessionId: 'child-session', + status: 'idle', + version: 2 + }) + + expect(broadcast).not.toHaveBeenCalled() + expect(resolveSessionRunId).toHaveBeenCalledTimes(1) + await expect(nextEvent(subscription.events)).resolves.toMatchObject({ + target: { kind: 'run', runId: 'root-run' }, + event: 'chat.stream.completed' + }) + await expect(nextEvent(subscription.events)).resolves.toMatchObject({ + target: { kind: 'run', runId: 'root-run' }, + event: 'sessions.status.changed' + }) + }) + + it('fails closed for late events from an unknown or deleted session', () => { + const { hub, broadcast, send } = createHub() + const router = new SessionEventRouter({ + hub, + resolveSessionRunId: () => undefined, + getBoundRendererIds: () => [] + }) + + router.publish('chat.stream.failed', { + requestId: 'request-1', + sessionId: 'deleted-session', + messageId: 'message-1', + failedAt: 123, + error: 'late failure' + }) + + expect(broadcast).not.toHaveBeenCalled() + expect(send).not.toHaveBeenCalled() + }) +}) diff --git a/test/main/session/data/tables/deepchatSessionMetadata.test.ts b/test/main/session/data/tables/deepchatSessionMetadata.test.ts new file mode 100644 index 000000000..2f4b3e81a --- /dev/null +++ b/test/main/session/data/tables/deepchatSessionMetadata.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, expect, it } from 'vitest' +import { Database, nativeSqliteDescribeIf } from '../../../nativeSqliteHarness' + +const sqliteModule = await import('better-sqlite3-multiple-ciphers').catch(() => null) +const tableModule = sqliteModule + ? await import('@/session/data/tables/deepchatSessionMetadata').catch(() => null) + : null +const DeepChatSessionMetadataTable = tableModule?.DeepChatSessionMetadataTable +const DatabaseCtor = Database! +const TableCtor = DeepChatSessionMetadataTable! +const describeIfSqlite = nativeSqliteDescribeIf( + Boolean(DeepChatSessionMetadataTable), + 'Session metadata native table module is unavailable' +) + +describeIfSqlite('DeepChatSessionMetadataTable', () => { + let db: InstanceType | null + let table: InstanceType + + beforeEach(() => { + db = new DatabaseCtor(':memory:') + table = new TableCtor(db) + table.createTable() + }) + + afterEach(() => { + db?.close() + db = null + }) + + it('round-trips CLI run ownership without adding a second run table', () => { + table.upsert('session-1', { source: 'cli_run' }, 123) + + expect(table.get('session-1')).toEqual({ source: 'cli_run' }) + }) + + it('preserves scheduled-run metadata compatibility', () => { + table.upsert( + 'session-1', + { + source: 'cron_job', + cronJobId: 'job-1', + cronJobRunId: 'job-run-1', + scheduledAt: 100 + }, + 123 + ) + + expect(table.get('session-1')).toEqual({ + source: 'cron_job', + cronJobId: 'job-1', + cronJobRunId: 'job-run-1', + scheduledAt: 100 + }) + }) +}) From 4539b4b5f77865e99084a3948d1d592c3fa59ece Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 16:38:27 +0800 Subject: [PATCH 22/51] feat(cli): add detached agent runs --- .../architecture/local-control-plane/tasks.md | 10 +- src/main/app/composition.ts | 18 +- src/main/cli/index.ts | 1 + src/main/cli/runService.ts | 471 +++++++++++++++++ src/main/cli/server.ts | 10 +- src/main/cli/surface.ts | 71 +++ src/main/events/sessionEventRouter.ts | 21 +- src/shared/contracts/events.ts | 13 + src/shared/contracts/events/runs.events.ts | 74 +++ src/shared/contracts/localControl.ts | 7 + src/shared/contracts/routes.ts | 11 + src/shared/contracts/routes/runs.routes.ts | 147 ++++++ test/main/cli/runService.test.ts | 472 ++++++++++++++++++ test/main/cli/server.test.ts | 45 +- test/main/cli/surface.test.ts | 29 ++ test/main/events/typedEventHub.test.ts | 9 +- 16 files changed, 1371 insertions(+), 38 deletions(-) create mode 100644 src/main/cli/runService.ts create mode 100644 src/shared/contracts/events/runs.events.ts create mode 100644 src/shared/contracts/routes/runs.routes.ts create mode 100644 test/main/cli/runService.test.ts diff --git a/docs/architecture/local-control-plane/tasks.md b/docs/architecture/local-control-plane/tasks.md index 221d66423..25d37fbb7 100644 --- a/docs/architecture/local-control-plane/tasks.md +++ b/docs/architecture/local-control-plane/tasks.md @@ -65,11 +65,11 @@ ## Events and Agent Runs -- [ ] Add explicit renderer/connection/request/run Event Hub targets. -- [ ] Add bounded queues, ordering, overflow, disconnect, and recovery semantics. -- [ ] Compose detached session creation with initial-turn execution. -- [ ] Add owned status, event streaming, result recovery, and idempotent cancellation. -- [ ] Add event-isolation, backpressure, detached-recovery, recursion-denial, and cancellation tests. +- [x] Add explicit renderer/connection/request/run Event Hub targets. +- [x] Add bounded queues, ordering, overflow, disconnect, and recovery semantics. +- [x] Compose detached session creation with initial-turn execution. +- [x] Add owned status, event streaming, result recovery, and idempotent cancellation. +- [x] Add event-isolation, backpressure, detached-recovery, recursion-denial, and cancellation tests. ## Packaging and Agent Use diff --git a/src/main/app/composition.ts b/src/main/app/composition.ts index 777e9eb17..9cd96e09d 100644 --- a/src/main/app/composition.ts +++ b/src/main/app/composition.ts @@ -221,6 +221,7 @@ import { CliMutationGuard, CliOcrService, CliRequestPolicy, + CliRunService, CliServer, CliSkillService, createArtifactRoutes, @@ -367,6 +368,7 @@ export async function createMainProcessControl(dependencies: { let cliSkillService: CliSkillService let cliMutationGuard: CliMutationGuard let cliRequestPolicy: CliRequestPolicy + let cliRunService: CliRunService let hasInitialized = false let databaseMaintenanceState: 'running' | 'maintenance' | 'failed' = 'running' let appLifecycleState: 'starting' | 'running' | 'stopping' | 'stopped' = 'starting' @@ -434,6 +436,10 @@ export async function createMainProcessControl(dependencies: { return output }, dispatchStream: async (method, input, caller, requestId, signal, emit) => { + if (cliRunService?.handlesStream(method)) { + assertRouteAllowedDuringDatabaseMaintenance(method) + return await cliRunService.dispatchStream(method, input, caller, signal, emit) + } if (!cliComputeService) throw new Error('CLI compute service is not ready') assertRouteAllowedDuringDatabaseMaintenance(method) return await cliComputeService.dispatchStream(method, input, caller, requestId, signal, emit) @@ -1651,6 +1657,14 @@ export async function createMainProcessControl(dependencies: { permissions: sessionPermissionPort, agentLifecycle }) + cliRunService = new CliRunService({ + lifecycle: sessionLifecycle, + turn: sessionTurn, + projection: sessionQuery, + sessions: appSessionService, + eventHub: typedEventHub, + log: logger + }) sessionHistorySearch = new SessionHistorySearch(sessionData.database, appSessionService) agentSessionExportService = new AgentSessionExportService({ agentManager: agentManager, @@ -2456,6 +2470,7 @@ export async function createMainProcessControl(dependencies: { }, log: logger }) + const cliRunRoutes = cliRunService.createRoutes() routeDispatcher = createRouteDispatcher({ appDatabaseMaintenance: { assertRouteAllowed: (routeName) => assertRouteAllowedDuringDatabaseMaintenance(routeName) @@ -2496,7 +2511,8 @@ export async function createMainProcessControl(dependencies: { cliComputeRoutes, cliProviderModelAdminRoutes, cliSkillRoutes, - cliMcpAdminRoutes + cliMcpAdminRoutes, + cliRunRoutes ], settingsWindow: windowPresenter, startupWorkloadCoordinator diff --git a/src/main/cli/index.ts b/src/main/cli/index.ts index 5de71d42e..e4916f6c5 100644 --- a/src/main/cli/index.ts +++ b/src/main/cli/index.ts @@ -10,6 +10,7 @@ export { export { CliOcrService, type CliOcrServiceOptions } from './ocrService' export { createCliMcpAdminRoutes, type CliMcpAdminDependencies } from './mcpAdminRoutes' export { CliSkillService, type CliSkillServiceOptions } from './skillService' +export { CliRunService, type CliRunServiceOptions } from './runService' export { createCliRoutes, type CliRuntimeStatus } from './routes' export { createCliProviderModelAdminRoutes, diff --git a/src/main/cli/runService.ts b/src/main/cli/runService.ts new file mode 100644 index 000000000..d2fc76b1c --- /dev/null +++ b/src/main/cli/runService.ts @@ -0,0 +1,471 @@ +import type { + ChatMessagePageResult, + ChatMessageRecord, + CreateDetachedSessionInput, + MessagePageCursor, + MessageStartResult, + SessionRecord, + SessionWithState +} from '@shared/types/agent-interface' +import { + eventsSubscribeRoute, + RUN_MESSAGE_MAX_TEXT_BYTES, + runsCancelRoute, + runsGetRoute, + sessionsRunDetachedRoute, + type EventsSubscribeInput, + type PublicRunMessage, + type PublicRunSnapshot, + type RunDetachedInput, + type RunGetInput +} from '@shared/contracts/routes' +import { + runsCancelRequestedEvent, + runsCreatedEvent, + runsSnapshotEvent, + runsTurnAcceptedEvent, + runsTurnFailedEvent +} from '@shared/contracts/events' +import { extractUserMessageInput } from '@/session/data/userMessageContent' +import { buildAssistantResponseMarkdown } from '@/agent/deepchat/runtime/sessionUpdates' +import type { AssistantMessageBlock } from '@shared/types/agent-interface' +import { + createRouteMap, + type CliRouteCaller, + type DeepchatRouteMap, + type RouteCaller +} from '@/routes/routeRegistry' +import type { TypedEventHub } from '@/events/typedEventHub' +import { TypedEventHubCapacityError, TypedEventHubOverflowError } from '@/events/typedEventHub' +import { CliRequestError } from './errors' +import type { CliStreamEmitter } from './server' + +const DEFAULT_MESSAGE_LIMIT = 50 +const MAX_PUBLIC_ERROR_CHARACTERS = 4_096 +const RUN_SNAPSHOT_MESSAGE_BUDGET_BYTES = 8 * 1024 * 1024 + +type RunLifecyclePort = Readonly<{ + createDetachedSession(input: CreateDetachedSessionInput): Promise +}> + +type RunTurnPort = Readonly<{ + sendMessage( + sessionId: string, + content: string, + options?: { maxProviderRounds?: number } + ): Promise + cancelGeneration(sessionId: string): Promise +}> + +type RunProjectionPort = Readonly<{ + getSession(sessionId: string): Promise + listMessagesPage( + sessionId: string, + options?: { limit?: number; cursor?: MessagePageCursor | null } + ): Promise +}> + +type RunSessionStorePort = Readonly<{ + get(sessionId: string): SessionRecord | null +}> + +export type CliRunServiceOptions = Readonly<{ + lifecycle: RunLifecyclePort + turn: RunTurnPort + projection: RunProjectionPort + sessions: RunSessionStorePort + eventHub: TypedEventHub + now?: () => number + log?: Pick +}> + +function requireCliCaller(caller: RouteCaller): CliRouteCaller { + if (caller.kind !== 'cli') { + throw new CliRequestError('permission_denied', 'Run routes require a CLI caller', { + httpStatus: 403 + }) + } + return caller +} + +function truncateUtf8(value: string, maxBytes: number): { value: string; truncated: boolean } { + if (Buffer.byteLength(value, 'utf8') <= maxBytes) return { value, truncated: false } + const output: string[] = [] + let bytes = 0 + for (const character of value) { + const characterBytes = Buffer.byteLength(character, 'utf8') + if (bytes + characterBytes > maxBytes) break + output.push(character) + bytes += characterBytes + } + return { value: output.join(''), truncated: true } +} + +function messageText(message: ChatMessageRecord): string { + if (message.role === 'user') return extractUserMessageInput(message.content).text + try { + const blocks = JSON.parse(message.content) as AssistantMessageBlock[] + return Array.isArray(blocks) ? buildAssistantResponseMarkdown(blocks) : message.content + } catch { + return message.content + } +} + +function toPublicMessage(message: ChatMessageRecord): PublicRunMessage { + const text = truncateUtf8(messageText(message), RUN_MESSAGE_MAX_TEXT_BYTES) + return { + id: message.id, + role: message.role, + status: message.status, + text: text.value, + textTruncated: text.truncated, + createdAt: message.createdAt, + updatedAt: message.updatedAt + } +} + +function projectMessagePage( + page: ChatMessagePageResult +): Pick { + const messages: PublicRunMessage[] = [] + let serializedBytes = 2 + let firstIncludedIndex = page.messages.length + + for (let index = page.messages.length - 1; index >= 0; index -= 1) { + const message = toPublicMessage(page.messages[index]) + const messageBytes = Buffer.byteLength(JSON.stringify(message), 'utf8') + const separatorBytes = messages.length > 0 ? 1 : 0 + if (serializedBytes + separatorBytes + messageBytes > RUN_SNAPSHOT_MESSAGE_BUDGET_BYTES) break + messages.unshift(message) + serializedBytes += separatorBytes + messageBytes + firstIncludedIndex = index + } + + const omittedFromPage = firstIncludedIndex > 0 + const hasMore = page.hasMore || omittedFromPage + const firstIncluded = page.messages[firstIncludedIndex] + const nextCursor = omittedFromPage + ? firstIncluded + ? { orderSeq: firstIncluded.orderSeq, id: firstIncluded.id } + : page.nextCursor + : page.nextCursor + + return { messages, nextCursor: hasMore ? nextCursor : null, hasMore } +} + +function publicErrorMessage(error: unknown): string { + const message = + error instanceof Error && error.message.trim() ? error.message.trim() : 'Unknown error' + return message.slice(0, MAX_PUBLIC_ERROR_CHARACTERS) +} + +export class CliRunService { + private readonly now: () => number + private readonly log: Pick + + constructor(private readonly options: CliRunServiceOptions) { + this.now = options.now ?? Date.now + this.log = options.log ?? console + } + + createRoutes(): DeepchatRouteMap { + return createRouteMap([ + [ + sessionsRunDetachedRoute.name, + async (rawInput, context) => + await this.startDetachedRun( + sessionsRunDetachedRoute.input.parse(rawInput), + requireCliCaller(context.caller) + ) + ], + [ + runsGetRoute.name, + async (rawInput, context) => + await this.getRun(runsGetRoute.input.parse(rawInput), requireCliCaller(context.caller)) + ], + [ + runsCancelRoute.name, + async (rawInput, context) => + await this.cancelRun( + runsCancelRoute.input.parse(rawInput), + requireCliCaller(context.caller) + ) + ] + ]) + } + + handlesStream(method: string): boolean { + return method === eventsSubscribeRoute.name + } + + async dispatchStream( + method: string, + rawInput: unknown, + caller: CliRouteCaller, + signal: AbortSignal, + emit: CliStreamEmitter + ): Promise { + if (method !== eventsSubscribeRoute.name) { + throw new CliRequestError('not_found', 'Run streaming method is not implemented', { + httpStatus: 404 + }) + } + return await this.subscribeToRun( + eventsSubscribeRoute.input.parse(rawInput), + caller, + signal, + emit + ) + } + + private async startDetachedRun( + input: RunDetachedInput, + caller: CliRouteCaller + ): Promise { + if (caller.principal !== 'human') { + throw new CliRequestError('permission_denied', 'Agents cannot create detached Agent runs', { + httpStatus: 403 + }) + } + + const session = await this.options.lifecycle.createDetachedSession({ + ...(input.agentId ? { agentId: input.agentId } : {}), + title: input.title ?? 'CLI Run', + ...(input.projectDir ? { projectDir: input.projectDir } : {}), + ...(input.providerId ? { providerId: input.providerId } : {}), + ...(input.modelId ? { modelId: input.modelId } : {}), + permissionMode: 'default', + ...(input.activeSkills ? { activeSkills: input.activeSkills } : {}), + ...(input.disabledAgentTools ? { disabledAgentTools: input.disabledAgentTools } : {}), + ...(input.systemPrompt !== undefined + ? { generationSettings: { systemPrompt: input.systemPrompt } } + : {}), + metadata: { source: 'cli_run' } + }) + const runId = session.id + this.options.eventHub.publish( + runsCreatedEvent.name, + { + runId, + sessionId: session.id, + status: session.status, + createdAt: session.createdAt + }, + { kind: 'run', runId } + ) + + let initialTurn: MessageStartResult + try { + initialTurn = await this.options.turn.sendMessage( + session.id, + input.prompt, + input.maxTurns ? { maxProviderRounds: input.maxTurns } : undefined + ) + } catch (error) { + const message = publicErrorMessage(error) + this.log.warn('[CLI] Failed to start detached Agent run', { runId, error }) + this.options.eventHub.publish( + runsTurnFailedEvent.name, + { + runId, + sessionId: session.id, + failedAt: this.now(), + error: message + }, + { kind: 'run', runId } + ) + throw new CliRequestError('conflict', `Detached Agent run could not start: ${message}`, { + httpStatus: 409, + details: { runId, sessionId: session.id } + }) + } + + const acceptedAt = this.now() + this.options.eventHub.publish( + runsTurnAcceptedEvent.name, + { + runId, + sessionId: session.id, + requestId: initialTurn.requestId, + messageId: initialTurn.messageId, + acceptedAt + }, + { kind: 'run', runId } + ) + const acceptedSession = await this.requireRunSnapshot(runId) + return sessionsRunDetachedRoute.output.parse({ + runId, + sessionId: session.id, + status: acceptedSession.status, + requestId: initialTurn.requestId, + messageId: initialTurn.messageId, + createdAt: session.createdAt + }) + } + + private async getRun(input: RunGetInput, caller: CliRouteCaller): Promise { + this.requireOwnedRun(input.runId, caller) + return await this.buildSnapshot(input.runId, input.limit, input.cursor) + } + + private async cancelRun(input: { runId: string }, caller: CliRouteCaller): Promise { + this.requireOwnedRun(input.runId, caller) + const before = await this.requireRunSnapshot(input.runId) + const cancelRequested = before.status === 'generating' + if (cancelRequested) { + await this.options.turn.cancelGeneration(input.runId) + this.options.eventHub.publish( + runsCancelRequestedEvent.name, + { + runId: input.runId, + sessionId: input.runId, + requestedAt: this.now() + }, + { kind: 'run', runId: input.runId } + ) + } + const after = cancelRequested ? await this.requireRunSnapshot(input.runId) : before + return runsCancelRoute.output.parse({ + runId: input.runId, + cancelRequested, + status: after.status + }) + } + + private async subscribeToRun( + input: EventsSubscribeInput, + caller: CliRouteCaller, + signal: AbortSignal, + emit: CliStreamEmitter + ): Promise { + const session = this.requireOwnedRun(input.runId, caller) + if (session.metadata?.source !== 'cli_run') { + throw new CliRequestError('not_found', 'Run was not found', { httpStatus: 404 }) + } + let subscription + try { + subscription = this.options.eventHub.subscribe( + { kind: 'run', runId: input.runId }, + { ...(input.cursor ? { afterCursor: input.cursor } : {}), signal } + ) + } catch (error) { + if (error instanceof TypedEventHubCapacityError) { + throw new CliRequestError('rate_limited', 'Too many active event subscribers', { + httpStatus: 429, + retriable: true + }) + } + throw error + } + + let lastCursor = subscription.initialCursor + try { + const currentRun = await this.buildSnapshot(input.runId, input.messageLimit) + const terminalAtSubscribe = currentRun.status !== 'generating' + if (subscription.recoveryReason) { + const snapshot = runsSnapshotEvent.payload.parse({ + cursor: subscription.initialCursor, + recoveryReason: subscription.recoveryReason, + run: currentRun + }) + await emit(runsSnapshotEvent.name, snapshot, { + runId: input.runId, + cursor: subscription.initialCursor + }) + if (terminalAtSubscribe) { + return eventsSubscribeRoute.output.parse({ runId: input.runId, lastCursor }) + } + } + + let caughtUp = + subscription.recoveryReason !== null || input.cursor === subscription.initialCursor + if (terminalAtSubscribe && caughtUp) { + return eventsSubscribeRoute.output.parse({ runId: input.runId, lastCursor }) + } + for await (const event of subscription.events) { + await emit(event.event, event.data, { + runId: input.runId, + cursor: event.cursor + }) + lastCursor = event.cursor + if (!caughtUp && event.cursor === subscription.initialCursor) { + caughtUp = true + if (terminalAtSubscribe) break + continue + } + if (caughtUp && this.isTerminalEvent(input.runId, event.event, event.data)) break + } + } catch (error) { + if (error instanceof TypedEventHubOverflowError) { + throw new CliRequestError('result_too_large', 'Event subscriber could not keep up', { + httpStatus: 409, + retriable: true, + details: { runId: input.runId, lastCursor } + }) + } + throw error + } finally { + subscription.close() + } + + return eventsSubscribeRoute.output.parse({ runId: input.runId, lastCursor }) + } + + private isTerminalEvent(runId: string, event: string, data: unknown): boolean { + if (!data || typeof data !== 'object' || Array.isArray(data)) return false + const payload = data as { runId?: unknown; sessionId?: unknown; status?: unknown } + if (event === runsTurnFailedEvent.name) return payload.runId === runId + if (payload.sessionId !== runId) return false + if (event === 'chat.stream.completed' || event === 'chat.stream.failed') return true + if (event !== 'sessions.status.changed') return false + const status = payload.status + return status === 'idle' || status === 'error' + } + + private requireOwnedRun(runId: string, caller: CliRouteCaller): SessionRecord { + const session = this.options.sessions.get(runId) + if (!session) { + throw new CliRequestError('not_found', 'Run was not found', { httpStatus: 404 }) + } + const owned = + caller.principal === 'human' + ? session.metadata?.source === 'cli_run' + : caller.conversationId === runId + if (!owned) { + throw new CliRequestError('not_found', 'Run was not found', { httpStatus: 404 }) + } + return session + } + + private async requireRunSnapshot(runId: string): Promise { + const session = await this.options.projection.getSession(runId) + if (!session) { + throw new CliRequestError('not_found', 'Run was not found', { httpStatus: 404 }) + } + return session + } + + private async buildSnapshot( + runId: string, + limit = DEFAULT_MESSAGE_LIMIT, + cursor?: MessagePageCursor | null + ): Promise { + const [session, page] = await Promise.all([ + this.requireRunSnapshot(runId), + this.options.projection.listMessagesPage(runId, { limit, cursor: cursor ?? null }) + ]) + const projectedPage = projectMessagePage(page) + return runsGetRoute.output.parse({ + runId, + sessionId: session.id, + agentId: session.agentId, + title: session.title, + status: session.status, + providerId: session.providerId, + modelId: session.modelId, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + ...projectedPage + }) + } +} diff --git a/src/main/cli/server.ts b/src/main/cli/server.ts index 1e923cf1f..6cc5d182b 100644 --- a/src/main/cli/server.ts +++ b/src/main/cli/server.ts @@ -73,7 +73,11 @@ const AgentCliTokenSchema = z export type AgentCliToken = z.infer -export type CliStreamEmitter = (event: string, data: JsonValue) => Promise +export type CliStreamEmitter = ( + event: string, + data: JsonValue, + context?: Readonly<{ runId?: string; cursor?: string }> +) => Promise export type CliUploadedInputFile = Readonly<{ path: string @@ -824,7 +828,7 @@ export class CliServer { response.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8') response.flushHeaders() let sequence = 0 - const emit: CliStreamEmitter = async (event, data) => { + const emit: CliStreamEmitter = async (event, data, context) => { if (signal.aborted) throw requestAbortError(signal) const parsed = LocalControlEventEnvelopeSchema.safeParse({ protocolVersion: LOCAL_CONTROL_PROTOCOL_VERSION, @@ -832,6 +836,8 @@ export class CliServer { sequence, timestamp: this.now(), requestId, + ...(context?.runId ? { runId: context.runId } : {}), + ...(context?.cursor ? { cursor: context.cursor } : {}), event, data }) diff --git a/src/main/cli/surface.ts b/src/main/cli/surface.ts index 8cca1dd28..53dcfead4 100644 --- a/src/main/cli/surface.ts +++ b/src/main/cli/surface.ts @@ -38,6 +38,7 @@ import { providersTestPublicConnectionRoute, providersUpdatePublicRoute, speechGenerateRoute, + sessionsRunDetachedRoute, settingsGetPublicRoute, settingsUpdatePublicRoute, skillsInstallPublicUrlRoute, @@ -46,6 +47,9 @@ import { skillsSetPublicStatusRoute, skillsUninstallPublicRoute, videosGenerateRoute, + eventsSubscribeRoute, + runsCancelRoute, + runsGetRoute, type CliCapability } from '@shared/contracts/routes' import { SKILL_ARCHIVE_MAX_INPUT_BYTES } from '@shared/types/skill' @@ -304,6 +308,11 @@ const APPROVED_MUTATION_LIMITS = { timeoutMs: 5 * 60_000 } as const satisfies CliRouteLimits +const RUN_CONTROL_LIMITS = { + maxBodyBytes: 16 * 1024, + timeoutMs: 30_000 +} as const satisfies CliRouteLimits + const diagnosticEntry = (contract: RouteContract): CliSurfaceEntry => ({ contract, effect: 'read', @@ -439,6 +448,68 @@ const CLI_SURFACE_V1_ENTRIES = [ timeoutMs: LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS } }, + { + contract: sessionsRunDetachedRoute, + effect: 'compute', + callers: ['human'], + scopes: ['sessions:run'], + transport: 'rpc', + approval: 'never', + auditProjection: (input) => { + const source = + input && typeof input === 'object' && !Array.isArray(input) + ? (input as Record) + : {} + const selected = selectAuditFields(input, ['agentId', 'providerId', 'modelId', 'maxTurns']) + return { + ...selected, + promptCharacters: typeof source.prompt === 'string' ? source.prompt.length : 0, + systemPromptPresent: typeof source.systemPrompt === 'string' + } + }, + // Covers worst-case JSON escaping for both bounded prompt fields and all option lists. + limits: { maxBodyBytes: 5 * 1024 * 1024, timeoutMs: 5 * 60_000 } + }, + { + contract: runsGetRoute, + effect: 'read', + callers: ['human', 'agent'], + scopes: ['runs:read'], + transport: 'rpc', + approval: 'never', + auditProjection: (input) => selectAuditFields(input, ['runId', 'limit']), + limits: RUN_CONTROL_LIMITS + }, + { + contract: runsCancelRoute, + effect: 'local-maintenance', + callers: ['human', 'agent'], + scopes: ['runs:cancel'], + transport: 'rpc', + approval: 'never', + auditProjection: (input) => selectAuditFields(input, ['runId']), + limits: RUN_CONTROL_LIMITS + }, + { + contract: eventsSubscribeRoute, + effect: 'read', + callers: ['human', 'agent'], + scopes: ['runs:read'], + transport: 'stream', + approval: 'never', + auditProjection: (input) => ({ + ...selectAuditFields(input, ['runId', 'messageLimit']), + cursorPresent: + Boolean(input) && + typeof input === 'object' && + !Array.isArray(input) && + typeof (input as Record).cursor === 'string' + }), + limits: { + maxBodyBytes: 16 * 1024, + timeoutMs: LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS + } + }, { contract: providersListPublicRoute, effect: 'read', diff --git a/src/main/events/sessionEventRouter.ts b/src/main/events/sessionEventRouter.ts index bd7beec92..956ccc270 100644 --- a/src/main/events/sessionEventRouter.ts +++ b/src/main/events/sessionEventRouter.ts @@ -61,11 +61,7 @@ export class SessionEventRouter { } if (name === 'sessions.updated') { - this.publishSessionsUpdated( - sessionsUpdatedEvent.payload.parse(payload), - cliRunOwnership.map(({ sessionId }) => sessionId), - unknownSessionIds - ) + this.publishSessionsUpdated(sessionsUpdatedEvent.payload.parse(payload), unknownSessionIds) return } @@ -103,26 +99,17 @@ export class SessionEventRouter { private publishSessionsUpdated( payload: ReturnType, - cliRunSessionIds: readonly string[], unknownSessionIds: readonly string[] ): void { - const cliRunIds = new Set(cliRunSessionIds) const unknownIds = new Set(unknownSessionIds) - const rendererSessionIds = payload.sessionIds.filter( - (sessionId) => !cliRunIds.has(sessionId) && !unknownIds.has(sessionId) - ) - if (rendererSessionIds.length > 0) { + const knownSessionIds = payload.sessionIds.filter((sessionId) => !unknownIds.has(sessionId)) + if (knownSessionIds.length > 0) { this.options.hub.publish( 'sessions.updated', - { ...payload, sessionIds: rendererSessionIds }, + { ...payload, sessionIds: knownSessionIds }, { kind: 'renderer-all' } ) } - for (const sessionId of cliRunIds) { - this.publishToBoundRenderers('sessions.updated', { ...payload, sessionIds: [sessionId] }, [ - sessionId - ]) - } } private publishToBoundRenderers( diff --git a/src/shared/contracts/events.ts b/src/shared/contracts/events.ts index 9e069248b..e35f9dcbf 100644 --- a/src/shared/contracts/events.ts +++ b/src/shared/contracts/events.ts @@ -140,6 +140,13 @@ import { workspaceWatchStatusChangedEvent } from './events/workspace.events' import { liveDelegationChangedEvent } from './events/orchestration.events' +import { + runsCancelRequestedEvent, + runsCreatedEvent, + runsSnapshotEvent, + runsTurnAcceptedEvent, + runsTurnFailedEvent +} from './events/runs.events' export * from './events/browser.events' export * from './events/computerUse.events' @@ -160,6 +167,7 @@ export * from './events/notification.events' export * from './events/oauth.events' export * from './events/orchestration.events' export * from './events/providers.events' +export * from './events/runs.events' export * from './events/settings.events' export * from './events/startup.events' export * from './events/sessions.events' @@ -212,6 +220,11 @@ export const DEEPCHAT_EVENT_CATALOG = { [sessionsAcpModesReadyEvent.name]: sessionsAcpModesReadyEvent, [sessionsAcpCommandsReadyEvent.name]: sessionsAcpCommandsReadyEvent, [sessionsAcpConfigOptionsReadyEvent.name]: sessionsAcpConfigOptionsReadyEvent, + [runsCreatedEvent.name]: runsCreatedEvent, + [runsTurnAcceptedEvent.name]: runsTurnAcceptedEvent, + [runsTurnFailedEvent.name]: runsTurnFailedEvent, + [runsCancelRequestedEvent.name]: runsCancelRequestedEvent, + [runsSnapshotEvent.name]: runsSnapshotEvent, [configLanguageChangedEvent.name]: configLanguageChangedEvent, [configThemeChangedEvent.name]: configThemeChangedEvent, [configSystemThemeChangedEvent.name]: configSystemThemeChangedEvent, diff --git a/src/shared/contracts/events/runs.events.ts b/src/shared/contracts/events/runs.events.ts new file mode 100644 index 000000000..ca041b5f2 --- /dev/null +++ b/src/shared/contracts/events/runs.events.ts @@ -0,0 +1,74 @@ +import { z } from 'zod' +import { + EntityIdSchema, + SessionStatusSchema, + TimestampMsSchema, + defineEventContract +} from '../common' +import { PublicRunSnapshotSchema, RunEventCursorSchema, RunIdSchema } from '../routes/runs.routes' + +export const RunEventRecoveryReasonSchema = z.enum([ + 'cursor_missing', + 'cursor_expired', + 'cursor_ahead', + 'server_restarted' +]) + +export const runsCreatedEvent = defineEventContract({ + name: 'runs.created', + payload: z + .object({ + runId: RunIdSchema, + sessionId: EntityIdSchema, + status: SessionStatusSchema, + createdAt: TimestampMsSchema + }) + .strict() +}) + +export const runsTurnAcceptedEvent = defineEventContract({ + name: 'runs.turn.accepted', + payload: z + .object({ + runId: RunIdSchema, + sessionId: EntityIdSchema, + requestId: EntityIdSchema.nullable(), + messageId: EntityIdSchema.nullable(), + acceptedAt: TimestampMsSchema + }) + .strict() +}) + +export const runsTurnFailedEvent = defineEventContract({ + name: 'runs.turn.failed', + payload: z + .object({ + runId: RunIdSchema, + sessionId: EntityIdSchema, + failedAt: TimestampMsSchema, + error: z.string().min(1).max(4096) + }) + .strict() +}) + +export const runsCancelRequestedEvent = defineEventContract({ + name: 'runs.cancel.requested', + payload: z + .object({ + runId: RunIdSchema, + sessionId: EntityIdSchema, + requestedAt: TimestampMsSchema + }) + .strict() +}) + +export const runsSnapshotEvent = defineEventContract({ + name: 'runs.snapshot', + payload: z + .object({ + cursor: RunEventCursorSchema, + recoveryReason: RunEventRecoveryReasonSchema, + run: PublicRunSnapshotSchema + }) + .strict() +}) diff --git a/src/shared/contracts/localControl.ts b/src/shared/contracts/localControl.ts index ddab74ecb..11bbfa7bf 100644 --- a/src/shared/contracts/localControl.ts +++ b/src/shared/contracts/localControl.ts @@ -121,6 +121,12 @@ const LocalControlRequestIdSchema = z .max(128) .regex(/^[A-Za-z0-9._:-]+$/) +export const LocalControlEventCursorSchema = z + .string() + .min(3) + .max(128) + .regex(/^[A-Za-z0-9_-]+:(?:0|[1-9][0-9]*)$/) + export const LocalControlMethodSchema = z .string() .min(3) @@ -206,6 +212,7 @@ export const LocalControlEventEnvelopeSchema = z timestamp: TimestampMsSchema, requestId: LocalControlRequestIdSchema.optional(), runId: z.string().min(1).max(128).optional(), + cursor: LocalControlEventCursorSchema.optional(), event: z.string().min(1).max(128), data: JsonValueSchema }) diff --git a/src/shared/contracts/routes.ts b/src/shared/contracts/routes.ts index 5c42e58bd..c77852269 100644 --- a/src/shared/contracts/routes.ts +++ b/src/shared/contracts/routes.ts @@ -433,6 +433,12 @@ import { } from './routes/shortcut.routes' import { startupGetBootstrapRoute } from './routes/startup.routes' import { performanceRecordRendererRoute } from './routes/performance.routes' +import { + eventsSubscribeRoute, + runsCancelRoute, + runsGetRoute, + sessionsRunDetachedRoute +} from './routes/runs.routes' import { sessionsActivateRoute, sessionsClearMessagesRoute, @@ -626,6 +632,7 @@ export * from './routes/plugins.routes' export * from './routes/performance.routes' export * from './routes/providers.routes' export * from './routes/project.routes' +export * from './routes/runs.routes' export * from './routes/remote-control.routes' export * from './routes/cronJobs.routes' export * from './routes/settings.routes' @@ -999,6 +1006,10 @@ const DEEPCHAT_ROUTE_CATALOG_PART_4 = { } satisfies Record const DEEPCHAT_ROUTE_CATALOG_PART_5 = { + [sessionsRunDetachedRoute.name]: sessionsRunDetachedRoute, + [runsGetRoute.name]: runsGetRoute, + [runsCancelRoute.name]: runsCancelRoute, + [eventsSubscribeRoute.name]: eventsSubscribeRoute, [artifactsDescribeRoute.name]: artifactsDescribeRoute, [artifactsReadRoute.name]: artifactsReadRoute, [artifactsDeleteRoute.name]: artifactsDeleteRoute, diff --git a/src/shared/contracts/routes/runs.routes.ts b/src/shared/contracts/routes/runs.routes.ts new file mode 100644 index 000000000..89ac7fc2a --- /dev/null +++ b/src/shared/contracts/routes/runs.routes.ts @@ -0,0 +1,147 @@ +import { z } from 'zod' +import { + EntityIdSchema, + MessagePageCursorSchema, + SessionStatusSchema, + TimestampMsSchema, + defineRouteContract +} from '../common' +import { LocalControlEventCursorSchema } from '../localControl' + +export const RUN_PROMPT_MAX_CHARACTERS = 256 * 1024 +export const RUN_SYSTEM_PROMPT_MAX_CHARACTERS = 256 * 1024 +export const RUN_MESSAGE_MAX_TEXT_BYTES = 128 * 1024 +export const RUN_MAX_MESSAGE_PAGE_SIZE = 100 + +export const RunIdSchema = EntityIdSchema.max(128) +export const RunEventCursorSchema = LocalControlEventCursorSchema + +const BoundedIdentifierSchema = z.string().trim().min(1).max(256) +const UniqueIdentifierListSchema = z + .array(BoundedIdentifierSchema) + .max(128) + .superRefine((values, context) => { + const seen = new Set() + values.forEach((value, index) => { + if (seen.has(value)) { + context.addIssue({ code: 'custom', message: `Duplicate value: ${value}`, path: [index] }) + } + seen.add(value) + }) + }) + +export const PublicRunMessageSchema = z + .object({ + id: EntityIdSchema, + role: z.enum(['user', 'assistant']), + status: z.enum(['pending', 'sent', 'error']), + text: z.string(), + textTruncated: z.boolean(), + createdAt: TimestampMsSchema, + updatedAt: TimestampMsSchema + }) + .strict() + +export const PublicRunSnapshotSchema = z + .object({ + runId: RunIdSchema, + sessionId: EntityIdSchema, + agentId: EntityIdSchema, + title: z.string(), + status: SessionStatusSchema, + providerId: z.string(), + modelId: z.string(), + createdAt: TimestampMsSchema, + updatedAt: TimestampMsSchema, + messages: z.array(PublicRunMessageSchema).max(RUN_MAX_MESSAGE_PAGE_SIZE), + nextCursor: MessagePageCursorSchema.nullable(), + hasMore: z.boolean() + }) + .strict() + +export const sessionsRunDetachedRoute = defineRouteContract({ + name: 'sessions.runDetached', + input: z + .object({ + prompt: z + .string() + .min(1) + .max(RUN_PROMPT_MAX_CHARACTERS) + .refine((value) => value.trim().length > 0, { message: 'Prompt must not be blank' }), + agentId: BoundedIdentifierSchema.optional(), + title: z.string().trim().min(1).max(512).optional(), + projectDir: z + .string() + .trim() + .min(1) + .max(4096) + .refine((value) => !value.includes('\0'), { message: 'Project directory contains NUL' }) + .optional(), + providerId: BoundedIdentifierSchema.optional(), + modelId: BoundedIdentifierSchema.optional(), + systemPrompt: z.string().max(RUN_SYSTEM_PROMPT_MAX_CHARACTERS).optional(), + activeSkills: UniqueIdentifierListSchema.optional(), + disabledAgentTools: UniqueIdentifierListSchema.optional(), + maxTurns: z.number().int().min(1).max(100).optional() + }) + .strict(), + output: z + .object({ + runId: RunIdSchema, + sessionId: EntityIdSchema, + status: SessionStatusSchema, + requestId: EntityIdSchema.nullable(), + messageId: EntityIdSchema.nullable(), + createdAt: TimestampMsSchema + }) + .strict() +}) + +export const runsGetRoute = defineRouteContract({ + name: 'runs.get', + input: z + .object({ + runId: RunIdSchema, + cursor: MessagePageCursorSchema.nullable().optional(), + limit: z.number().int().positive().max(RUN_MAX_MESSAGE_PAGE_SIZE).optional() + }) + .strict(), + output: PublicRunSnapshotSchema +}) + +export const runsCancelRoute = defineRouteContract({ + name: 'runs.cancel', + input: z.object({ runId: RunIdSchema }).strict(), + output: z + .object({ + runId: RunIdSchema, + cancelRequested: z.boolean(), + status: SessionStatusSchema + }) + .strict() +}) + +export const eventsSubscribeRoute = defineRouteContract({ + name: 'events.subscribe', + input: z + .object({ + runId: RunIdSchema, + cursor: RunEventCursorSchema.optional(), + messageLimit: z.number().int().positive().max(RUN_MAX_MESSAGE_PAGE_SIZE).optional() + }) + .strict(), + output: z + .object({ + runId: RunIdSchema, + lastCursor: RunEventCursorSchema + }) + .strict() +}) + +export type PublicRunMessage = z.infer +export type PublicRunSnapshot = z.infer +export type RunDetachedInput = z.infer +export type RunDetachedOutput = z.infer +export type RunGetInput = z.infer +export type RunCancelInput = z.infer +export type EventsSubscribeInput = z.infer diff --git a/test/main/cli/runService.test.ts b/test/main/cli/runService.test.ts new file mode 100644 index 000000000..014dd77be --- /dev/null +++ b/test/main/cli/runService.test.ts @@ -0,0 +1,472 @@ +import { describe, expect, it, vi } from 'vitest' +import { + RUN_MESSAGE_MAX_TEXT_BYTES, + RUN_PROMPT_MAX_CHARACTERS, + eventsSubscribeRoute, + runsCancelRoute, + runsGetRoute, + sessionsRunDetachedRoute +} from '@shared/contracts/routes' +import type { + ChatMessageRecord, + SessionRecord, + SessionWithState +} from '@shared/types/agent-interface' +import { DEFAULT_ORCHESTRATION_POLICY } from '@shared/orchestration/policy' +import { LOCAL_CONTROL_MAX_JSON_RESPONSE_BYTES } from '@shared/contracts/localControl' +import { CliRunService, type CliRunServiceOptions } from '@/cli/runService' +import { CliRequestError } from '@/cli/errors' +import { TypedEventHub, type TypedEventRecord } from '@/events/typedEventHub' +import type { CliRouteCaller, RouteContext } from '@/routes/routeRegistry' + +const humanCaller: CliRouteCaller = { + kind: 'cli', + principal: 'human', + connectionId: 'connection-1', + scopes: ['sessions:run', 'runs:read', 'runs:cancel'] +} + +const agentCaller: CliRouteCaller = { + kind: 'cli', + principal: 'agent', + connectionId: 'connection-agent', + conversationId: 'run-1', + expiresAt: 10_000, + scopes: ['runs:read', 'runs:cancel'] +} + +const baseSession: SessionWithState = { + id: 'run-1', + agentId: 'deepchat', + title: 'CLI Run', + projectDir: null, + isPinned: false, + isDraft: false, + sessionKind: 'regular', + parentSessionId: null, + subagentMeta: null, + orchestrationPolicy: DEFAULT_ORCHESTRATION_POLICY, + createdAt: 100, + updatedAt: 101, + metadata: { source: 'cli_run' }, + status: 'generating', + providerId: 'provider-1', + modelId: 'model-1' +} + +function createMessage(overrides: Partial): ChatMessageRecord { + return { + id: 'message-1', + sessionId: 'run-1', + orderSeq: 1, + role: 'user', + content: JSON.stringify({ text: 'hello', files: [] }), + status: 'sent', + isContextEdge: 0, + metadata: JSON.stringify({ provider: 'private-provider-detail' }), + createdAt: 100, + updatedAt: 101, + ...overrides + } +} + +function createHarness( + overrides: { + session?: SessionWithState + storedSession?: SessionRecord | null + messages?: ChatMessageRecord[] + } = {} +): { + service: CliRunService + hub: TypedEventHub + lifecycle: CliRunServiceOptions['lifecycle'] + turn: CliRunServiceOptions['turn'] + projection: CliRunServiceOptions['projection'] + sessions: CliRunServiceOptions['sessions'] +} { + const session = overrides.session ?? baseSession + const lifecycle = { + createDetachedSession: vi.fn(async () => ({ ...session, status: 'idle' as const })) + } + const turn = { + sendMessage: vi.fn(async () => ({ requestId: 'request-1', messageId: 'message-2' })), + cancelGeneration: vi.fn(async () => undefined) + } + const projection = { + getSession: vi.fn(async () => session), + listMessagesPage: vi.fn(async () => ({ + messages: overrides.messages ?? [], + nextCursor: null, + hasMore: false + })) + } + const sessions = { + get: vi.fn(() => + overrides.storedSession === undefined ? (session as SessionRecord) : overrides.storedSession + ) + } + const hub = new TypedEventHub({ + renderer: { broadcast: vi.fn(), send: vi.fn() }, + epoch: 'test-epoch', + now: () => 200 + }) + return { + service: new CliRunService({ + lifecycle, + turn, + projection, + sessions, + eventHub: hub, + now: () => 200, + log: { warn: vi.fn() } + }), + hub, + lifecycle, + turn, + projection, + sessions + } +} + +async function invokeRoute( + service: CliRunService, + method: string, + input: unknown, + caller: RouteContext['caller'] = humanCaller +): Promise { + const route = service.createRoutes().get(method as never) + if (!route) throw new Error(`Missing route: ${method}`) + return await route(input, { caller }) +} + +async function nextEvent(events: AsyncIterable): Promise { + const result = await events[Symbol.asyncIterator]().next() + if (result.done) throw new Error('Expected an event') + return result.value +} + +describe('CliRunService', () => { + it('creates a durable default-permission session before starting its initial turn', async () => { + const { service, hub, lifecycle, turn } = createHarness() + const events = hub.subscribe({ kind: 'run', runId: 'run-1' }) + + await expect( + invokeRoute(service, sessionsRunDetachedRoute.name, { + prompt: 'Run the benchmark', + providerId: 'provider-1', + modelId: 'model-1', + systemPrompt: 'Be concise', + maxTurns: 4 + }) + ).resolves.toEqual({ + runId: 'run-1', + sessionId: 'run-1', + status: 'generating', + requestId: 'request-1', + messageId: 'message-2', + createdAt: 100 + }) + + expect(lifecycle.createDetachedSession).toHaveBeenCalledWith({ + title: 'CLI Run', + providerId: 'provider-1', + modelId: 'model-1', + permissionMode: 'default', + generationSettings: { systemPrompt: 'Be concise' }, + metadata: { source: 'cli_run' } + }) + expect(turn.sendMessage).toHaveBeenCalledWith('run-1', 'Run the benchmark', { + maxProviderRounds: 4 + }) + await expect(nextEvent(events.events)).resolves.toMatchObject({ event: 'runs.created' }) + await expect(nextEvent(events.events)).resolves.toMatchObject({ event: 'runs.turn.accepted' }) + }) + + it('keeps detached creation human-only even if an Agent reaches the handler', async () => { + const { service, lifecycle } = createHarness() + + await expect( + invokeRoute(service, sessionsRunDetachedRoute.name, { prompt: 'recurse' }, agentCaller) + ).rejects.toMatchObject({ code: 'permission_denied' }) + expect(lifecycle.createDetachedSession).not.toHaveBeenCalled() + }) + + it('rejects oversized prompts before creating a durable session', async () => { + const { service, lifecycle } = createHarness() + + await expect( + invokeRoute(service, sessionsRunDetachedRoute.name, { + prompt: 'x'.repeat(RUN_PROMPT_MAX_CHARACTERS + 1) + }) + ).rejects.toBeDefined() + expect(lifecycle.createDetachedSession).not.toHaveBeenCalled() + }) + + it('returns the durable run identity when initial turn startup fails', async () => { + const { service, turn } = createHarness() + vi.mocked(turn.sendMessage).mockRejectedValueOnce(new Error('provider unavailable')) + + await expect( + invokeRoute(service, sessionsRunDetachedRoute.name, { prompt: 'hello' }) + ).rejects.toMatchObject({ + code: 'conflict', + options: { details: { runId: 'run-1', sessionId: 'run-1' } } + }) + }) + + it('returns bounded public message text without internal message metadata', async () => { + const oversized = '🙂'.repeat(RUN_MESSAGE_MAX_TEXT_BYTES) + const { service } = createHarness({ + messages: [ + createMessage({}), + createMessage({ + id: 'message-2', + orderSeq: 2, + role: 'assistant', + content: JSON.stringify([{ type: 'content', content: oversized }]) + }) + ] + }) + + const result = runsGetRoute.output.parse( + await invokeRoute(service, runsGetRoute.name, { runId: 'run-1' }) + ) + + expect(result.messages[0]).toMatchObject({ role: 'user', text: 'hello' }) + expect(result.messages[1].textTruncated).toBe(true) + expect(Buffer.byteLength(result.messages[1].text, 'utf8')).toBeLessThanOrEqual( + RUN_MESSAGE_MAX_TEXT_BYTES + ) + expect(JSON.stringify(result)).not.toContain('private-provider-detail') + }) + + it('keeps escaped transcript pages within the local response byte limit', async () => { + const messages = Array.from({ length: 16 }, (_, index) => + createMessage({ + id: `message-${index + 1}`, + orderSeq: index + 1, + role: 'assistant', + content: JSON.stringify([ + { type: 'content', content: '\0'.repeat(RUN_MESSAGE_MAX_TEXT_BYTES) } + ]) + }) + ) + const { service } = createHarness({ messages }) + + const result = runsGetRoute.output.parse( + await invokeRoute(service, runsGetRoute.name, { runId: 'run-1', limit: messages.length }) + ) + + expect(result.messages.length).toBeLessThan(messages.length) + expect(result.hasMore).toBe(true) + const firstIncluded = messages.find((message) => message.id === result.messages[0]?.id) + expect(result.nextCursor).toEqual({ + orderSeq: firstIncluded?.orderSeq, + id: firstIncluded?.id + }) + expect(Buffer.byteLength(JSON.stringify(result), 'utf8')).toBeLessThan( + LOCAL_CONTROL_MAX_JSON_RESPONSE_BYTES + ) + }) + + it('hides non-CLI sessions from a human while allowing an Agent to inspect its own session', async () => { + const normalSession = { ...baseSession, metadata: undefined } + const { service } = createHarness({ session: normalSession, storedSession: normalSession }) + + await expect(invokeRoute(service, runsGetRoute.name, { runId: 'run-1' })).rejects.toMatchObject( + { code: 'not_found', httpStatus: 404 } + ) + await expect( + invokeRoute(service, runsGetRoute.name, { runId: 'run-1' }, agentCaller) + ).resolves.toMatchObject({ runId: 'run-1' }) + }) + + it('fails closed instead of waiting on a normal session with no run event stream', async () => { + const normalSession = { ...baseSession, metadata: undefined } + const { service } = createHarness({ session: normalSession, storedSession: normalSession }) + + await expect( + service.dispatchStream( + eventsSubscribeRoute.name, + { runId: 'run-1' }, + agentCaller, + new AbortController().signal, + vi.fn() + ) + ).rejects.toMatchObject({ code: 'not_found', httpStatus: 404 }) + }) + + it('makes cancellation idempotent and only emits for an active run', async () => { + const idleSession = { ...baseSession, status: 'idle' as const } + const { service, hub, projection, turn } = createHarness() + vi.mocked(projection.getSession) + .mockResolvedValueOnce(baseSession) + .mockResolvedValueOnce(idleSession) + .mockResolvedValue(idleSession) + const events = hub.subscribe({ kind: 'run', runId: 'run-1' }) + + await expect(invokeRoute(service, runsCancelRoute.name, { runId: 'run-1' })).resolves.toEqual({ + runId: 'run-1', + cancelRequested: true, + status: 'idle' + }) + await expect(invokeRoute(service, runsCancelRoute.name, { runId: 'run-1' })).resolves.toEqual({ + runId: 'run-1', + cancelRequested: false, + status: 'idle' + }) + expect(turn.cancelGeneration).toHaveBeenCalledTimes(1) + await expect(nextEvent(events.events)).resolves.toMatchObject({ + event: 'runs.cancel.requested' + }) + }) + + it('emits a recovery snapshot and then terminates on a targeted completion event', async () => { + const { service, hub } = createHarness() + const emitted: Array<{ event: string; data: unknown; context: unknown }> = [] + let snapshotEmitted!: () => void + const snapshotReady = new Promise((resolve) => { + snapshotEmitted = resolve + }) + const controller = new AbortController() + const result = service.dispatchStream( + eventsSubscribeRoute.name, + { runId: 'run-1' }, + humanCaller, + controller.signal, + async (event, data, context) => { + emitted.push({ event, data, context }) + if (event === 'runs.snapshot') snapshotEmitted() + } + ) + await snapshotReady + + hub.publish( + 'chat.stream.completed', + { + requestId: 'request-1', + sessionId: 'run-1', + messageId: 'message-2', + completedAt: 300 + }, + { kind: 'run', runId: 'run-1' } + ) + + await expect(result).resolves.toEqual({ runId: 'run-1', lastCursor: 'test-epoch:1' }) + expect(emitted.map((entry) => entry.event)).toEqual(['runs.snapshot', 'chat.stream.completed']) + expect(emitted[0].context).toEqual({ runId: 'run-1', cursor: 'test-epoch:0' }) + }) + + it('does not terminate a root run watcher when a descendant session completes', async () => { + const { service, hub } = createHarness() + const emitted: string[] = [] + let snapshotEmitted!: () => void + const snapshotReady = new Promise((resolve) => { + snapshotEmitted = resolve + }) + const result = service.dispatchStream( + eventsSubscribeRoute.name, + { runId: 'run-1' }, + humanCaller, + new AbortController().signal, + async (event) => { + emitted.push(event) + if (event === 'runs.snapshot') snapshotEmitted() + } + ) + await snapshotReady + + hub.publish( + 'chat.stream.completed', + { + requestId: 'request-child', + sessionId: 'child-session', + messageId: 'message-child', + completedAt: 300 + }, + { kind: 'run', runId: 'run-1' } + ) + await vi.waitFor(() => expect(emitted).toContain('chat.stream.completed')) + let settled = false + void result.finally(() => { + settled = true + }) + await Promise.resolve() + expect(settled).toBe(false) + + hub.publish( + 'chat.stream.completed', + { + requestId: 'request-root', + sessionId: 'run-1', + messageId: 'message-root', + completedAt: 301 + }, + { kind: 'run', runId: 'run-1' } + ) + + await expect(result).resolves.toEqual({ runId: 'run-1', lastCursor: 'test-epoch:2' }) + expect(emitted).toEqual(['runs.snapshot', 'chat.stream.completed', 'chat.stream.completed']) + }) + + it('returns immediately after recovering an already-terminal run', async () => { + const idleSession = { ...baseSession, status: 'idle' as const } + const { service } = createHarness({ session: idleSession }) + const emit = vi.fn(async () => undefined) + + await expect( + service.dispatchStream( + eventsSubscribeRoute.name, + { runId: 'run-1' }, + humanCaller, + new AbortController().signal, + emit + ) + ).resolves.toEqual({ runId: 'run-1', lastCursor: 'test-epoch:0' }) + expect(emit).toHaveBeenCalledWith( + 'runs.snapshot', + expect.objectContaining({ recoveryReason: 'cursor_missing' }), + { runId: 'run-1', cursor: 'test-epoch:0' } + ) + }) + + it('disconnects a watcher without cancelling the detached run', async () => { + const { service, turn } = createHarness() + let snapshotEmitted!: () => void + const snapshotReady = new Promise((resolve) => { + snapshotEmitted = resolve + }) + const controller = new AbortController() + const result = service.dispatchStream( + eventsSubscribeRoute.name, + { runId: 'run-1' }, + humanCaller, + controller.signal, + async (event) => { + if (event === 'runs.snapshot') snapshotEmitted() + } + ) + await snapshotReady + controller.abort() + + await expect(result).resolves.toEqual({ runId: 'run-1', lastCursor: 'test-epoch:0' }) + expect(turn.cancelGeneration).not.toHaveBeenCalled() + }) + + it('rejects renderer callers before exposing run existence', async () => { + const { service } = createHarness() + + await expect( + invokeRoute( + service, + runsGetRoute.name, + { runId: 'run-1' }, + { + kind: 'renderer', + webContentsId: 1, + windowId: 1 + } + ) + ).rejects.toBeInstanceOf(CliRequestError) + }) +}) diff --git a/test/main/cli/server.test.ts b/test/main/cli/server.test.ts index 1a7084851..f15af11ae 100644 --- a/test/main/cli/server.test.ts +++ b/test/main/cli/server.test.ts @@ -210,7 +210,11 @@ async function createTestServer( options: { resolveAgentToken?: (token: string) => AgentCliToken | null dispatchOutput?: (method: string) => unknown - streamOutput?: Readonly<{ events: readonly JsonValue[]; result: unknown }> + streamOutput?: Readonly<{ + events: readonly JsonValue[] + contexts?: readonly (Readonly<{ runId?: string; cursor?: string }> | undefined)[] + result: unknown + }> surface?: ReadonlyMap dispatchUpload?: ( method: string, @@ -258,9 +262,15 @@ async function createTestServer( _caller: CliRouteCaller, _requestId: string, _signal: AbortSignal, - emit: (event: string, data: JsonValue) => Promise + emit: ( + event: string, + data: JsonValue, + context?: Readonly<{ runId?: string; cursor?: string }> + ) => Promise ) => { - for (const event of options.streamOutput?.events ?? []) await emit(method, event) + for (const [index, event] of (options.streamOutput?.events ?? []).entries()) { + await emit(method, event, options.streamOutput?.contexts?.[index]) + } return options.streamOutput?.result } } @@ -625,6 +635,10 @@ describe('CLI local transport', () => { { type: 'text_delta', text: 'hello' }, { type: 'stop', reason: 'complete' } ], + contexts: [ + { runId: 'run-1', cursor: 'epoch-1:1' }, + { runId: 'run-1', cursor: 'epoch-1:2' } + ], result: { providerId: 'provider-1', modelId: 'model-1', @@ -635,7 +649,11 @@ describe('CLI local transport', () => { } } }) - const events: JsonValue[] = [] + const events: Array<{ + data: JsonValue + runId?: string + cursor?: string + }> = [] const result = await invokeLocalControlStream( { @@ -650,12 +668,25 @@ describe('CLI local transport', () => { }, signal: new AbortController().signal }, - async (event) => events.push(event.data) + async (event) => + events.push({ + data: event.data, + ...(event.runId ? { runId: event.runId } : {}), + ...(event.cursor ? { cursor: event.cursor } : {}) + }) ) expect(events).toEqual([ - { type: 'text_delta', text: 'hello' }, - { type: 'stop', reason: 'complete' } + { + data: { type: 'text_delta', text: 'hello' }, + runId: 'run-1', + cursor: 'epoch-1:1' + }, + { + data: { type: 'stop', reason: 'complete' }, + runId: 'run-1', + cursor: 'epoch-1:2' + } ]) expect(result).toMatchObject({ ok: true, result: { text: 'hello' } }) expect(server.getStatus().pendingRequests).toBe(0) diff --git a/test/main/cli/surface.test.ts b/test/main/cli/surface.test.ts index 97afe73f7..ad3df436e 100644 --- a/test/main/cli/surface.test.ts +++ b/test/main/cli/surface.test.ts @@ -21,6 +21,7 @@ describe('CLI surface V1', () => { 'cli.doctor', 'cli.status', 'cli.version', + 'events.subscribe', 'images.generate', 'mcp.addPublic', 'mcp.listPublic', @@ -45,6 +46,9 @@ describe('CLI surface V1', () => { 'providers.setCredential', 'providers.testPublicConnection', 'providers.updatePublic', + 'runs.cancel', + 'runs.get', + 'sessions.runDetached', 'settings.getPublic', 'settings.updatePublic', 'skills.installPublicUrl', @@ -264,6 +268,13 @@ describe('CLI surface V1', () => { expect.objectContaining({ method: 'cli.doctor', possibleEffects: ['read'] }), expect.objectContaining({ method: 'cli.status', possibleEffects: ['read'] }), expect.objectContaining({ method: 'cli.version', possibleEffects: ['read'] }), + expect.objectContaining({ + method: 'events.subscribe', + possibleEffects: ['read'], + transport: 'stream', + callers: ['human', 'agent'], + scopes: ['runs:read'] + }), expect.objectContaining({ method: 'images.generate', possibleEffects: ['compute'], @@ -369,6 +380,24 @@ describe('CLI surface V1', () => { possibleEffects: ['execution-config'], approval: 'policy' }), + expect.objectContaining({ + method: 'runs.cancel', + possibleEffects: ['local-maintenance'], + callers: ['human', 'agent'], + scopes: ['runs:cancel'] + }), + expect.objectContaining({ + method: 'runs.get', + possibleEffects: ['read'], + callers: ['human', 'agent'], + scopes: ['runs:read'] + }), + expect.objectContaining({ + method: 'sessions.runDetached', + possibleEffects: ['compute'], + callers: ['human'], + scopes: ['sessions:run'] + }), expect.objectContaining({ method: 'settings.getPublic', possibleEffects: ['read'] }), expect.objectContaining({ method: 'settings.updatePublic', diff --git a/test/main/events/typedEventHub.test.ts b/test/main/events/typedEventHub.test.ts index 8ca1a512a..d4eedcd80 100644 --- a/test/main/events/typedEventHub.test.ts +++ b/test/main/events/typedEventHub.test.ts @@ -169,7 +169,7 @@ describe('SessionEventRouter', () => { }) }) - it('filters CLI run IDs from mixed session-list notifications', () => { + it('keeps CLI runs discoverable without broadcasting their transcript content', () => { const { hub, broadcast, send } = createHub() const router = new SessionEventRouter({ hub, @@ -184,12 +184,9 @@ describe('SessionEventRouter', () => { expect(broadcast).toHaveBeenCalledWith({ name: 'sessions.updated', - payload: { sessionIds: ['normal'], reason: 'updated' } - }) - expect(send).toHaveBeenCalledWith(9, { - name: 'sessions.updated', - payload: { sessionIds: ['cli-run'], reason: 'updated' } + payload: { sessionIds: ['normal', 'cli-run'], reason: 'updated' } }) + expect(send).not.toHaveBeenCalled() }) it('suppresses full transcript notifications from CLI event streams', async () => { From 43442040e634cbdd144acd7a9eb845e35d22233b Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 16:53:05 +0800 Subject: [PATCH 23/51] feat(cli): add detached run commands --- src/cli/args.ts | 330 ++++++++++++++++----- src/cli/format.ts | 32 ++ src/cli/run.ts | 146 +++++++-- src/main/events/sessionEventRouter.ts | 18 +- src/shared/contracts/events/runs.events.ts | 23 ++ test/main/cli/args.test.ts | 111 +++++++ test/main/cli/client.test.ts | 248 ++++++++++++++++ 7 files changed, 788 insertions(+), 120 deletions(-) diff --git a/src/cli/args.ts b/src/cli/args.ts index 9b75187f7..6a86be4f3 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -63,6 +63,16 @@ import { skillsSetPublicStatusRoute, skillsUninstallPublicRoute } from '@shared/contracts/routes/skills.routes' +import { + eventsSubscribeRoute, + RUN_MAX_MESSAGE_PAGE_SIZE, + RunEventCursorSchema, + RunIdSchema, + runsCancelRoute, + runsGetRoute, + sessionsRunDetachedRoute +} from '@shared/contracts/routes/runs.routes' +import { MessagePageCursorSchema } from '@shared/contracts/common' import { JsonValueSchema, type JsonValue } from '@shared/contracts/json' import { LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS } from '@shared/contracts/localControl' import { @@ -124,6 +134,10 @@ export type CliRpcContract = | typeof mcpSetPublicStatusRoute | typeof mcpStartPublicRoute | typeof mcpStopPublicRoute + | typeof sessionsRunDetachedRoute + | typeof runsGetRoute + | typeof runsCancelRoute + | typeof eventsSubscribeRoute export type CliCommandOperation = 'rpc' | 'stream' | 'upload' | 'download' @@ -186,7 +200,11 @@ const COMMANDS = new Map([ ['mcp disable', mcpSetPublicStatusRoute], ['mcp start', mcpStartPublicRoute], ['mcp stop', mcpStopPublicRoute], - ['mcp remove', mcpRemovePublicRoute] + ['mcp remove', mcpRemovePublicRoute], + ['agent run', sessionsRunDetachedRoute], + ['run get', runsGetRoute], + ['run watch', eventsSubscribeRoute], + ['run cancel', runsCancelRoute] ]) const LONG_RUNNING_COMMANDS = new Set([ @@ -198,7 +216,9 @@ const LONG_RUNNING_COMMANDS = new Set([ 'audio transcribe', 'ocr extract', 'ocr clear-cache', - 'skill install' + 'skill install', + 'agent run', + 'run watch' ]) const APPROVED_MUTATION_COMMANDS = new Set([ @@ -289,6 +309,15 @@ const VALUE_DOMAIN_OPTIONS: Readonly> = { if (!parsed.success) throw new CliUsageError('--artifact is not a valid artifact identifier') return parsed.data }, + run: (value) => { + const parsed = RunIdSchema.safeParse(value) + if (!parsed.success) throw new CliUsageError('--run is not a valid run identifier') + return parsed.data + }, + cursor: (value) => { + if (value.length > 4_096) throw new CliUsageError('--cursor exceeds its character limit') + return value + }, mime: (value) => { const normalized = value.trim().toLowerCase() if (!normalized) throw new CliUsageError('--mime must not be empty') @@ -299,6 +328,12 @@ const VALUE_DOMAIN_OPTIONS: Readonly> = { prompt: stringOption, text: stringOption, system: stringOption, + title: stringOption, + 'project-dir': stringOption, + skills: stringOption, + 'disable-tools': stringOption, + 'max-turns': (value) => parseNumberInRange(value, '--max-turns', 1, 100, true), + limit: (value) => parseNumberInRange(value, '--limit', 1, RUN_MAX_MESSAGE_PAGE_SIZE, true), temperature: (value) => parseNumberInRange(value, '--temperature', 0, 2), 'max-tokens': (value) => parseNumberInRange(value, '--max-tokens', 1, 1_000_000, true), size: stringOption, @@ -405,7 +440,26 @@ const COMMAND_DOMAIN_OPTIONS = new Map>([ ['mcp disable', new Set(['name'])], ['mcp start', new Set(['name'])], ['mcp stop', new Set(['name'])], - ['mcp remove', new Set(['name'])] + ['mcp remove', new Set(['name'])], + [ + 'agent run', + new Set([ + 'prompt', + 'stdin', + 'agent', + 'provider', + 'model', + 'system', + 'title', + 'project-dir', + 'skills', + 'disable-tools', + 'max-turns' + ]) + ], + ['run get', new Set(['run', 'cursor', 'limit'])], + ['run watch', new Set(['run', 'cursor', 'limit'])], + ['run cancel', new Set(['run'])] ]) const AUDIO_MIME_BY_EXTENSION: Readonly> = { @@ -478,6 +532,31 @@ function parseTimeout(value: string, source: string): number { return timeoutMs } +function parseIdentifierList(value: string, source: string): string[] { + const items = value + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + if (items.length === 0) throw new CliUsageError(`${source} must contain at least one identifier`) + if (items.length > 128) throw new CliUsageError(`${source} accepts at most 128 identifiers`) + if (new Set(items).size !== items.length) { + throw new CliUsageError(`${source} must not contain duplicate identifiers`) + } + return items +} + +function parseMessagePageCursor(value: string): JsonValue { + let candidate: unknown + try { + candidate = JSON.parse(value) as unknown + } catch { + throw new CliUsageError('--cursor must be a JSON message cursor') + } + const parsed = MessagePageCursorSchema.safeParse(candidate) + if (!parsed.success) throw new CliUsageError('--cursor must be a JSON message cursor') + return parsed.data +} + export function parseCliArguments( argv: readonly string[], env: NodeJS.ProcessEnv = process.env @@ -606,6 +685,14 @@ export function parseCliArguments( const prompt = getString('prompt') const textInput = getString('text') const systemPrompt = getString('system') + const runId = getString('run') + const cursorValue = getString('cursor') + const title = getString('title') + const projectDir = getString('project-dir') + const skillsValue = getString('skills') + const disabledToolsValue = getString('disable-tools') + const maxTurns = getNumber('max-turns') + const messageLimit = getNumber('limit') const temperature = getNumber('temperature') const maxTokens = getNumber('max-tokens') const readStdin = getBoolean('stdin') ?? false @@ -634,7 +721,7 @@ export function parseCliArguments( const providerApiType = getString('api-type') const providerBaseUrl = getString('base-url') const providerEnabled = getBoolean('enabled') - const skillAgentId = getString('agent') + const agentId = getString('agent') const skillUrl = getString('url') const skillName = getString('name') const mcpServerName = getString('name') @@ -695,6 +782,10 @@ export function parseCliArguments( const isMcpStatus = commandKey === 'mcp enable' || commandKey === 'mcp disable' const isMcpRuntime = commandKey === 'mcp start' || commandKey === 'mcp stop' const isMcpRemove = commandKey === 'mcp remove' + const isAgentRun = commandKey === 'agent run' + const isRunGet = commandKey === 'run get' + const isRunWatch = commandKey === 'run watch' + const isRunCancel = commandKey === 'run cancel' const allowedDomainOptions = COMMAND_DOMAIN_OPTIONS.get(commandKey) ?? new Set() const invalidDomainOption = Array.from(domainOptions).find( (option) => !allowedDomainOptions.has(option) @@ -708,6 +799,12 @@ export function parseCliArguments( if (!helpRequested && isModelInvoke && (prompt !== undefined) === readStdin) { throw new CliUsageError('deepchat model invoke requires exactly one of --prompt or --stdin') } + if (!helpRequested && isAgentRun && (prompt !== undefined) === readStdin) { + throw new CliUsageError('deepchat agent run requires exactly one of --prompt or --stdin') + } + if (!helpRequested && (isRunGet || isRunWatch || isRunCancel) && !runId) { + throw new CliUsageError(`deepchat run ${verb} requires --run `) + } if (!helpRequested && isMediaGenerate && (!providerId || !modelId)) { throw new CliUsageError(`deepchat ${domain} ${verb} requires --provider and --model`) } @@ -805,6 +902,21 @@ export function parseCliArguments( throw new CliUsageError(`deepchat mcp ${verb} requires --stdin`) } + const activeSkills = skillsValue ? parseIdentifierList(skillsValue, '--skills') : undefined + const disabledAgentTools = disabledToolsValue + ? parseIdentifierList(disabledToolsValue, '--disable-tools') + : undefined + const runCursor = + cursorValue && isRunGet + ? parseMessagePageCursor(cursorValue) + : cursorValue && isRunWatch + ? (() => { + const parsed = RunEventCursorSchema.safeParse(cursorValue) + if (!parsed.success) throw new CliUsageError('--cursor is not a valid event cursor') + return parsed.data + })() + : undefined + let params: JsonValue = artifactId ? { id: artifactId } : {} if (isProviderList) params = { enabledOnly } if (isProviderTest && providerId) { @@ -849,19 +961,19 @@ export function parseCliArguments( if (isSettingsSet && settingKey && domainOptions.has('value')) { params = { changes: [{ key: settingKey, value: settingValue ?? null }] } } - if (isSkillList) params = skillAgentId ? { agentId: skillAgentId } : {} + if (isSkillList) params = agentId ? { agentId } : {} if (isSkillInstall) { if (inputPath) { contract = skillsInstallUploadRoute params = { - ...(skillAgentId ? { agentId: skillAgentId } : {}), + ...(agentId ? { agentId } : {}), filename: path.basename(inputPath), overwrite } } else if (skillUrl) { contract = skillsInstallPublicUrlRoute params = { - ...(skillAgentId ? { agentId: skillAgentId } : {}), + ...(agentId ? { agentId } : {}), url: skillUrl, overwrite } @@ -869,13 +981,13 @@ export function parseCliArguments( } if (isSkillStatus && skillName) { params = { - ...(skillAgentId ? { agentId: skillAgentId } : {}), + ...(agentId ? { agentId } : {}), name: skillName, enabled: commandKey === 'skill enable' } } if (isSkillRemove && skillName) { - params = { ...(skillAgentId ? { agentId: skillAgentId } : {}), name: skillName } + params = { ...(agentId ? { agentId } : {}), name: skillName } } if (isMcpList) params = {} if ((isMcpAdd || isMcpUpdate) && mcpServerName) params = { serverName: mcpServerName } @@ -883,6 +995,35 @@ export function parseCliArguments( params = { serverName: mcpServerName, enabled: commandKey === 'mcp enable' } } if ((isMcpRuntime || isMcpRemove) && mcpServerName) params = { serverName: mcpServerName } + if (isAgentRun) { + params = { + ...(prompt !== undefined ? { prompt } : {}), + ...(agentId ? { agentId } : {}), + ...(title ? { title } : {}), + ...(projectDir ? { projectDir } : {}), + ...(providerId ? { providerId } : {}), + ...(modelId ? { modelId } : {}), + ...(systemPrompt !== undefined ? { systemPrompt } : {}), + ...(activeSkills ? { activeSkills } : {}), + ...(disabledAgentTools ? { disabledAgentTools } : {}), + ...(maxTurns !== undefined ? { maxTurns } : {}) + } + } + if (isRunGet && runId) { + params = { + runId, + ...(runCursor ? { cursor: runCursor } : {}), + ...(messageLimit !== undefined ? { limit: messageLimit } : {}) + } + } + if (isRunWatch && runId) { + params = { + runId, + ...(runCursor ? { cursor: runCursor } : {}), + ...(messageLimit !== undefined ? { messageLimit } : {}) + } + } + if (isRunCancel && runId) params = { runId } if (isModelInvoke && providerId && modelId) { params = { providerId, @@ -993,7 +1134,7 @@ export function parseCliArguments( ? 'download' : inputPath && (isAudioTranscribe || isOcrExtract || isSkillInstall) ? 'upload' - : isModelInvoke || isMediaGenerate + : isModelInvoke || isMediaGenerate || isRunWatch ? 'stream' : 'rpc', params, @@ -1016,47 +1157,55 @@ export function formatCliHelp(command?: Pick --out [--overwrite]' : ' --id ' - : command.domain === 'model' - ? ' --provider --model (--prompt |--stdin)' - : command.domain === 'image' || command.domain === 'video' - ? ' --provider --model (--prompt |--stdin)' - : command.domain === 'audio' && command.verb === 'speak' - ? ' --provider --model (--text |--stdin)' - : command.domain === 'audio' - ? ' --provider --model (--file |--artifact )' - : command.domain === 'ocr' && command.verb === 'extract' - ? ' (--file |--artifact )' - : command.domain === 'provider' - ? command.verb === 'list' - ? ' [--enabled-only]' - : command.verb === 'add' - ? ' --name --api-type --base-url [--enabled ]' - : command.verb === 'update' - ? ' --provider [--name ] [--api-type ] [--base-url ] [--enabled ]' - : command.verb === 'set-credential' - ? ' --provider --stdin' - : ` --provider ${command.verb === 'test' ? ' [--model ]' : ''}` - : command.domain === 'model' && command.verb !== 'invoke' - ? command.verb === 'list' - ? ' --provider ' - : ` --provider --model ${command.verb === 'config-set' ? ' --stdin' : ''}` - : command.domain === 'settings' && command.verb === 'get' - ? ' [--keys ]' - : command.domain === 'settings' - ? ' --key --value ' - : command.domain === 'skill' - ? command.verb === 'list' - ? ' [--agent ]' - : command.verb === 'install' - ? ' (--file |--url ) [--agent ] [--overwrite]' - : ' --name [--agent ]' - : command.domain === 'mcp' + : command.domain === 'agent' + ? ' (--prompt |--stdin) [--agent ] [--provider ] [--model ]' + : command.domain === 'run' + ? command.verb === 'get' + ? ' --run [--cursor ] [--limit ]' + : command.verb === 'watch' + ? ' --run [--cursor ] [--limit ]' + : ' --run ' + : command.domain === 'model' + ? command.verb === 'invoke' + ? ' --provider --model (--prompt |--stdin)' + : command.verb === 'list' + ? ' --provider ' + : ` --provider --model ${command.verb === 'config-set' ? ' --stdin' : ''}` + : command.domain === 'image' || command.domain === 'video' + ? ' --provider --model (--prompt |--stdin)' + : command.domain === 'audio' && command.verb === 'speak' + ? ' --provider --model (--text |--stdin)' + : command.domain === 'audio' + ? ' --provider --model (--file |--artifact )' + : command.domain === 'ocr' && command.verb === 'extract' + ? ' (--file |--artifact )' + : command.domain === 'provider' + ? command.verb === 'list' + ? ' [--enabled-only]' + : command.verb === 'add' + ? ' --name --api-type --base-url [--enabled ]' + : command.verb === 'update' + ? ' --provider [--name ] [--api-type ] [--base-url ] [--enabled ]' + : command.verb === 'set-credential' + ? ' --provider --stdin' + : ` --provider ${command.verb === 'test' ? ' [--model ]' : ''}` + : command.domain === 'settings' && command.verb === 'get' + ? ' [--keys ]' + : command.domain === 'settings' + ? ' --key --value ' + : command.domain === 'skill' ? command.verb === 'list' - ? '' - : command.verb === 'add' || command.verb === 'update' - ? ' --name --stdin' - : ' --name ' - : '' + ? ' [--agent ]' + : command.verb === 'install' + ? ' (--file |--url ) [--agent ] [--overwrite]' + : ' --name [--agent ]' + : command.domain === 'mcp' + ? command.verb === 'list' + ? '' + : command.verb === 'add' || command.verb === 'update' + ? ' --name --stdin' + : ' --name ' + : '' const commandKey = `${command.domain} ${command.verb}` const optionLines = commandKey === 'model invoke' @@ -1065,42 +1214,55 @@ export function formatCliHelp(command?: Pick Set sampling temperature (0..2)', ' --max-tokens Set the output-token limit' ] - : commandKey === 'image generate' + : commandKey === 'agent run' ? [ - ' --size Set output dimensions', - ' --quality Set low, medium, high, or auto quality', - ' --format Set png, jpeg, or webp output', - ' --compression Set jpeg/webp compression (0..100)', - ' --background Set auto or opaque background', - ' --moderation Set auto or low moderation' + ' --system Override the Agent system prompt', + ' --title Set the durable session title', + ' --project-dir Set the Agent working directory', + ' --skills Activate selected Skills', + ' --disable-tools Disable selected Agent tools', + ' --max-turns Limit provider rounds (1..100)' ] - : commandKey === 'video generate' - ? [ - ' --seconds Set provider-specific clip seconds', - ' --size Set provider-specific dimensions', - ' --ratio Set aspect ratio', - ' --duration Set duration (-1..3600)', - ' --resolution Set output resolution', - ' --watermark Enable or disable watermarking', - ' --audio Enable or disable generated audio' - ] - : commandKey === 'audio speak' - ? [ - ' --voice Select a voice', - ' --format Set mp3, opus, aac, flac, wav, or pcm', - ' --speed Set playback speed (0.25..4)', - ' --instructions Add provider-supported speech guidance' - ] - : commandKey === 'audio transcribe' - ? [' --mime Override the MIME type inferred from --file'] - : commandKey === 'ocr extract' + : commandKey === 'run get' + ? [' --cursor Read the next older message page'] + : commandKey === 'run watch' + ? [' --cursor Resume after an event cursor'] + : commandKey === 'image generate' + ? [ + ' --size Set output dimensions', + ' --quality Set low, medium, high, or auto quality', + ' --format Set png, jpeg, or webp output', + ' --compression Set jpeg/webp compression (0..100)', + ' --background Set auto or opaque background', + ' --moderation Set auto or low moderation' + ] + : commandKey === 'video generate' ? [ - ' --mime Override the MIME type inferred from --file', - ' --backend Select auto or cpu', - ' --page-count Provide a PDF page-count hint', - ' --max-tokens Limit PDF OCR output tokens' + ' --seconds Set provider-specific clip seconds', + ' --size Set provider-specific dimensions', + ' --ratio Set aspect ratio', + ' --duration Set duration (-1..3600)', + ' --resolution Set output resolution', + ' --watermark Enable or disable watermarking', + ' --audio Enable or disable generated audio' ] - : [] + : commandKey === 'audio speak' + ? [ + ' --voice Select a voice', + ' --format Set mp3, opus, aac, flac, wav, or pcm', + ' --speed Set playback speed (0.25..4)', + ' --instructions Add provider-supported speech guidance' + ] + : commandKey === 'audio transcribe' + ? [' --mime Override the MIME type inferred from --file'] + : commandKey === 'ocr extract' + ? [ + ' --mime Override the MIME type inferred from --file', + ' --backend Select auto or cpu', + ' --page-count Provide a PDF page-count hint', + ' --max-tokens Limit PDF OCR output tokens' + ] + : [] return [ `Usage: deepchat ${command.domain} ${command.verb}${commandOptions} [--json|--jsonl] [--timeout ]`, '', @@ -1156,6 +1318,10 @@ export function formatCliHelp(command?: Pick [ + '', + `[${message.role}]${message.textTruncated ? ' (truncated)' : ''}`, + message.text + ]), + ...(result.nextCursor ? ['', `Next cursor: ${JSON.stringify(result.nextCursor)}`] : []) + ].join('\n') + } + case 'runs.cancel': { + const result = contract.output.parse(value) + return result.cancelRequested + ? `Cancellation requested for ${result.runId} (${result.status})` + : `Run ${result.runId} was already ${result.status}` + } + case 'events.subscribe': { + const result = contract.output.parse(value) + return `Run ${result.runId} stream ended at ${result.lastCursor}` + } case 'providers.listPublic': { const result = contract.output.parse(value) return result.providers diff --git a/src/cli/run.ts b/src/cli/run.ts index fbb007857..d765299df 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto' import { JsonValueSchema, type JsonValue } from '@shared/contracts/json' +import { getDeepchatEventContract, RunStreamEventNameSchema } from '@shared/contracts/events' import { LOCAL_CONTROL_AGENT_TOKEN_ENV, createLocalControlFailure, @@ -11,6 +12,10 @@ import { MediaGenerationEventSchema } from '@shared/contracts/routes/media.route import { ModelInvokeEventSchema } from '@shared/contracts/routes/models.routes' import { PUBLIC_MCP_CONFIG_MAX_BYTES } from '@shared/contracts/routes/mcp.routes' import { PROVIDER_CREDENTIAL_MAX_BYTES } from '@shared/contracts/routes/providers.routes' +import { + RUN_PROMPT_MAX_CHARACTERS, + sessionsRunDetachedRoute +} from '@shared/contracts/routes/runs.routes' import { parseCliArguments, formatCliHelp, inferCliOutputMode, type CliOutputMode } from './args' import { loadLocalControlDescriptor, @@ -63,6 +68,52 @@ function writeText(output: WritableOutput, value: string): void { output.write(value.endsWith('\n') ? value : `${value}\n`) } +function sanitizeTerminalText(value: string): string { + const output: string[] = [] + let start = 0 + let offset = 0 + for (const character of value) { + const codePoint = character.codePointAt(0) ?? 0 + const isUnsafeControl = + codePoint === 0x0d || + (codePoint <= 0x1f && codePoint !== 0x09 && codePoint !== 0x0a) || + (codePoint >= 0x7f && codePoint <= 0x9f) + const isBidiControl = + codePoint === 0x061c || + codePoint === 0x200e || + codePoint === 0x200f || + (codePoint >= 0x202a && codePoint <= 0x202e) || + (codePoint >= 0x2066 && codePoint <= 0x2069) + if (isUnsafeControl || isBidiControl) { + if (offset > start) output.push(value.slice(start, offset)) + start = offset + character.length + } + offset += character.length + } + if (start === 0) return value + if (start < value.length) output.push(value.slice(start)) + return output.join('') +} + +function writeHumanText(output: WritableOutput, value: string): void { + writeText(output, sanitizeTerminalText(value)) +} + +function runEventPayloadMatchesTarget( + event: Parameters[0], + data: JsonValue, + expectedRunId: string | undefined +): boolean { + if (!event.event.startsWith('runs.')) return true + if (!data || typeof data !== 'object' || Array.isArray(data)) return false + if (event.event === 'runs.snapshot') { + const run = data.run + if (!run || typeof run !== 'object' || Array.isArray(run)) return false + return data.cursor === event.cursor && run.runId === expectedRunId + } + return data.runId === expectedRunId +} + function parseStdinJsonObject(input: string, label: string): Record { let candidate: unknown try { @@ -98,7 +149,7 @@ function writeClientError( stderr: WritableOutput ): void { if (outputMode === 'text') { - writeText(stderr, `${error.code}: ${error.message}`) + writeHumanText(stderr, `${error.code}: ${error.message}`) return } writeText( @@ -113,13 +164,27 @@ function writeClientError( ) } -function validateStreamEvent(method: string, data: unknown): void { +function parseStreamEventData( + method: string, + event: Parameters[0], + expectedRunId?: string +): JsonValue { + const runEventName = + method === 'events.subscribe' ? RunStreamEventNameSchema.safeParse(event.event) : null const parsed = - method === 'models.invoke' - ? ModelInvokeEventSchema.safeParse(data) - : method === 'images.generate' || method === 'videos.generate' || method === 'speech.generate' - ? MediaGenerationEventSchema.safeParse(data) + method === 'events.subscribe' + ? event.runId === expectedRunId && typeof event.cursor === 'string' && runEventName?.success + ? getDeepchatEventContract(runEventName.data).payload.safeParse(event.data) : null + : event.event !== method + ? null + : method === 'models.invoke' + ? ModelInvokeEventSchema.safeParse(event.data) + : method === 'images.generate' || + method === 'videos.generate' || + method === 'speech.generate' + ? MediaGenerationEventSchema.safeParse(event.data) + : null if (!parsed?.success) { throw new CliClientError( 'internal_error', @@ -127,6 +192,25 @@ function validateStreamEvent(method: string, data: unknown): void { CLI_EXIT_CODES.internal ) } + const data = JsonValueSchema.safeParse(parsed.data) + if (!data.success) { + throw new CliClientError( + 'internal_error', + 'DeepChat emitted a non-JSON stream event', + CLI_EXIT_CODES.internal + ) + } + if ( + method === 'events.subscribe' && + !runEventPayloadMatchesTarget(event, data.data, expectedRunId) + ) { + throw new CliClientError( + 'internal_error', + 'DeepChat emitted an event for another run', + CLI_EXIT_CODES.internal + ) + } + return data.data } export async function runCli( @@ -147,8 +231,8 @@ export async function runCli( const message = error instanceof CliUsageError ? error.message : 'Invalid CLI arguments' const outputMode = inferCliOutputMode(argv, env) if (outputMode === 'text') { - writeText(stderr, message) - writeText(stderr, 'Run: deepchat help commands') + writeHumanText(stderr, message) + writeHumanText(stderr, 'Run: deepchat help commands') } else { writeClientError( new CliClientError('invalid_request', message, CLI_EXIT_CODES.usage), @@ -206,9 +290,12 @@ export async function runCli( controller.signal, parsed.contract.name === 'providers.setCredential' ? PROVIDER_CREDENTIAL_MAX_BYTES - : parsed.contract.name === 'mcp.addPublic' || parsed.contract.name === 'mcp.updatePublic' - ? PUBLIC_MCP_CONFIG_MAX_BYTES - : undefined + : parsed.contract.name === sessionsRunDetachedRoute.name + ? RUN_PROMPT_MAX_CHARACTERS * 4 + : parsed.contract.name === 'mcp.addPublic' || + parsed.contract.name === 'mcp.updatePublic' + ? PUBLIC_MCP_CONFIG_MAX_BYTES + : undefined ) if (!params || typeof params !== 'object' || Array.isArray(params)) { throw new CliClientError( @@ -230,6 +317,9 @@ export async function runCli( case 'speech.generate': params = { ...params, text: input } break + case 'sessions.runDetached': + params = { ...params, prompt: input } + break case 'providers.setCredential': params = { ...params, value: input.replace(/(?:\r\n|\n)$/, '') } break @@ -299,25 +389,29 @@ export async function runCli( } let streamedText = false let streamedTextEndsWithNewline = false + const contractName = parsed.contract.name + const expectedRunId = + contractName === 'events.subscribe' && + validatedInput.data && + typeof validatedInput.data === 'object' && + 'runId' in validatedInput.data && + typeof validatedInput.data.runId === 'string' + ? validatedInput.data.runId + : undefined const onStreamEvent: CliStreamEventHandler = async (event) => { - if (event.event !== parsed.contract?.name) { - throw new CliClientError( - 'internal_error', - 'DeepChat emitted an event for another method', - CLI_EXIT_CODES.internal - ) - } - validateStreamEvent(parsed.contract.name, event.data) + const canonicalData = parseStreamEventData(contractName, event, expectedRunId) if (parsed.outputMode === 'jsonl') { - writeText(stdout, JSON.stringify(event)) + writeText(stdout, JSON.stringify({ ...event, data: canonicalData })) return } - if (parsed.outputMode !== 'text' || parsed.contract.name !== 'models.invoke') return - const parsedEvent = ModelInvokeEventSchema.parse(event.data) + if (parsed.outputMode !== 'text' || contractName !== 'models.invoke') return + const parsedEvent = ModelInvokeEventSchema.parse(canonicalData) if (parsedEvent.type === 'text_delta' && parsedEvent.text) { - stdout.write(parsedEvent.text) + const safeText = sanitizeTerminalText(parsedEvent.text) + if (!safeText) return + stdout.write(safeText) streamedText = true - streamedTextEndsWithNewline = parsedEvent.text.endsWith('\n') + streamedTextEndsWithNewline = safeText.endsWith('\n') } } let response: LocalControlRpcResponse @@ -346,7 +440,7 @@ export async function runCli( if (!response.ok) { if (streamedText && !streamedTextEndsWithNewline) stdout.write('\n') if (parsed.outputMode === 'text') { - writeText(stderr, `${response.error.code}: ${response.error.message}`) + writeHumanText(stderr, `${response.error.code}: ${response.error.message}`) } else { writeText(stdout, serializeMachineResponse(response)) } @@ -385,7 +479,7 @@ export async function runCli( ) { if (!streamedText || !streamedTextEndsWithNewline) stdout.write('\n') } else if (parsed.outputMode === 'text') { - writeText( + writeHumanText( stdout, formatHumanResult(parsed.contract, response.result, { outputPath: parsed.outputPath }) ) diff --git a/src/main/events/sessionEventRouter.ts b/src/main/events/sessionEventRouter.ts index 956ccc270..b8c09e8e9 100644 --- a/src/main/events/sessionEventRouter.ts +++ b/src/main/events/sessionEventRouter.ts @@ -1,17 +1,11 @@ -import { sessionsUpdatedEvent, type DeepchatEventName } from '@shared/contracts/events' +import { + SESSION_RUN_STREAM_EVENT_NAMES, + sessionsUpdatedEvent, + type DeepchatEventName +} from '@shared/contracts/events' import type { TypedEventHub } from './typedEventHub' -const RUN_STREAM_EVENTS = new Set([ - 'chat.stream.updated', - 'chat.stream.completed', - 'chat.stream.failed', - 'chat.plan.updated', - 'sessions.status.changed', - 'sessions.compaction.changed', - 'sessions.acp.modes.ready', - 'sessions.acp.commands.ready', - 'sessions.acp.configOptions.ready' -]) +const RUN_STREAM_EVENTS = new Set(SESSION_RUN_STREAM_EVENT_NAMES) type SessionEventRouterOptions = Readonly<{ hub: TypedEventHub diff --git a/src/shared/contracts/events/runs.events.ts b/src/shared/contracts/events/runs.events.ts index ca041b5f2..0e8690961 100644 --- a/src/shared/contracts/events/runs.events.ts +++ b/src/shared/contracts/events/runs.events.ts @@ -72,3 +72,26 @@ export const runsSnapshotEvent = defineEventContract({ }) .strict() }) + +export const SESSION_RUN_STREAM_EVENT_NAMES = [ + 'chat.stream.updated', + 'chat.stream.completed', + 'chat.stream.failed', + 'chat.plan.updated', + 'sessions.status.changed', + 'sessions.compaction.changed', + 'sessions.acp.modes.ready', + 'sessions.acp.commands.ready', + 'sessions.acp.configOptions.ready' +] as const + +export const RUN_STREAM_EVENT_NAMES = [ + runsCreatedEvent.name, + runsTurnAcceptedEvent.name, + runsTurnFailedEvent.name, + runsCancelRequestedEvent.name, + runsSnapshotEvent.name, + ...SESSION_RUN_STREAM_EVENT_NAMES +] as const + +export const RunStreamEventNameSchema = z.enum(RUN_STREAM_EVENT_NAMES) diff --git a/test/main/cli/args.test.ts b/test/main/cli/args.test.ts index 9a76ebd90..0a92afb39 100644 --- a/test/main/cli/args.test.ts +++ b/test/main/cli/args.test.ts @@ -65,6 +65,11 @@ describe('CLI argument grammar', () => { helpRequested: true }) expect(() => parseCliArguments(['--help'], {})).toThrow('deepchat ') + expect(formatCliHelp({ domain: 'agent', verb: 'run' })).toContain('--max-turns ') + expect(formatCliHelp({ domain: 'run', verb: 'watch' })).toContain('--cursor ') + expect(formatCliHelp({ domain: 'model', verb: 'list' })).toContain( + 'deepchat model list --provider ' + ) }) it('parses artifact ownership commands without accepting output flags on metadata operations', () => { @@ -141,6 +146,112 @@ describe('CLI argument grammar', () => { ).toThrow('exactly one of --prompt or --stdin') }) + it('parses bounded durable Agent creation options', () => { + expect( + parseCliArguments( + [ + 'agent', + 'run', + '--prompt', + 'Run the benchmark', + '--agent', + 'deepchat', + '--provider', + 'provider-1', + '--model', + 'model-1', + '--system', + 'Be concise', + '--title', + 'Benchmark run', + '--project-dir', + '/tmp/project', + '--skills', + 'bench,reporting', + '--disable-tools', + 'dangerous-tool', + '--max-turns', + '8' + ], + {} + ) + ).toMatchObject({ + contract: { name: 'sessions.runDetached' }, + operation: 'rpc', + timeoutMs: DEFAULT_COMPUTE_TIMEOUT_MS, + params: { + prompt: 'Run the benchmark', + agentId: 'deepchat', + providerId: 'provider-1', + modelId: 'model-1', + systemPrompt: 'Be concise', + title: 'Benchmark run', + projectDir: '/tmp/project', + activeSkills: ['bench', 'reporting'], + disabledAgentTools: ['dangerous-tool'], + maxTurns: 8 + } + }) + expect(parseCliArguments(['agent', 'run', '--stdin'], {})).toMatchObject({ + readStdin: true, + params: {} + }) + expect(() => parseCliArguments(['agent', 'run', '--prompt', 'hello', '--stdin'], {})).toThrow( + 'exactly one of --prompt or --stdin' + ) + expect(() => + parseCliArguments(['agent', 'run', '--prompt', 'hello', '--skills', 'bench,bench'], {}) + ).toThrow('duplicate identifiers') + }) + + it('keeps run identity and message/event cursors in separate typed options', () => { + expect( + parseCliArguments( + [ + 'run', + 'get', + '--run', + 'run-1', + '--cursor', + '{"orderSeq":42,"id":"message-42"}', + '--limit', + '25' + ], + {} + ) + ).toMatchObject({ + contract: { name: 'runs.get' }, + operation: 'rpc', + params: { + runId: 'run-1', + cursor: { orderSeq: 42, id: 'message-42' }, + limit: 25 + } + }) + expect( + parseCliArguments( + ['run', 'watch', '--run', 'run-1', '--cursor', 'epoch-1:42', '--limit', '10'], + {} + ) + ).toMatchObject({ + contract: { name: 'events.subscribe' }, + operation: 'stream', + timeoutMs: DEFAULT_COMPUTE_TIMEOUT_MS, + params: { runId: 'run-1', cursor: 'epoch-1:42', messageLimit: 10 } + }) + expect(parseCliArguments(['run', 'cancel', '--run', 'run-1'], {})).toMatchObject({ + contract: { name: 'runs.cancel' }, + params: { runId: 'run-1' } + }) + expect(() => parseCliArguments(['run', 'get'], {})).toThrow('requires --run') + expect(() => + parseCliArguments(['run', 'get', '--run', 'run-1', '--cursor', 'epoch-1:42'], {}) + ).toThrow('JSON message cursor') + expect(() => + parseCliArguments(['run', 'watch', '--run', 'run-1', '--cursor', '{"orderSeq":1}'], {}) + ).toThrow('valid event cursor') + }) + it('keeps model flags after the two-token capability signature', () => { expect(() => parseCliArguments( diff --git a/test/main/cli/client.test.ts b/test/main/cli/client.test.ts index 3a4faaab4..e309498cf 100644 --- a/test/main/cli/client.test.ts +++ b/test/main/cli/client.test.ts @@ -8,6 +8,7 @@ import type { DeepchatRouteName } from '@shared/contracts/routes' import type { JsonValue } from '@shared/contracts/json' import { LOCAL_CONTROL_AGENT_TOKEN_ENV, + LocalControlEventEnvelopeSchema, LocalControlRpcResponseSchema, type LocalControlDescriptor } from '@shared/contracts/localControl' @@ -301,6 +302,253 @@ describe('bundled CLI client', () => { expect(records[3]).toMatchObject({ ok: true, result: { text: 'Hello' } }) }) + it('starts a durable Agent run with bounded stdin and prints its recovery identity', async () => { + const stdout = captureOutput() + const stderr = captureOutput() + const invokeRpc = vi.fn(async (invocation) => + LocalControlRpcResponseSchema.parse({ + protocolVersion: 1, + surfaceVersion: 1, + id: invocation.id, + ok: true, + result: { + runId: 'run-1', + sessionId: 'run-1', + status: 'generating', + requestId: 'agent-request-1', + messageId: 'message-1', + createdAt: 1_000 + } + }) + ) + + await expect( + runCli(['agent', 'run', '--stdin', '--provider', 'provider-1', '--model', 'model-1'], { + env: {}, + stdin: Readable.from(['Run the benchmark']), + stdout: stdout.stream, + stderr: stderr.stream, + randomId: () => 'request-1', + loadDescriptor: async () => testDescriptor, + invokeRpc + }) + ).resolves.toBe(0) + + expect(invokeRpc).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'sessions.runDetached', + params: { + prompt: 'Run the benchmark', + providerId: 'provider-1', + modelId: 'model-1' + } + }) + ) + expect(stdout.read()).toContain('Run run-1 started (generating)') + expect(stdout.read()).toContain('deepchat run watch --run run-1') + expect(stderr.read()).toBe('') + }) + + it('validates targeted run events and preserves their resume cursors in JSONL', async () => { + const stdout = captureOutput() + const stderr = captureOutput() + const invokeStream = vi.fn(async (invocation, onEvent) => { + await onEvent( + LocalControlEventEnvelopeSchema.parse({ + protocolVersion: 1, + surfaceVersion: 1, + sequence: 0, + timestamp: 1_000, + requestId: invocation.id, + runId: 'run-1', + cursor: 'epoch-1:7', + event: 'sessions.status.changed', + data: { + sessionId: 'run-1', + status: 'idle', + version: 2, + internalSecret: 'do-not-publish' + } + }) + ) + return LocalControlRpcResponseSchema.parse({ + protocolVersion: 1, + surfaceVersion: 1, + id: invocation.id, + ok: true, + result: { runId: 'run-1', lastCursor: 'epoch-1:7' } + }) + }) + + await expect( + runCli(['run', 'watch', '--run', 'run-1', '--jsonl'], { + env: {}, + stdout: stdout.stream, + stderr: stderr.stream, + randomId: () => 'request-1', + loadDescriptor: async () => testDescriptor, + invokeStream + }) + ).resolves.toBe(0) + + const records = stdout + .read() + .trimEnd() + .split('\n') + .map((line) => JSON.parse(line) as Record) + expect(records).toHaveLength(2) + expect(records[0]).toMatchObject({ + event: 'sessions.status.changed', + runId: 'run-1', + cursor: 'epoch-1:7' + }) + expect(records[1]).toMatchObject({ + ok: true, + result: { runId: 'run-1', lastCursor: 'epoch-1:7' } + }) + expect(stdout.read()).not.toContain('do-not-publish') + expect(stderr.read()).toBe('') + }) + + it('rejects untargeted run stream records before writing attacker-controlled data', async () => { + const stdout = captureOutput() + const invokeStream = vi.fn(async (invocation, onEvent) => { + await onEvent( + LocalControlEventEnvelopeSchema.parse({ + protocolVersion: 1, + surfaceVersion: 1, + sequence: 0, + timestamp: 1_000, + requestId: invocation.id, + event: 'chat.stream.failed', + data: { + requestId: 'agent-request-1', + sessionId: 'other-run', + messageId: 'message-1', + failedAt: 1_000, + error: 'untrusted' + } + }) + ) + throw new Error('unreachable') + }) + + await expect( + runCli(['run', 'watch', '--run', 'run-1', '--jsonl'], { + env: {}, + stdout: stdout.stream, + stderr: captureOutput().stream, + randomId: () => 'request-1', + loadDescriptor: async () => testDescriptor, + invokeStream + }) + ).resolves.toBe(8) + + const records = stdout.read().trimEnd().split('\n') + expect(records).toHaveLength(1) + expect(LocalControlRpcResponseSchema.parse(JSON.parse(records[0]))).toMatchObject({ + ok: false, + error: { code: 'internal_error' } + }) + expect(stdout.read()).not.toContain('untrusted') + }) + + it('keeps typed internal events outside the public run stream allowlist', async () => { + const stdout = captureOutput() + const invokeStream = vi.fn(async (invocation, onEvent) => { + await onEvent( + LocalControlEventEnvelopeSchema.parse({ + protocolVersion: 1, + surfaceVersion: 1, + sequence: 0, + timestamp: 1_000, + requestId: invocation.id, + runId: 'run-1', + cursor: 'epoch-1:8', + event: 'approvals.closed', + data: { + requestId: 'approval-request-1234', + reason: 'approved' + } + }) + ) + throw new Error('unreachable') + }) + + await expect( + runCli(['run', 'watch', '--run', 'run-1', '--jsonl'], { + env: {}, + stdout: stdout.stream, + stderr: captureOutput().stream, + randomId: () => 'request-1', + loadDescriptor: async () => testDescriptor, + invokeStream + }) + ).resolves.toBe(8) + + const response = LocalControlRpcResponseSchema.parse(JSON.parse(stdout.read())) + expect(response).toMatchObject({ ok: false, error: { code: 'internal_error' } }) + expect(stdout.read()).not.toContain('approvals.closed') + }) + + it('neutralizes terminal control characters in human model output', async () => { + const stdout = captureOutput() + const invokeStream = vi.fn(async (invocation, onEvent) => { + await onEvent( + LocalControlEventEnvelopeSchema.parse({ + protocolVersion: 1, + surfaceVersion: 1, + sequence: 0, + timestamp: 1_000, + requestId: invocation.id, + event: 'models.invoke', + data: { type: 'text_delta', text: '\u001b]52;c;c2VjcmV0\u0007safe\r\u202e' } + }) + ) + return LocalControlRpcResponseSchema.parse({ + protocolVersion: 1, + surfaceVersion: 1, + id: invocation.id, + ok: true, + result: { + providerId: 'provider-1', + modelId: 'model-1', + text: 'safe', + finishReason: 'complete', + durationMs: 10, + ttftMs: 1 + } + }) + }) + + await expect( + runCli( + ['model', 'invoke', '--provider', 'provider-1', '--model', 'model-1', '--prompt', 'hello'], + { + env: {}, + stdout: stdout.stream, + stderr: captureOutput().stream, + randomId: () => 'request-1', + loadDescriptor: async () => testDescriptor, + invokeStream + } + ) + ).resolves.toBe(0) + + expect(stdout.read()).toContain('safe') + expect( + Array.from(stdout.read()).some((character) => { + const codePoint = character.codePointAt(0) ?? 0 + return ( + codePoint === 0x0d || + (codePoint <= 0x1f && codePoint !== 0x09 && codePoint !== 0x0a) || + (codePoint >= 0x7f && codePoint <= 0x9f) || + (codePoint >= 0x202a && codePoint <= 0x202e) + ) + }) + ).toBe(false) + }) + it('cancels a stream promptly even when its provider ignores the signal', async () => { const { userDataPath, server } = await createClientServer({ hangStream: true }) const invocation = runWithCapturedOutput( From 3525fceb2b8a84447fcd9e82583c106af585d118 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 16:57:28 +0800 Subject: [PATCH 24/51] fix(permission): isolate shell control approvals --- .../permission/commandPermissionService.ts | 60 +++++++++++++- .../commandPermissionService.test.ts | 80 +++++++++++++++++++ 2 files changed, 137 insertions(+), 3 deletions(-) diff --git a/src/main/tool/permission/commandPermissionService.ts b/src/main/tool/permission/commandPermissionService.ts index 40b87a263..70909938c 100644 --- a/src/main/tool/permission/commandPermissionService.ts +++ b/src/main/tool/permission/commandPermissionService.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import { CommandPermissionCache } from './commandPermissionCache' export type CommandRiskLevel = 'low' | 'medium' | 'high' | 'critical' @@ -40,7 +41,7 @@ const SAFE_COMMANDS = new Set([ const DESTRUCTIVE_PATTERN = /\brm\s+-rf\b|:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;|\bchmod\s+777\s+\// const NETWORK_PATTERN = /\b(curl|wget|nc|netcat|telnet)\b/ -const CHAINING_PATTERN = /&&|\|\||;|\$\(|`|\|/ +const SHELL_CONTROL_CHARS = new Set([';', '|', '&', '<', '>', '\r', '\n']) const RISKY_COMMANDS = /\b(rm|rmdir|mv|chmod|chown|sudo|doas|su|docker|podman|kubectl)\b/ const BUILD_COMMANDS = /\b(git\s+(pull|push|checkout|switch|merge)|npm|pnpm|yarn|bun|pip|pip3|cargo|make|gradle|mvn)\b/ @@ -52,6 +53,53 @@ const SUGGESTION_KEYS: Record = { critical: 'components.messageBlockPermissionRequest.suggestion.critical' } +function hasShellControlSyntax(command: string): boolean { + let quote: "'" | '"' | null = null + const supportsPosixEscapes = process.platform !== 'win32' + + for (let index = 0; index < command.length; index += 1) { + const character = command[index] + + if (quote === "'") { + if (character === "'") quote = null + continue + } + + if (quote === '"') { + if (character === '"') { + quote = null + continue + } + if (supportsPosixEscapes && character === '\\') { + index += 1 + continue + } + if (character === '`' || (character === '$' && command[index + 1] === '(')) { + return true + } + continue + } + + if (supportsPosixEscapes && character === '\\') { + index += 1 + continue + } + if (character === '"' || (supportsPosixEscapes && character === "'")) { + quote = character + continue + } + if ( + character === '`' || + (character === '$' && command[index + 1] === '(') || + SHELL_CONTROL_CHARS.has(character) + ) { + return true + } + } + + return false +} + export class CommandPermissionRequiredError extends Error { readonly permissionRequest: { toolName: string @@ -167,7 +215,7 @@ export class CommandPermissionService { return { level: 'critical', suggestion: SUGGESTION_KEYS.critical } } - if (CHAINING_PATTERN.test(command)) { + if (hasShellControlSyntax(command)) { return { level: 'critical', suggestion: SUGGESTION_KEYS.critical } } @@ -200,7 +248,13 @@ export class CommandPermissionService { } extractCommandSignature(command: string): string { - const tokens = this.tokenize(command) + const trimmed = command.trim() + if (hasShellControlSyntax(trimmed)) { + const digest = createHash('sha256').update(trimmed).digest('hex') + return `shell:${digest}` + } + + const tokens = this.tokenize(trimmed) if (tokens.length === 0) return '' let index = 0 diff --git a/test/main/tool/permission/commandPermissionService.test.ts b/test/main/tool/permission/commandPermissionService.test.ts index 7bd34223a..d276d8584 100644 --- a/test/main/tool/permission/commandPermissionService.test.ts +++ b/test/main/tool/permission/commandPermissionService.test.ts @@ -32,6 +32,86 @@ describe('CommandPermissionService', () => { expect(service.extractCommandSignature('git pull origin main')).toBe('git pull') expect(service.extractCommandSignature('rm -rf /')).toBe('rm -rf /') }) + + it('keeps deepchat outside the implicit safe-command set', () => { + const service = new CommandPermissionService() + const result = service.checkPermission('conv-1', 'deepchat model invoke --prompt hello') + + expect(result.allowed).toBe(false) + expect(result.reason).toBe('permission') + expect(result.signature).toBe('deepchat model') + }) + + it.each([ + 'cat notes.txt > copied.txt', + 'cat notes.txt 2>> errors.log', + 'cat < notes.txt', + 'ls | sort', + 'ls && touch changed.txt', + 'ls\ntouch changed.txt', + 'echo $(touch changed.txt)', + 'echo `touch changed.txt`', + 'sleep 1 & touch changed.txt' + ])('requires an exact approval for shell control syntax in %j', (command) => { + const service = new CommandPermissionService() + const result = service.checkPermission('conv-1', command) + + expect(result.allowed).toBe(false) + expect(result.reason).toBe('permission') + expect(result.risk.level).toBe('critical') + expect(result.signature).toMatch(/^shell:[a-f0-9]{64}$/) + }) + + it.each([ + 'echo "a > b"', + "grep 'a&b' notes.txt", + "echo '\$(touch changed.txt)'", + 'echo escaped\\>value' + ])('does not treat quoted or escaped shell characters as control syntax in %j', (command) => { + const service = new CommandPermissionService() + const result = service.checkPermission('conv-1', command) + + expect(result.allowed).toBe(true) + expect(result.reason).toBe('whitelist') + expect(result.risk.level).toBe('low') + expect(result.signature).not.toMatch(/^shell:/) + }) + + it('detects command substitution inside double quotes', () => { + const service = new CommandPermissionService() + const result = service.checkPermission('conv-1', 'echo "$(touch changed.txt)"') + + expect(result.allowed).toBe(false) + expect(result.risk.level).toBe('critical') + expect(result.signature).toMatch(/^shell:[a-f0-9]{64}$/) + }) + + it('does not let a broad command approval authorize a redirected command', () => { + const service = new CommandPermissionService() + service.approve('conv-1', 'deepchat model', false) + + const redirected = service.checkPermission( + 'conv-1', + 'deepchat model invoke --prompt hello > output.txt' + ) + const original = service.checkPermission('conv-1', 'deepchat model invoke --prompt hello') + + expect(redirected.allowed).toBe(false) + expect(redirected.signature).toMatch(/^shell:[a-f0-9]{64}$/) + expect(original.allowed).toBe(true) + }) + + it('allows only the exact shell expression that was approved', () => { + const service = new CommandPermissionService() + const command = 'deepchat model invoke --prompt hello > output.txt' + const signature = service.extractCommandSignature(command) + service.approve('conv-1', signature, false) + + expect(service.checkPermission('conv-1', command).allowed).toBe(true) + expect( + service.checkPermission('conv-1', 'deepchat model invoke --prompt hello > other.txt').allowed + ).toBe(false) + }) }) describe('CommandPermissionCache', () => { From c88a61d7dbe562650eef16ec585e84ca0b3adce3 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 17:10:26 +0800 Subject: [PATCH 25/51] feat(cli): enforce scoped agent quotas --- src/main/app/composition.ts | 6 + src/main/cli/agentTokenAuthority.ts | 327 ++++++++++++++++++++++ src/main/cli/artifactSpool.ts | 9 + src/main/cli/body.ts | 2 + src/main/cli/index.ts | 7 + src/main/cli/server.ts | 150 +++++++--- src/main/routes/routeRegistry.ts | 1 + test/main/cli/agentTokenAuthority.test.ts | 128 +++++++++ test/main/cli/artifactSpool.test.ts | 28 +- test/main/cli/mcpAdminRoutes.test.ts | 1 + test/main/cli/policy.test.ts | 1 + test/main/cli/runService.test.ts | 1 + test/main/cli/server.test.ts | 119 +++++++- test/main/cli/skillService.test.ts | 1 + test/main/routes/routeRegistry.test.ts | 1 + 15 files changed, 738 insertions(+), 44 deletions(-) create mode 100644 src/main/cli/agentTokenAuthority.ts create mode 100644 test/main/cli/agentTokenAuthority.test.ts diff --git a/src/main/app/composition.ts b/src/main/app/composition.ts index 9cd96e09d..bb5d317cb 100644 --- a/src/main/app/composition.ts +++ b/src/main/app/composition.ts @@ -214,6 +214,7 @@ import { } from '@/routes' import { createNodeScheduler } from '@/routes/scheduler' import { + AgentCliTokenAuthority, ArtifactSpool, CliAudioTranscriptionService, CliAuditLog, @@ -410,8 +411,10 @@ export async function createMainProcessControl(dependencies: { resolveSessionRunId: (sessionId) => resolveSessionRunId(sessionId), getBoundRendererIds: (sessionId) => resolveBoundRendererIds(sessionId) }) + const agentCliTokenAuthority = new AgentCliTokenAuthority() const artifactSpool = new ArtifactSpool({ directory: path.join(app.getPath('userData'), 'local-control', 'artifacts'), + consumeAgentBytes: (tokenId, bytes) => agentCliTokenAuthority.consumeBytes(tokenId, bytes), log: logger }) const cliAuditLog = new CliAuditLog({ @@ -467,6 +470,7 @@ export async function createMainProcessControl(dependencies: { if (!cliRequestPolicy) throw new Error('CLI request policy is not ready') return await cliRequestPolicy.authorize(input) }, + beginAgentRequest: (token) => agentCliTokenAuthority.beginRequest(token), artifactSpool, log: logger }) @@ -1275,6 +1279,7 @@ export async function createMainProcessControl(dependencies: { } sessionPermissionPort = { clearSessionPermissions: (sessionId) => { + agentCliTokenAuthority.revokeConversation(sessionId) commandPermissionService.clearConversation(sessionId) filePermissionService.clearConversation(sessionId) settingsPermissionService.clearConversation(sessionId) @@ -2074,6 +2079,7 @@ export async function createMainProcessControl(dependencies: { } async function destroy(): Promise { + await runDestroyStep('agentCliTokenAuthority.clear', () => agentCliTokenAuthority.clear()) await runDestroyStep('cliServer.stop', () => cliServer.stop()) await runDestroyStep('typedEventHub.close', () => typedEventHub.close()) await runDestroyStep('cliMutationGuard.clear', () => cliMutationGuard.clear()) diff --git a/src/main/cli/agentTokenAuthority.ts b/src/main/cli/agentTokenAuthority.ts new file mode 100644 index 000000000..4948fd3b8 --- /dev/null +++ b/src/main/cli/agentTokenAuthority.ts @@ -0,0 +1,327 @@ +import { createHash, randomBytes } from 'node:crypto' +import { + LocalControlScopesSchema, + LocalControlTokenSchema, + type LocalControlScope +} from '@shared/contracts/localControl' + +export const DEFAULT_AGENT_CLI_TOKEN_TTL_MS = 35 * 60_000 +export const DEFAULT_AGENT_CLI_TOKEN_MAX_CALLS = 64 +export const DEFAULT_AGENT_CLI_TOKEN_MAX_BYTES = 256 * 1024 * 1024 +export const MAX_AGENT_CLI_TOKEN_TTL_MS = 60 * 60_000 +export const MAX_AGENT_CLI_TOKEN_CALLS = 1024 +export const MAX_AGENT_CLI_TOKEN_BYTES = 1024 * 1024 * 1024 + +const DEFAULT_MAX_TOKENS = 256 +const DEFAULT_MAX_TOKENS_PER_CONVERSATION = 8 + +export const DEFAULT_AGENT_CLI_SCOPES = [ + 'system:read', + 'models:read', + 'models:invoke', + 'media:generate', + 'audio:transcribe', + 'ocr:read', + 'ocr:extract', + 'runs:read', + 'runs:cancel', + 'artifacts:read', + 'settings:read', + 'settings:write', + 'providers:read', + 'skills:read', + 'skills:write', + 'mcp:read', + 'mcp:write' +] as const satisfies readonly LocalControlScope[] + +export type AgentCliTokenClaims = Readonly<{ + tokenId: string + conversationId: string + expiresAt: number + scopes: readonly LocalControlScope[] +}> + +export type IssuedAgentCliToken = AgentCliTokenClaims & + Readonly<{ + token: string + maxCalls: number + maxBytes: number + }> + +export type AgentCliRequestGrant = Readonly<{ + claims: AgentCliTokenClaims + signal: AbortSignal + consumeBytes(bytes: number): boolean +}> + +export type AgentCliRequestBeginResult = + | Readonly<{ status: 'granted'; grant: AgentCliRequestGrant }> + | Readonly<{ status: 'invalid' | 'expired' | 'quota-exhausted' }> + +export type AgentCliTokenAuthorityOptions = Readonly<{ + now?: () => number + createToken?: () => string + createTokenId?: () => string + maxTokens?: number + maxTokensPerConversation?: number +}> + +type TokenRecord = { + digest: string + claims: AgentCliTokenClaims + maxCalls: number + maxBytes: number + usedCalls: number + usedBytes: number + issuedAt: number + controller: AbortController + expiryTimer: NodeJS.Timeout +} + +function positiveSafeInteger(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive safe integer`) + } + return value +} + +function boundedPositiveSafeInteger(value: number, maximum: number, name: string): number { + const normalized = positiveSafeInteger(value, name) + if (normalized > maximum) throw new Error(`${name} exceeds its supported maximum`) + return normalized +} + +function tokenDigest(token: string): string { + return createHash('sha256').update(token).digest('hex') +} + +function normalizeConversationId(value: string): string { + const normalized = value.trim() + if (normalized.length === 0 || normalized.length > 128) { + throw new Error('conversationId must contain 1 to 128 characters') + } + return normalized +} + +export class AgentCliTokenCapacityError extends Error { + constructor() { + super('Agent CLI token capacity is exhausted') + this.name = 'AgentCliTokenCapacityError' + } +} + +export class AgentCliTokenAuthority { + private readonly now: () => number + private readonly createToken: () => string + private readonly createTokenId: () => string + private readonly maxTokens: number + private readonly maxTokensPerConversation: number + private readonly recordsByDigest = new Map() + private readonly recordsById = new Map() + private readonly digestsByConversation = new Map>() + + constructor(options: AgentCliTokenAuthorityOptions = {}) { + this.now = options.now ?? Date.now + this.createToken = options.createToken ?? (() => randomBytes(32).toString('base64url')) + this.createTokenId = options.createTokenId ?? (() => randomBytes(16).toString('base64url')) + this.maxTokens = positiveSafeInteger(options.maxTokens ?? DEFAULT_MAX_TOKENS, 'maxTokens') + this.maxTokensPerConversation = positiveSafeInteger( + options.maxTokensPerConversation ?? DEFAULT_MAX_TOKENS_PER_CONVERSATION, + 'maxTokensPerConversation' + ) + } + + issue( + input: Readonly<{ + conversationId: string + scopes?: readonly LocalControlScope[] + ttlMs?: number + maxCalls?: number + maxBytes?: number + }> + ): IssuedAgentCliToken { + const conversationId = normalizeConversationId(input.conversationId) + const scopes = LocalControlScopesSchema.parse([...(input.scopes ?? DEFAULT_AGENT_CLI_SCOPES)]) + const ttlMs = boundedPositiveSafeInteger( + input.ttlMs ?? DEFAULT_AGENT_CLI_TOKEN_TTL_MS, + MAX_AGENT_CLI_TOKEN_TTL_MS, + 'ttlMs' + ) + const maxCalls = boundedPositiveSafeInteger( + input.maxCalls ?? DEFAULT_AGENT_CLI_TOKEN_MAX_CALLS, + MAX_AGENT_CLI_TOKEN_CALLS, + 'maxCalls' + ) + const maxBytes = boundedPositiveSafeInteger( + input.maxBytes ?? DEFAULT_AGENT_CLI_TOKEN_MAX_BYTES, + MAX_AGENT_CLI_TOKEN_BYTES, + 'maxBytes' + ) + const issuedAt = this.now() + if (issuedAt > Number.MAX_SAFE_INTEGER - ttlMs) { + throw new Error('Agent CLI token expiry is outside the supported range') + } + this.pruneExpired(issuedAt) + const replacesConversationToken = + (this.digestsByConversation.get(conversationId)?.size ?? 0) >= this.maxTokensPerConversation + if (this.recordsByDigest.size >= this.maxTokens && !replacesConversationToken) { + throw new AgentCliTokenCapacityError() + } + const token = LocalControlTokenSchema.parse(this.createUniqueToken()) + const tokenId = this.createUniqueTokenId() + if (replacesConversationToken) this.enforceConversationCapacity(conversationId) + if (this.recordsByDigest.size >= this.maxTokens) { + throw new AgentCliTokenCapacityError() + } + const expiresAt = issuedAt + ttlMs + const claims: AgentCliTokenClaims = { + tokenId, + conversationId, + expiresAt, + scopes + } + const digest = tokenDigest(token) + const controller = new AbortController() + const expiryTimer = setTimeout(() => this.removeRecord(digest, 'expired'), ttlMs) + expiryTimer.unref() + const record: TokenRecord = { + digest, + claims, + maxCalls, + maxBytes, + usedCalls: 0, + usedBytes: 0, + issuedAt, + controller, + expiryTimer + } + this.recordsByDigest.set(digest, record) + this.recordsById.set(tokenId, record) + const conversationTokens = this.digestsByConversation.get(conversationId) ?? new Set() + conversationTokens.add(digest) + this.digestsByConversation.set(conversationId, conversationTokens) + + return { token, ...claims, maxCalls, maxBytes } + } + + beginRequest(token: string): AgentCliRequestBeginResult { + const parsedToken = LocalControlTokenSchema.safeParse(token) + if (!parsedToken.success) return { status: 'invalid' } + const digest = tokenDigest(parsedToken.data) + const record = this.recordsByDigest.get(digest) + if (!record) return { status: 'invalid' } + if (record.claims.expiresAt <= this.now()) { + this.removeRecord(digest, 'expired') + return { status: 'expired' } + } + if (record.usedCalls >= record.maxCalls || record.usedBytes >= record.maxBytes) { + return { status: 'quota-exhausted' } + } + + record.usedCalls += 1 + return { + status: 'granted', + grant: { + claims: record.claims, + signal: record.controller.signal, + consumeBytes: (bytes) => this.consumeRecordBytes(record, bytes) + } + } + } + + consumeBytes(tokenId: string, bytes: number): boolean { + const record = this.recordsById.get(tokenId) + return record ? this.consumeRecordBytes(record, bytes) : false + } + + revokeConversation(conversationId: string): void { + const normalized = conversationId.trim() + if (!normalized) return + for (const digest of this.digestsByConversation.get(normalized) ?? []) { + this.removeRecord(digest, 'revoked') + } + } + + clear(): void { + for (const digest of this.recordsByDigest.keys()) { + this.removeRecord(digest, 'revoked') + } + } + + snapshot(): Readonly<{ tokens: number; conversations: number }> { + this.pruneExpired(this.now()) + return { + tokens: this.recordsByDigest.size, + conversations: this.digestsByConversation.size + } + } + + private createUniqueToken(): string { + for (let attempt = 0; attempt < 8; attempt += 1) { + const candidate = this.createToken() + if (!this.recordsByDigest.has(tokenDigest(candidate))) return candidate + } + throw new Error('Failed to allocate a unique Agent CLI token') + } + + private createUniqueTokenId(): string { + for (let attempt = 0; attempt < 8; attempt += 1) { + const candidate = this.createTokenId() + if (/^[A-Za-z0-9_-]{16,128}$/.test(candidate) && !this.recordsById.has(candidate)) { + return candidate + } + } + throw new Error('Failed to allocate a unique Agent CLI token ID') + } + + private consumeRecordBytes(record: TokenRecord, bytes: number): boolean { + if (!Number.isSafeInteger(bytes) || bytes < 0) { + throw new Error('Agent CLI byte usage must be a non-negative safe integer') + } + if (this.recordsByDigest.get(record.digest) !== record) return false + if (record.claims.expiresAt <= this.now()) { + this.removeRecord(record.digest, 'expired') + return false + } + if (bytes > record.maxBytes - record.usedBytes) { + record.usedBytes = record.maxBytes + return false + } + record.usedBytes += bytes + return true + } + + private enforceConversationCapacity(conversationId: string): void { + const conversationTokens = this.digestsByConversation.get(conversationId) + if (!conversationTokens || conversationTokens.size < this.maxTokensPerConversation) return + const oldest = [...conversationTokens] + .map((digest) => this.recordsByDigest.get(digest)) + .filter((record): record is TokenRecord => Boolean(record)) + .sort( + (left, right) => + left.issuedAt - right.issuedAt || left.claims.tokenId.localeCompare(right.claims.tokenId) + )[0] + if (oldest) this.removeRecord(oldest.digest, 'replaced') + } + + private pruneExpired(now: number): void { + for (const record of this.recordsByDigest.values()) { + if (record.claims.expiresAt <= now) this.removeRecord(record.digest, 'expired') + } + } + + private removeRecord(digest: string, reason: 'expired' | 'replaced' | 'revoked'): void { + const record = this.recordsByDigest.get(digest) + if (!record) return + this.recordsByDigest.delete(digest) + this.recordsById.delete(record.claims.tokenId) + const conversationTokens = this.digestsByConversation.get(record.claims.conversationId) + conversationTokens?.delete(digest) + if (conversationTokens?.size === 0) { + this.digestsByConversation.delete(record.claims.conversationId) + } + clearTimeout(record.expiryTimer) + record.controller.abort(new Error(`Agent CLI token ${reason}`)) + } +} diff --git a/src/main/cli/artifactSpool.ts b/src/main/cli/artifactSpool.ts index a3da88ba4..adeb386da 100644 --- a/src/main/cli/artifactSpool.ts +++ b/src/main/cli/artifactSpool.ts @@ -86,6 +86,7 @@ type OpenArtifactHandle = Readonly<{ export type ArtifactSpoolOptions = Readonly<{ directory: string limits?: Partial + consumeAgentBytes?: (tokenId: string, bytes: number) => boolean now?: () => number createId?: () => string cleanupIntervalMs?: number @@ -346,6 +347,14 @@ export class ArtifactSpool { } ) } + if ( + input.caller.principal === 'agent' && + !this.options.consumeAgentBytes?.(input.caller.tokenId, chunk.byteLength) + ) { + throw new CliRequestError('rate_limited', 'Agent CLI token byte quota is exhausted', { + httpStatus: 429 + }) + } this.reserveBytes(ownerQuotaKey, requestQuotaKey, connectionQuotaKey, chunk.byteLength) reservedSize += chunk.byteLength position = await writeAll(handle, chunk, position) diff --git a/src/main/cli/body.ts b/src/main/cli/body.ts index 243ce30d3..f8cb9313b 100644 --- a/src/main/cli/body.ts +++ b/src/main/cli/body.ts @@ -30,6 +30,7 @@ export type BoundedBodyOptions = Readonly<{ memoryThresholdBytes: number tempDirectory: string requireContentLength: boolean + consumeBytes?: (bytes: number) => void }> export function readDeclaredBodyLength(request: IncomingMessage): number | null { @@ -135,6 +136,7 @@ export async function readBoundedRequestBody( httpStatus: 413 }) } + options.consumeBytes?.(chunk.length) sha256.update(chunk) diff --git a/src/main/cli/index.ts b/src/main/cli/index.ts index e4916f6c5..e86a6af97 100644 --- a/src/main/cli/index.ts +++ b/src/main/cli/index.ts @@ -1,4 +1,11 @@ export { CliServer, type CliServerDependencies } from './server' +export { + AgentCliTokenAuthority, + type AgentCliRequestBeginResult, + type AgentCliRequestGrant, + type AgentCliTokenClaims, + type IssuedAgentCliToken +} from './agentTokenAuthority' export { CliAuditLog, type CliAuditLogOptions } from './auditLog' export { ArtifactSpool, type ArtifactSpoolOptions } from './artifactSpool' export { createArtifactRoutes } from './artifactRoutes' diff --git a/src/main/cli/server.ts b/src/main/cli/server.ts index 6cc5d182b..8e27d3301 100644 --- a/src/main/cli/server.ts +++ b/src/main/cli/server.ts @@ -53,6 +53,7 @@ import { CLI_SURFACE_V1 } from './surface' import type { CliSurfaceEntry } from './surface' import type { CliRuntimeStatus } from './routes' import type { ArtifactSpool } from './artifactSpool' +import type { AgentCliRequestBeginResult, AgentCliRequestGrant } from './agentTokenAuthority' const MAX_HEADER_BYTES = 8 * 1024 const MAX_CONNECTIONS = 64 @@ -65,14 +66,17 @@ const emptyAdmission: CliRequestAdmission = Object.freeze({ release: () => undef const AgentCliTokenSchema = z .object({ + tokenId: z + .string() + .min(16) + .max(128) + .regex(/^[A-Za-z0-9_-]+$/), conversationId: z.string().min(1).max(128), expiresAt: TimestampMsSchema.max(Number.MAX_SAFE_INTEGER), scopes: LocalControlScopesSchema }) .strict() -export type AgentCliToken = z.infer - export type CliStreamEmitter = ( event: string, data: JsonValue, @@ -110,7 +114,7 @@ export type CliServerDependencies = Readonly<{ ): Promise authorize?(input: CliRequestPolicyInput): Promise surface?: ReadonlyMap - resolveAgentToken?(token: string): AgentCliToken | null + beginAgentRequest?(token: string): AgentCliRequestBeginResult artifactSpool?: ArtifactSpool now?: () => number platform?: NodeJS.Platform @@ -118,6 +122,14 @@ export type CliServerDependencies = Readonly<{ log?: Pick }> +type AuthenticationResult = + | Readonly<{ + ok: true + caller: CliRouteCaller + agentGrant?: AgentCliRequestGrant + }> + | Readonly<{ ok: false; quotaExhausted: boolean }> + function hashToken(token: string): Buffer { return createHash('sha256').update(token).digest() } @@ -459,27 +471,42 @@ export class CliServer { }) } - private authenticate(request: IncomingMessage, connectionId: string): CliRouteCaller | null { + private authenticate(request: IncomingMessage, connectionId: string): AuthenticationResult { const token = readBearerToken(request) - if (!token) return null + if (!token) return { ok: false, quotaExhausted: false } if (this.token && tokensEqual(token, this.token)) { return { - kind: 'cli', - principal: 'human', - connectionId, - scopes: LOCAL_CONTROL_SCOPES + ok: true, + caller: { + kind: 'cli', + principal: 'human', + connectionId, + scopes: LOCAL_CONTROL_SCOPES + } } } - const agent = AgentCliTokenSchema.safeParse(this.dependencies.resolveAgentToken?.(token)) - if (!agent.success || agent.data.expiresAt <= this.now()) return null + const beginResult = this.dependencies.beginAgentRequest?.(token) + if (beginResult && beginResult.status !== 'granted') { + return { ok: false, quotaExhausted: beginResult.status === 'quota-exhausted' } + } + const grant = beginResult?.status === 'granted' ? beginResult.grant : undefined + const agent = AgentCliTokenSchema.safeParse(grant?.claims) + if (!agent.success || agent.data.expiresAt <= this.now()) { + return { ok: false, quotaExhausted: false } + } return { - kind: 'cli', - principal: 'agent', - connectionId, - scopes: agent.data.scopes, - conversationId: agent.data.conversationId, - expiresAt: agent.data.expiresAt + ok: true, + caller: { + kind: 'cli', + principal: 'agent', + connectionId, + scopes: agent.data.scopes, + tokenId: agent.data.tokenId, + conversationId: agent.data.conversationId, + expiresAt: agent.data.expiresAt + }, + ...(grant ? { agentGrant: grant } : {}) } } @@ -516,18 +543,23 @@ export class CliServer { return } - const caller = this.authenticate(request, connectionId) - if (!caller) { + const authentication = this.authenticate(request, connectionId) + if (!authentication.ok) { this.sendFailure( response, - 401, + authentication.quotaExhausted ? 429 : 401, UNKNOWN_REQUEST_ID, - new CliRequestError('authentication_failed', 'Authentication failed', { - httpStatus: 401 - }) + authentication.quotaExhausted + ? new CliRequestError('rate_limited', 'Agent CLI token quota is exhausted', { + httpStatus: 429 + }) + : new CliRequestError('authentication_failed', 'Authentication failed', { + httpStatus: 401 + }) ) return } + const { caller, agentGrant } = authentication if (isArtifactRequest) { await this.handleArtifactDownload(request, response, caller) return @@ -576,7 +608,17 @@ export class CliServer { const abort = () => { abortRequest(controller, new CliRequestError('cancelled', 'Request was cancelled')) } + const abortRevokedAgentRequest = () => { + abortRequest( + controller, + new CliRequestError('authentication_failed', 'Agent CLI token is no longer valid', { + httpStatus: 401 + }) + ) + } request.once('aborted', abort) + agentGrant?.signal.addEventListener('abort', abortRevokedAgentRequest, { once: true }) + if (agentGrant?.signal.aborted) abortRevokedAgentRequest() response.once('close', () => { if (!response.writableEnded) abort() }) @@ -595,7 +637,10 @@ export class CliServer { maxBytes: maxBodyBytes, memoryThresholdBytes: Math.min(maxBodyBytes, MAX_IN_MEMORY_BODY_BYTES), tempDirectory: this.layout?.tempDirectory ?? this.dependencies.userDataPath, - requireContentLength: true + requireContentLength: true, + ...(agentGrant + ? { consumeBytes: (bytes: number) => this.consumeAgentBytes(agentGrant, bytes) } + : {}) }) bodySize = body.size rawRequest = await parseBoundedJsonBody(body) @@ -690,7 +735,8 @@ export class CliServer { input, caller, requestId, - controller.signal + controller.signal, + agentGrant ) return } @@ -706,7 +752,10 @@ export class CliServer { maxBytes: entry.limits.maxBodyBytes, memoryThresholdBytes: 0, tempDirectory: this.layout?.tempDirectory ?? this.dependencies.userDataPath, - requireContentLength: false + requireContentLength: false, + ...(agentGrant + ? { consumeBytes: (bytes: number) => this.consumeAgentBytes(agentGrant, bytes) } + : {}) }) try { if ( @@ -746,7 +795,7 @@ export class CliServer { throw requestAbortError(controller.signal) } const result = this.parseRouteOutput(entry, rawOutput, routeMethod) - this.sendJson(response, 200, createLocalControlSuccess(requestId, result)) + this.sendJson(response, 200, createLocalControlSuccess(requestId, result), agentGrant) } catch (error) { if (error instanceof CliRequestError) { this.sendFailure(response, error.httpStatus, requestId, error) @@ -763,6 +812,7 @@ export class CliServer { } } finally { request.off('aborted', abort) + agentGrant?.signal.removeEventListener('abort', abortRevokedAgentRequest) this.requestControllers.delete(controller) this.pendingRequests = Math.max(0, this.pendingRequests - 1) const remaining = (this.pendingByConnection.get(connectionId) ?? 1) - 1 @@ -812,7 +862,8 @@ export class CliServer { input: unknown, caller: CliRouteCaller, requestId: string, - signal: AbortSignal + signal: AbortSignal, + agentGrant?: AgentCliRequestGrant ): Promise { const dispatchStream = this.dependencies.dispatchStream if (!dispatchStream) { @@ -847,7 +898,7 @@ export class CliServer { }) } sequence += 1 - await this.writeStreamRecord(response, parsed.data, signal) + await this.writeStreamRecord(response, parsed.data, signal, agentGrant) } try { @@ -856,7 +907,12 @@ export class CliServer { ) if (signal.aborted) throw requestAbortError(signal) const result = this.parseRouteOutput(entry, rawOutput, entry.contract.name) - await this.writeStreamRecord(response, createLocalControlSuccess(requestId, result), signal) + await this.writeStreamRecord( + response, + createLocalControlSuccess(requestId, result), + signal, + agentGrant + ) } catch (error) { const failure = signal.aborted ? requestAbortError(signal) @@ -883,7 +939,8 @@ export class CliServer { private async writeStreamRecord( response: ServerResponse, record: LocalControlStreamRecord, - signal?: AbortSignal + signal?: AbortSignal, + agentGrant?: AgentCliRequestGrant ): Promise { if (signal?.aborted) throw requestAbortError(signal) const serialized = Buffer.from(`${JSON.stringify(record)}\n`, 'utf8') @@ -892,6 +949,7 @@ export class CliServer { httpStatus: 500 }) } + if (agentGrant) this.consumeAgentBytes(agentGrant, serialized.length) if (response.destroyed || response.writableEnded) { throw new CliRequestError('cancelled', 'Stream connection is closed') } @@ -1068,7 +1126,12 @@ export class CliServer { this.sendJson(response, status, this.createFailureRecord(requestId, error)) } - private sendJson(response: ServerResponse, status: number, body: JsonValue): void { + private sendJson( + response: ServerResponse, + status: number, + body: JsonValue, + agentGrant?: AgentCliRequestGrant + ): void { if (response.destroyed || response.writableEnded) return let responseStatus = status let serialized = Buffer.from(JSON.stringify(body), 'utf8') @@ -1088,6 +1151,22 @@ export class CliServer { 'utf8' ) } + if (responseStatus < 400 && agentGrant && !agentGrant.consumeBytes(serialized.length)) { + responseStatus = 429 + serialized = Buffer.from( + JSON.stringify( + createLocalControlFailure( + isRecord(body) ? toSafeRequestId(body.id) : UNKNOWN_REQUEST_ID, + { + code: 'rate_limited', + message: 'Agent CLI token byte quota is exhausted', + retriable: false + } + ) + ), + 'utf8' + ) + } response.statusCode = responseStatus if (responseStatus >= 400) { response.shouldKeepAlive = false @@ -1097,4 +1176,11 @@ export class CliServer { response.setHeader('Content-Length', serialized.length) response.end(serialized) } + + private consumeAgentBytes(grant: AgentCliRequestGrant, bytes: number): void { + if (grant.consumeBytes(bytes)) return + throw new CliRequestError('rate_limited', 'Agent CLI token byte quota is exhausted', { + httpStatus: 429 + }) + } } diff --git a/src/main/routes/routeRegistry.ts b/src/main/routes/routeRegistry.ts index 84343d737..495dec014 100644 --- a/src/main/routes/routeRegistry.ts +++ b/src/main/routes/routeRegistry.ts @@ -18,6 +18,7 @@ export type AgentCliRouteCaller = Readonly<{ kind: 'cli' principal: 'agent' connectionId: string + tokenId: string scopes: readonly LocalControlScope[] conversationId: string expiresAt: number diff --git a/test/main/cli/agentTokenAuthority.test.ts b/test/main/cli/agentTokenAuthority.test.ts new file mode 100644 index 000000000..7642178ec --- /dev/null +++ b/test/main/cli/agentTokenAuthority.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from 'vitest' +import { AgentCliTokenAuthority, AgentCliTokenCapacityError } from '@/cli/agentTokenAuthority' + +function token(character: string): string { + return character.repeat(43) +} + +describe('AgentCliTokenAuthority', () => { + it('issues bounded in-memory claims and consumes call and byte quotas', () => { + let now = 1_000 + const authority = new AgentCliTokenAuthority({ + now: () => now, + createToken: () => token('a'), + createTokenId: () => 'token-id-1234567890' + }) + const issued = authority.issue({ + conversationId: ' conversation-1 ', + scopes: ['models:invoke'], + ttlMs: 1_000, + maxCalls: 2, + maxBytes: 5 + }) + + expect(issued).toMatchObject({ + token: token('a'), + tokenId: 'token-id-1234567890', + conversationId: 'conversation-1', + expiresAt: 2_000, + scopes: ['models:invoke'], + maxCalls: 2, + maxBytes: 5 + }) + const first = authority.beginRequest(issued.token) + expect(first.status).toBe('granted') + if (first.status !== 'granted') throw new Error('Expected grant') + expect(first.grant.consumeBytes(3)).toBe(true) + const second = authority.beginRequest(issued.token) + expect(second.status).toBe('granted') + if (second.status !== 'granted') throw new Error('Expected grant') + expect(second.grant.consumeBytes(3)).toBe(false) + expect(authority.beginRequest(issued.token)).toEqual({ status: 'quota-exhausted' }) + + now = 2_000 + expect(authority.beginRequest(issued.token)).toEqual({ status: 'expired' }) + expect(authority.snapshot()).toEqual({ tokens: 0, conversations: 0 }) + }) + + it('revokes every token and active grant for one conversation only', () => { + const generatedTokens = [token('a'), token('b'), token('c')] + const authority = new AgentCliTokenAuthority({ + createToken: () => generatedTokens.shift()!, + createTokenId: () => `token-id-${generatedTokens.length}`.padEnd(16, '0') + }) + const first = authority.issue({ conversationId: 'conversation-1' }) + const second = authority.issue({ conversationId: 'conversation-1' }) + const other = authority.issue({ conversationId: 'conversation-2' }) + const active = authority.beginRequest(first.token) + if (active.status !== 'granted') throw new Error('Expected grant') + const abort = vi.fn() + active.grant.signal.addEventListener('abort', abort) + + authority.revokeConversation('conversation-1') + + expect(abort).toHaveBeenCalledOnce() + expect(authority.beginRequest(first.token)).toEqual({ status: 'invalid' }) + expect(authority.beginRequest(second.token)).toEqual({ status: 'invalid' }) + expect(authority.beginRequest(other.token).status).toBe('granted') + }) + + it('replaces the oldest per-conversation token but fails closed at global capacity', () => { + const generatedTokens = [token('a'), token('b'), token('c')] + let tokenId = 0 + const authority = new AgentCliTokenAuthority({ + createToken: () => generatedTokens.shift()!, + createTokenId: () => `token-id-${String((tokenId += 1)).padStart(8, '0')}`, + maxTokens: 2, + maxTokensPerConversation: 1 + }) + const first = authority.issue({ conversationId: 'conversation-1' }) + const replacement = authority.issue({ conversationId: 'conversation-1' }) + + expect(authority.beginRequest(first.token)).toEqual({ status: 'invalid' }) + expect(authority.beginRequest(replacement.token).status).toBe('granted') + authority.issue({ conversationId: 'conversation-2' }) + expect(() => authority.issue({ conversationId: 'conversation-3' })).toThrow( + AgentCliTokenCapacityError + ) + }) + + it('never stores an invalid or duplicate generated token', () => { + const authority = new AgentCliTokenAuthority({ + createToken: () => 'not a token', + createTokenId: () => 'token-id-1234567890' + }) + + expect(() => authority.issue({ conversationId: 'conversation-1' })).toThrow() + expect(authority.snapshot()).toEqual({ tokens: 0, conversations: 0 }) + }) + + it('does not revoke an existing token when replacement allocation fails', () => { + const generatedTokens = [token('a'), 'not a token'] + let tokenId = 0 + const authority = new AgentCliTokenAuthority({ + createToken: () => generatedTokens.shift()!, + createTokenId: () => `token-id-${String((tokenId += 1)).padStart(8, '0')}`, + maxTokensPerConversation: 1 + }) + const existing = authority.issue({ conversationId: 'conversation-1' }) + + expect(() => authority.issue({ conversationId: 'conversation-1' })).toThrow() + expect(authority.beginRequest(existing.token).status).toBe('granted') + }) + + it('bounds custom lifetime, call, and byte limits', () => { + const authority = new AgentCliTokenAuthority() + + expect(() => + authority.issue({ conversationId: 'conversation-1', ttlMs: 60 * 60_000 + 1 }) + ).toThrow('ttlMs exceeds') + expect(() => authority.issue({ conversationId: 'conversation-1', maxCalls: 1025 })).toThrow( + 'maxCalls exceeds' + ) + expect(() => + authority.issue({ conversationId: 'conversation-1', maxBytes: 1024 * 1024 * 1024 + 1 }) + ).toThrow('maxBytes exceeds') + expect(authority.snapshot()).toEqual({ tokens: 0, conversations: 0 }) + }) +}) diff --git a/test/main/cli/artifactSpool.test.ts b/test/main/cli/artifactSpool.test.ts index e06c6da15..28deccf93 100644 --- a/test/main/cli/artifactSpool.test.ts +++ b/test/main/cli/artifactSpool.test.ts @@ -19,6 +19,7 @@ const agentCaller = (conversationId: string): AgentCliRouteCaller => ({ kind: 'cli', principal: 'agent', connectionId: `agent-${conversationId}`, + tokenId: `token-id-${conversationId}`, conversationId, expiresAt: Date.now() + 60_000, scopes: ['artifacts:read'] @@ -30,7 +31,7 @@ async function createSpool( const root = await mkdtemp(path.join(os.tmpdir(), 'deepchat-artifact-spool-')) temporaryDirectories.push(root) const directory = path.join(root, 'artifacts') - const spool = new ArtifactSpool({ directory, ...options }) + const spool = new ArtifactSpool({ directory, consumeAgentBytes: () => true, ...options }) spools.push(spool) return { spool, directory } } @@ -152,6 +153,31 @@ describe('ArtifactSpool', () => { await expect(spool.describe(metadata.id, humanCaller)).resolves.toEqual(metadata) }) + it('stops and removes an Agent artifact when its token byte quota is exhausted', async () => { + let remainingBytes = 5 + const { spool, directory } = await createSpool({ + consumeAgentBytes: (_tokenId, bytes) => { + if (bytes > remainingBytes) return false + remainingBytes -= bytes + return true + } + }) + async function* chunks(): AsyncGenerator { + yield Buffer.from('1234') + yield Buffer.from('5678') + } + + await expect( + spool.write({ + caller: agentCaller('conversation-a'), + requestId: 'request-quota', + mimeType: 'application/octet-stream', + data: chunks() + }) + ).rejects.toMatchObject({ code: 'rate_limited' }) + expect(await readdir(directory)).toEqual([]) + }) + it('accounts in-flight writes before enforcing aggregate quotas', async () => { const { spool } = await createSpool({ limits: { diff --git a/test/main/cli/mcpAdminRoutes.test.ts b/test/main/cli/mcpAdminRoutes.test.ts index 94c2fabb5..1d0aa89f3 100644 --- a/test/main/cli/mcpAdminRoutes.test.ts +++ b/test/main/cli/mcpAdminRoutes.test.ts @@ -373,6 +373,7 @@ describe('CLI MCP administration routes', () => { const agentCaller: CliRouteCaller = { ...caller, principal: 'agent', + tokenId: 'token-id-conversation-1', conversationId: 'conversation-1', expiresAt: Date.now() + 60_000 } diff --git a/test/main/cli/policy.test.ts b/test/main/cli/policy.test.ts index 29d9208d5..d8efbb264 100644 --- a/test/main/cli/policy.test.ts +++ b/test/main/cli/policy.test.ts @@ -24,6 +24,7 @@ const agentCaller: CliRouteCaller = { kind: 'cli', principal: 'agent', connectionId: 'agent-connection', + tokenId: 'token-id-conversation-1', conversationId: 'conversation-1', expiresAt: Date.now() + 60_000, scopes: ['settings:write'] diff --git a/test/main/cli/runService.test.ts b/test/main/cli/runService.test.ts index 014dd77be..5086f95a0 100644 --- a/test/main/cli/runService.test.ts +++ b/test/main/cli/runService.test.ts @@ -30,6 +30,7 @@ const agentCaller: CliRouteCaller = { kind: 'cli', principal: 'agent', connectionId: 'connection-agent', + tokenId: 'token-id-run-1', conversationId: 'run-1', expiresAt: 10_000, scopes: ['runs:read', 'runs:cancel'] diff --git a/test/main/cli/server.test.ts b/test/main/cli/server.test.ts index f15af11ae..0296acb15 100644 --- a/test/main/cli/server.test.ts +++ b/test/main/cli/server.test.ts @@ -20,7 +20,12 @@ import { type LocalControlUploadBinding } from '@shared/contracts/localControl' import { createCliRoutes } from '@/cli/routes' -import { CliServer, type AgentCliToken, type CliUploadedInputFile } from '@/cli/server' +import { CliServer, type CliUploadedInputFile } from '@/cli/server' +import { + AgentCliTokenAuthority, + type AgentCliRequestBeginResult, + type AgentCliTokenClaims +} from '@/cli/agentTokenAuthority' import type { CliRequestAdmission, CliRequestPolicyInput } from '@/cli/policy' import type { CliSurfaceEntry } from '@/cli/surface' import type { CliRouteCaller } from '@/routes/routeRegistry' @@ -32,6 +37,17 @@ type RpcResult = Readonly<{ body: LocalControlRpcResponse }> +function grantAgentRequest(claims: AgentCliTokenClaims): AgentCliRequestBeginResult { + return { + status: 'granted', + grant: { + claims, + signal: new AbortController().signal, + consumeBytes: () => true + } + } +} + const servers: CliServer[] = [] const temporaryDirectories: string[] = [] @@ -208,7 +224,7 @@ function createUploadSurface(maxBodyBytes: number): ReadonlyMap AgentCliToken | null + beginAgentRequest?: (token: string) => AgentCliRequestBeginResult dispatchOutput?: (method: string) => unknown streamOutput?: Readonly<{ events: readonly JsonValue[] @@ -275,7 +291,7 @@ async function createTestServer( } } : {}), - resolveAgentToken: options.resolveAgentToken, + beginAgentRequest: options.beginAgentRequest, dispatchUpload, ...(options.authorize ? { authorize } : {}), surface: options.surface, @@ -526,14 +542,15 @@ describe('CLI local transport', () => { const agentToken = 'a'.repeat(43) const { descriptor, userDataPath, dispatchUpload } = await createTestServer({ surface: createUploadSurface(16), - resolveAgentToken: (token) => + beginAgentRequest: (token) => token === agentToken - ? { + ? grantAgentRequest({ + tokenId: 'token-id-conversation-1', conversationId: 'conversation-1', expiresAt: Date.now() + 60_000, scopes: ['system:read'] - } - : null + }) + : { status: 'invalid' } }) const response = await uploadRequest(descriptor, { @@ -594,14 +611,15 @@ describe('CLI local transport', () => { let expiresAt = Date.now() - 1 const agentToken = 'g'.repeat(43) const { descriptor, dispatch } = await createTestServer({ - resolveAgentToken: (token) => + beginAgentRequest: (token) => token === agentToken - ? { + ? grantAgentRequest({ + tokenId: 'token-id-conversation-1', conversationId: 'conversation-1', expiresAt, scopes - } - : null + }) + : { status: 'invalid' } }) const expired = await rpcRequest(descriptor, { token: agentToken }) @@ -623,11 +641,90 @@ describe('CLI local transport', () => { expect(dispatch.mock.calls[0]?.[2]).toMatchObject({ kind: 'cli', principal: 'agent', + tokenId: 'token-id-conversation-1', conversationId: 'conversation-1', scopes: ['system:read'] }) }) + it('enforces Agent token call quotas before dispatch', async () => { + const agentToken = 'h'.repeat(43) + const authority = new AgentCliTokenAuthority({ + createToken: () => agentToken, + createTokenId: () => 'token-id-conversation-1' + }) + authority.issue({ + conversationId: 'conversation-1', + scopes: ['system:read'], + maxCalls: 1, + maxBytes: 16 * 1024 + }) + const { descriptor, dispatch } = await createTestServer({ + beginAgentRequest: (token) => authority.beginRequest(token) + }) + + const allowed = await rpcRequest(descriptor, { token: agentToken }) + const exhausted = await rpcRequest(descriptor, { token: agentToken }) + + expect(allowed).toMatchObject({ status: 200, body: { ok: true } }) + expect(exhausted).toMatchObject({ + status: 429, + body: { ok: false, error: { code: 'rate_limited' } } + }) + expect(dispatch).toHaveBeenCalledOnce() + }) + + it('stops reading when an Agent token byte quota is exhausted', async () => { + const agentToken = 'i'.repeat(43) + const authority = new AgentCliTokenAuthority({ + createToken: () => agentToken, + createTokenId: () => 'token-id-conversation-1' + }) + authority.issue({ + conversationId: 'conversation-1', + scopes: ['system:read'], + maxCalls: 2, + maxBytes: 1 + }) + const { descriptor, dispatch } = await createTestServer({ + beginAgentRequest: (token) => authority.beginRequest(token) + }) + + const exhausted = await rpcRequest(descriptor, { token: agentToken }) + + expect(exhausted).toMatchObject({ + status: 429, + body: { ok: false, error: { code: 'rate_limited' } } + }) + expect(dispatch).not.toHaveBeenCalled() + }) + + it('cancels an active Agent request when its conversation token is revoked', async () => { + const agentToken = 'j'.repeat(43) + const authority = new AgentCliTokenAuthority({ + createToken: () => agentToken, + createTokenId: () => 'token-id-conversation-1' + }) + authority.issue({ + conversationId: 'conversation-1', + scopes: ['system:read'], + maxBytes: 16 * 1024 + }) + const { descriptor, dispatch } = await createTestServer({ + beginAgentRequest: (token) => authority.beginRequest(token), + dispatchOutput: () => new Promise(() => undefined) + }) + + const response = rpcRequest(descriptor, { token: agentToken }) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + authority.revokeConversation('conversation-1') + + await expect(response).resolves.toMatchObject({ + status: 401, + body: { ok: false, error: { code: 'authentication_failed' } } + }) + }) + it('streams typed events and one terminal route result', async () => { const { server, descriptor } = await createTestServer({ streamOutput: { diff --git a/test/main/cli/skillService.test.ts b/test/main/cli/skillService.test.ts index 2626490cd..7fc17be8a 100644 --- a/test/main/cli/skillService.test.ts +++ b/test/main/cli/skillService.test.ts @@ -274,6 +274,7 @@ describe('CLI Skill service', () => { const agentCaller: CliRouteCaller = { ...caller, principal: 'agent', + tokenId: 'token-id-conversation-1', conversationId: 'conversation-1', expiresAt: Date.now() + 60_000 } diff --git a/test/main/routes/routeRegistry.test.ts b/test/main/routes/routeRegistry.test.ts index fd3408dae..4e72d5c5c 100644 --- a/test/main/routes/routeRegistry.test.ts +++ b/test/main/routes/routeRegistry.test.ts @@ -38,6 +38,7 @@ describe('route caller context', () => { kind: 'cli', principal: 'agent', connectionId: 'connection-2', + tokenId: 'token-id-session-1', scopes: ['models:invoke'], conversationId: 'session-1', expiresAt: Date.now() + 60_000 From da27c50b75b5751e09da8438c40042841ef46007 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 17:28:33 +0800 Subject: [PATCH 26/51] feat(cli): authorize agent command access --- scripts/build-cli.mjs | 27 +++- src/main/app/composition.ts | 15 +- src/main/cli/agentCommandAccess.ts | 66 +++++++++ src/main/cli/agentTokenAuthority.ts | 34 ++++- src/main/cli/index.ts | 5 + src/main/cli/server.ts | 11 ++ src/main/tool/agentTools/agentBashHandler.ts | 49 ++++++- src/main/tool/agentTools/agentToolManager.ts | 8 +- src/main/tool/index.ts | 3 + .../permission/commandPermissionService.ts | 4 + test/main/cli/agentCommandAccess.test.ts | 134 ++++++++++++++++++ test/main/cli/agentTokenAuthority.test.ts | 24 ++++ test/main/cli/server.test.ts | 25 ++-- test/main/scripts/buildCli.test.ts | 7 + .../tool/agentTools/agentBashHandler.test.ts | 74 ++++++++++ .../commandPermissionService.test.ts | 7 + 16 files changed, 468 insertions(+), 25 deletions(-) create mode 100644 src/main/cli/agentCommandAccess.ts create mode 100644 test/main/cli/agentCommandAccess.test.ts diff --git a/scripts/build-cli.mjs b/scripts/build-cli.mjs index 1f7440d2d..a4560c49c 100644 --- a/scripts/build-cli.mjs +++ b/scripts/build-cli.mjs @@ -22,11 +22,34 @@ while [ -L "$script_path" ]; do esac done script_dir=$(CDPATH= cd -P -- "$(dirname -- "$script_path")" && pwd) -exec "$script_dir/../runtime/node/bin/node" "$script_dir/deepchat.mjs" "$@" +runtime_node="$script_dir/../runtime/node/bin/node" +if [ ! -x "$runtime_node" ]; then + runtime_node="$script_dir/../../runtime/node/bin/node" +fi +if [ -x "$runtime_node" ]; then + exec "$runtime_node" "$script_dir/deepchat.mjs" "$@" +fi +if command -v node >/dev/null 2>&1; then + exec node "$script_dir/deepchat.mjs" "$@" +fi +echo "DeepChat CLI requires the bundled Node.js runtime or node on PATH." >&2 +exit 127 ` export const WINDOWS_LAUNCHER = `@echo off\r -"%~dp0..\\runtime\\node\\node.exe" "%~dp0deepchat.mjs" %*\r +set "runtime_node=%~dp0..\\runtime\\node\\node.exe"\r +if not exist "%runtime_node%" set "runtime_node=%~dp0..\\..\\runtime\\node\\node.exe"\r +if exist "%runtime_node%" goto bundled_runtime\r +where node >nul 2>&1\r +if errorlevel 1 goto missing_runtime\r +node "%~dp0deepchat.mjs" %*\r +exit /b %errorlevel%\r +:bundled_runtime\r +"%runtime_node%" "%~dp0deepchat.mjs" %*\r +exit /b %errorlevel%\r +:missing_runtime\r +echo DeepChat CLI requires the bundled Node.js runtime or node on PATH. 1>&2\r +exit /b 127\r ` export async function buildCli(options = {}) { diff --git a/src/main/app/composition.ts b/src/main/app/composition.ts index bb5d317cb..adf96853d 100644 --- a/src/main/app/composition.ts +++ b/src/main/app/composition.ts @@ -214,6 +214,7 @@ import { } from '@/routes' import { createNodeScheduler } from '@/routes/scheduler' import { + AgentCliCommandAccess, AgentCliTokenAuthority, ArtifactSpool, CliAudioTranscriptionService, @@ -229,7 +230,8 @@ import { createCliComputeRoutes, createCliMcpAdminRoutes, createCliProviderModelAdminRoutes, - createCliRoutes + createCliRoutes, + resolveBundledCliDirectory } from '@/cli' import { AcpRegistryMigrationService } from '@/agent/acp/catalog/acpRegistryMigrationService' import { killTerminal } from '@/agent/acp/launch/acpInitHelper' @@ -766,6 +768,16 @@ export async function createMainProcessControl(dependencies: { acpAsLlmProviderSessionControl = providerRuntime acpAsLlmProviderPermission = providerRuntime const commandPermissionHandler = new CommandPermissionService() + const agentCliCommandAccess = new AgentCliCommandAccess({ + tokenAuthority: agentCliTokenAuthority, + commandPermission: commandPermissionHandler, + resolveCliDirectory: () => + resolveBundledCliDirectory({ + appPath: app.getAppPath(), + resourcesPath: process.resourcesPath, + isPackaged: app.isPackaged + }) + }) commandPermissionService = commandPermissionHandler filePermissionService = new FilePermissionService() settingsPermissionService = new SettingsPermissionService() @@ -1230,6 +1242,7 @@ export async function createMainProcessControl(dependencies: { skillSettings, desktopSettings, commandPermissionHandler, + commandEnvironment: agentCliCommandAccess, permissionBroker: toolPermissionBroker, liveDelegationConsent, agentTools: agentToolDependencies, diff --git a/src/main/cli/agentCommandAccess.ts b/src/main/cli/agentCommandAccess.ts new file mode 100644 index 000000000..e2bfc4e03 --- /dev/null +++ b/src/main/cli/agentCommandAccess.ts @@ -0,0 +1,66 @@ +import * as fs from 'node:fs' +import path from 'node:path' +import { LOCAL_CONTROL_AGENT_TOKEN_ENV } from '@shared/contracts/localControl' +import type { CommandPermissionService } from '@/tool/permission/commandPermissionService' +import type { AgentCliTokenAuthority } from './agentTokenAuthority' + +const AGENT_CLI_COMMAND_PATTERN = /^deepchat\s+[a-z][a-z0-9-]*\s+[a-z][a-z0-9-]*(?:\s|$)/ +const AGENT_CLI_COMMAND_TOKEN_TTL_MS = 5 * 60_000 + +export type AgentCliCommandAccessOptions = Readonly<{ + tokenAuthority: Pick + commandPermission: Pick + resolveCliDirectory(): string | null +}> + +export function resolveBundledCliDirectory( + input: Readonly<{ + appPath: string + resourcesPath: string + isPackaged: boolean + platform?: NodeJS.Platform + isFile?: (filePath: string) => boolean + }> +): string | null { + const platform = input.platform ?? process.platform + const directory = input.isPackaged + ? path.join(input.resourcesPath, 'app.asar.unpacked', 'cli') + : path.join(input.appPath, 'out', 'cli') + const launcher = path.join(directory, platform === 'win32' ? 'deepchat.cmd' : 'deepchat') + if (input.isFile) return input.isFile(launcher) ? directory : null + try { + return fs.statSync(launcher).isFile() ? directory : null + } catch { + return null + } +} + +export class AgentCliCommandAccess { + constructor(private readonly options: AgentCliCommandAccessOptions) {} + + createEnvironment(conversationId: string, command: string): Record | undefined { + const normalizedConversationId = conversationId.trim() + const normalizedCommand = command.trim() + if ( + !normalizedConversationId || + this.options.commandPermission.extractBaseCommand(normalizedCommand) !== 'deepchat' || + this.options.commandPermission.hasShellControlSyntax(normalizedCommand) || + !AGENT_CLI_COMMAND_PATTERN.test(normalizedCommand) || + normalizedCommand.includes(LOCAL_CONTROL_AGENT_TOKEN_ENV) + ) { + return undefined + } + + const cliDirectory = this.options.resolveCliDirectory() + if (!cliDirectory) return undefined + const issued = this.options.tokenAuthority.issue({ + conversationId: normalizedConversationId, + ttlMs: AGENT_CLI_COMMAND_TOKEN_TTL_MS, + maxCalls: 1 + }) + return { + [LOCAL_CONTROL_AGENT_TOKEN_ENV]: issued.token, + PATH: cliDirectory + } + } +} diff --git a/src/main/cli/agentTokenAuthority.ts b/src/main/cli/agentTokenAuthority.ts index 4948fd3b8..30bf09233 100644 --- a/src/main/cli/agentTokenAuthority.ts +++ b/src/main/cli/agentTokenAuthority.ts @@ -53,6 +53,7 @@ export type AgentCliRequestGrant = Readonly<{ claims: AgentCliTokenClaims signal: AbortSignal consumeBytes(bytes: number): boolean + release(): void }> export type AgentCliRequestBeginResult = @@ -74,6 +75,7 @@ type TokenRecord = { maxBytes: number usedCalls: number usedBytes: number + activeRequests: number issuedAt: number controller: AbortController expiryTimer: NodeJS.Timeout @@ -162,7 +164,7 @@ export class AgentCliTokenAuthority { if (issuedAt > Number.MAX_SAFE_INTEGER - ttlMs) { throw new Error('Agent CLI token expiry is outside the supported range') } - this.pruneExpired(issuedAt) + this.pruneRetired(issuedAt) const replacesConversationToken = (this.digestsByConversation.get(conversationId)?.size ?? 0) >= this.maxTokensPerConversation if (this.recordsByDigest.size >= this.maxTokens && !replacesConversationToken) { @@ -192,6 +194,7 @@ export class AgentCliTokenAuthority { maxBytes, usedCalls: 0, usedBytes: 0, + activeRequests: 0, issuedAt, controller, expiryTimer @@ -220,12 +223,19 @@ export class AgentCliTokenAuthority { } record.usedCalls += 1 + record.activeRequests += 1 + let released = false return { status: 'granted', grant: { claims: record.claims, signal: record.controller.signal, - consumeBytes: (bytes) => this.consumeRecordBytes(record, bytes) + consumeBytes: (bytes) => this.consumeRecordBytes(record, bytes), + release: () => { + if (released) return + released = true + record.activeRequests = Math.max(0, record.activeRequests - 1) + } } } } @@ -250,7 +260,7 @@ export class AgentCliTokenAuthority { } snapshot(): Readonly<{ tokens: number; conversations: number }> { - this.pruneExpired(this.now()) + this.pruneRetired(this.now()) return { tokens: this.recordsByDigest.size, conversations: this.digestsByConversation.size @@ -305,13 +315,25 @@ export class AgentCliTokenAuthority { if (oldest) this.removeRecord(oldest.digest, 'replaced') } - private pruneExpired(now: number): void { + private pruneRetired(now: number): void { for (const record of this.recordsByDigest.values()) { - if (record.claims.expiresAt <= now) this.removeRecord(record.digest, 'expired') + if (record.claims.expiresAt <= now) { + this.removeRecord(record.digest, 'expired') + continue + } + if ( + record.activeRequests === 0 && + (record.usedCalls >= record.maxCalls || record.usedBytes >= record.maxBytes) + ) { + this.removeRecord(record.digest, 'exhausted') + } } } - private removeRecord(digest: string, reason: 'expired' | 'replaced' | 'revoked'): void { + private removeRecord( + digest: string, + reason: 'expired' | 'exhausted' | 'replaced' | 'revoked' + ): void { const record = this.recordsByDigest.get(digest) if (!record) return this.recordsByDigest.delete(digest) diff --git a/src/main/cli/index.ts b/src/main/cli/index.ts index e86a6af97..1315035df 100644 --- a/src/main/cli/index.ts +++ b/src/main/cli/index.ts @@ -6,6 +6,11 @@ export { type AgentCliTokenClaims, type IssuedAgentCliToken } from './agentTokenAuthority' +export { + AgentCliCommandAccess, + resolveBundledCliDirectory, + type AgentCliCommandAccessOptions +} from './agentCommandAccess' export { CliAuditLog, type CliAuditLogOptions } from './auditLog' export { ArtifactSpool, type ArtifactSpoolOptions } from './artifactSpool' export { createArtifactRoutes } from './artifactRoutes' diff --git a/src/main/cli/server.ts b/src/main/cli/server.ts index 8e27d3301..74b23f998 100644 --- a/src/main/cli/server.ts +++ b/src/main/cli/server.ts @@ -493,6 +493,7 @@ export class CliServer { const grant = beginResult?.status === 'granted' ? beginResult.grant : undefined const agent = AgentCliTokenSchema.safeParse(grant?.claims) if (!agent.success || agent.data.expiresAt <= this.now()) { + grant?.release() return { ok: false, quotaExhausted: false } } return { @@ -560,6 +561,16 @@ export class CliServer { return } const { caller, agentGrant } = authentication + if (agentGrant) { + let released = false + const releaseAgentGrant = () => { + if (released) return + released = true + agentGrant.release() + } + response.once('finish', releaseAgentGrant) + response.once('close', releaseAgentGrant) + } if (isArtifactRequest) { await this.handleArtifactDownload(request, response, caller) return diff --git a/src/main/tool/agentTools/agentBashHandler.ts b/src/main/tool/agentTools/agentBashHandler.ts index 9c7c7a660..bc977c22d 100644 --- a/src/main/tool/agentTools/agentBashHandler.ts +++ b/src/main/tool/agentTools/agentBashHandler.ts @@ -50,6 +50,10 @@ export interface ExecuteCommandOptions { allowExternalCwd?: boolean } +export interface AgentCommandEnvironmentPort { + createEnvironment(conversationId: string, command: string): Record | undefined +} + interface PreparedCommand { originalCommand: string command: string @@ -60,6 +64,11 @@ interface PreparedCommand { rtkFallbackReason?: string } +interface ResolvedCommandEnvironment { + env?: Record + preserveCommand: boolean +} + interface CompletedShellProcessResult { kind: 'completed' output: string @@ -84,7 +93,8 @@ export class AgentBashHandler { constructor( allowedDirectories: string[], settings: Pick, - commandPermissionHandler: CommandPermissionService + commandPermissionHandler: CommandPermissionService, + private readonly commandEnvironment?: AgentCommandEnvironmentPort ) { if (allowedDirectories.length === 0) { throw new Error('At least one allowed directory must be provided') @@ -139,7 +149,12 @@ export class AgentBashHandler { let result: ShellProcessResult - const prepared = await this.prepareCommand(command, options.env) + const resolvedEnvironment = this.resolveCommandEnvironment(command, options) + const prepared = await this.prepareCommand( + command, + resolvedEnvironment.env, + resolvedEnvironment.preserveCommand + ) result = await this.runShellProcess( prepared.command, @@ -576,7 +591,12 @@ export class AgentBashHandler { ) } - const prepared = await this.prepareCommand(command, options.env) + const resolvedEnvironment = this.resolveCommandEnvironment(command, options) + const prepared = await this.prepareCommand( + command, + resolvedEnvironment.env, + resolvedEnvironment.preserveCommand + ) const result = await backgroundExecSessionManager.start(conversationId, prepared.command, cwd, { timeout: timeout ?? COMMAND_DEFAULT_TIMEOUT_MS, @@ -603,13 +623,14 @@ export class AgentBashHandler { private async prepareCommand( command: string, - env?: Record + env?: Record, + preserveCommand = false ): Promise { const baseEnv = env ?? {} const prepared = await rtkRuntimeService.prepareShellCommand( command, baseEnv, - this.settings.get(RTK_ENABLED_SETTING_KEY) !== false + !preserveCommand && this.settings.get(RTK_ENABLED_SETTING_KEY) !== false ) return { originalCommand: prepared.originalCommand, @@ -618,7 +639,23 @@ export class AgentBashHandler { rewritten: prepared.rewritten, rtkApplied: prepared.rtkApplied, rtkMode: prepared.rtkMode, - rtkFallbackReason: prepared.rtkFallbackReason + rtkFallbackReason: preserveCommand + ? 'RTK rewrite bypassed for scoped command authority' + : prepared.rtkFallbackReason + } + } + + private resolveCommandEnvironment( + command: string, + options: ExecuteCommandOptions + ): ResolvedCommandEnvironment { + const scopedEnvironment = options.conversationId + ? this.commandEnvironment?.createEnvironment(options.conversationId, command) + : undefined + if (!scopedEnvironment) return { env: options.env, preserveCommand: false } + return { + env: { ...options.env, ...scopedEnvironment }, + preserveCommand: true } } diff --git a/src/main/tool/agentTools/agentToolManager.ts b/src/main/tool/agentTools/agentToolManager.ts index 55057aad7..bb673a868 100644 --- a/src/main/tool/agentTools/agentToolManager.ts +++ b/src/main/tool/agentTools/agentToolManager.ts @@ -14,7 +14,7 @@ import type { ToolCallImagePreview } from '@shared/types/core/mcp' import type { SkillManageResult } from '@shared/types/skill' import { buildBinaryReadGuidance, shouldRejectAgentBinaryRead } from '@/lib/binaryReadGuard' import { AgentFileSystemHandler, type ProtectedDirectoryRule } from './agentFileSystemHandler' -import { AgentBashHandler } from './agentBashHandler' +import { AgentBashHandler, type AgentCommandEnvironmentPort } from './agentBashHandler' import { AgentFffSearchHandler, GLOB_TOOL_NAME, @@ -116,6 +116,7 @@ interface AgentToolManagerOptions { skillSettings: SkillSettingsPort desktopSettings: AgentDisplaySettingsPort commandPermissionHandler: CommandPermissionService + commandEnvironment?: AgentCommandEnvironmentPort dependencies: AgentToolDependencies } @@ -163,6 +164,7 @@ export class AgentToolManager { private readonly agentSettings: Pick private readonly skillSettings: SkillSettingsPort private readonly desktopSettings: AgentDisplaySettingsPort + private readonly commandEnvironment?: AgentCommandEnvironmentPort private readonly dependencies: AgentToolDependencies private skillTools: SkillTools | null = null private skillExecutionService: SkillExecutionService | null = null @@ -338,6 +340,7 @@ export class AgentToolManager { this.skillSettings = options.skillSettings this.desktopSettings = options.desktopSettings this.commandPermissionHandler = options.commandPermissionHandler + this.commandEnvironment = options.commandEnvironment this.dependencies = options.dependencies this.liveDelegationTool = this.dependencies.liveDelegation ? new LiveDelegationAgentTool(this.dependencies.liveDelegation) @@ -1045,7 +1048,8 @@ export class AgentToolManager { const bashHandler = new AgentBashHandler( allowedDirectories, this.settings, - this.commandPermissionHandler + this.commandPermissionHandler, + this.commandEnvironment ) const execArgs = parsedArgs as { command: string diff --git a/src/main/tool/index.ts b/src/main/tool/index.ts index 947fdbcaa..4ebf7ab63 100644 --- a/src/main/tool/index.ts +++ b/src/main/tool/index.ts @@ -52,6 +52,7 @@ import { YO_BROWSER_TOOL_NAMES } from './browser/definitions' import type { SkillSettingsPort } from '@/skill/settings' import type { AgentSettingsPort } from '@/agent/settings' import type { SettingsStore } from '@/config/settingsStore' +import type { AgentCommandEnvironmentPort } from './agentTools/agentBashHandler' import type { ToolEffectObserver } from './effectObserver' import { resolvePluginToolPolicy } from '@/plugin/toolPolicyStore' import { composeSubagentAuthority } from '@/session/subagentAuthority' @@ -68,6 +69,7 @@ interface ToolServiceOptions { skillSettings: SkillSettingsPort desktopSettings: AgentDisplaySettingsPort commandPermissionHandler: CommandPermissionService + commandEnvironment?: AgentCommandEnvironmentPort permissionBroker?: ToolPermissionBroker liveDelegationConsent?: LiveDelegationConsentIssuer agentTools: AgentToolDependencies @@ -159,6 +161,7 @@ export class ToolService implements ToolServicePort { skillSettings: this.options.skillSettings, desktopSettings: this.options.desktopSettings, commandPermissionHandler: this.options.commandPermissionHandler, + commandEnvironment: this.options.commandEnvironment, dependencies: this.options.agentTools }) } diff --git a/src/main/tool/permission/commandPermissionService.ts b/src/main/tool/permission/commandPermissionService.ts index 70909938c..ce183b6f0 100644 --- a/src/main/tool/permission/commandPermissionService.ts +++ b/src/main/tool/permission/commandPermissionService.ts @@ -235,6 +235,10 @@ export class CommandPermissionService { return { level: 'medium', suggestion: SUGGESTION_KEYS.medium } } + hasShellControlSyntax(command: string): boolean { + return hasShellControlSyntax(command) + } + extractBaseCommand(command: string): string { const tokens = this.tokenize(command) if (tokens.length === 0) return '' diff --git a/test/main/cli/agentCommandAccess.test.ts b/test/main/cli/agentCommandAccess.test.ts new file mode 100644 index 000000000..0171265f4 --- /dev/null +++ b/test/main/cli/agentCommandAccess.test.ts @@ -0,0 +1,134 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { LOCAL_CONTROL_AGENT_TOKEN_ENV } from '@shared/contracts/localControl' +import { AgentCliCommandAccess, resolveBundledCliDirectory } from '@/cli/agentCommandAccess' +import { AgentCliTokenAuthority } from '@/cli/agentTokenAuthority' +import { CommandPermissionService } from '@/tool/permission/commandPermissionService' + +const temporaryDirectories: string[] = [] + +async function createCliDirectory(platform: NodeJS.Platform = 'darwin') { + const root = await mkdtemp(path.join(os.tmpdir(), 'deepchat-agent-cli-')) + temporaryDirectories.push(root) + const directory = path.join(root, 'out', 'cli') + await mkdir(directory, { recursive: true }) + await writeFile(path.join(directory, platform === 'win32' ? 'deepchat.cmd' : 'deepchat'), '') + return { root, directory } +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })) + ) +}) + +describe('AgentCliCommandAccess', () => { + it('issues one scoped call only for a standalone two-token CLI command', async () => { + const { directory } = await createCliDirectory() + const agentToken = 'a'.repeat(43) + const authority = new AgentCliTokenAuthority({ + now: () => 1_000, + createToken: () => agentToken, + createTokenId: () => 'token-id-conversation-1' + }) + const access = new AgentCliCommandAccess({ + tokenAuthority: authority, + commandPermission: new CommandPermissionService(), + resolveCliDirectory: () => directory + }) + + const environment = access.createEnvironment( + ' conversation-1 ', + 'deepchat model invoke --prompt hello --jsonl' + ) + + expect(environment).toEqual({ + [LOCAL_CONTROL_AGENT_TOKEN_ENV]: agentToken, + PATH: directory + }) + const first = authority.beginRequest(agentToken) + expect(first.status).toBe('granted') + if (first.status !== 'granted') throw new Error('Expected Agent CLI grant') + expect(first.grant.claims).toMatchObject({ + conversationId: 'conversation-1', + expiresAt: 301_000, + scopes: expect.arrayContaining(['models:invoke']) + }) + first.grant.release() + expect(authority.beginRequest(agentToken)).toEqual({ status: 'quota-exhausted' }) + }) + + it.each([ + 'deepchat --json model invoke', + 'deepchat model', + 'deepchat model invoke > output.txt', + 'deepchat model invoke | tee output.txt', + 'FOO=bar deepchat model invoke', + `deepchat model invoke --prompt $${LOCAL_CONTROL_AGENT_TOKEN_ENV}`, + 'ls -la' + ])('does not issue authority for %j', async (command) => { + const { directory } = await createCliDirectory() + const authority = new AgentCliTokenAuthority() + const access = new AgentCliCommandAccess({ + tokenAuthority: authority, + commandPermission: new CommandPermissionService(), + resolveCliDirectory: () => directory + }) + + expect(access.createEnvironment('conversation-1', command)).toBeUndefined() + expect(authority.snapshot()).toEqual({ tokens: 0, conversations: 0 }) + }) + + it('fails closed without a built launcher', () => { + const authority = new AgentCliTokenAuthority() + const access = new AgentCliCommandAccess({ + tokenAuthority: authority, + commandPermission: new CommandPermissionService(), + resolveCliDirectory: () => null + }) + + expect(access.createEnvironment('conversation-1', 'deepchat cli status')).toBeUndefined() + expect(authority.snapshot()).toEqual({ tokens: 0, conversations: 0 }) + }) +}) + +describe('resolveBundledCliDirectory', () => { + it('finds development and packaged launchers without guessing a missing path', async () => { + const development = await createCliDirectory() + expect( + resolveBundledCliDirectory({ + appPath: development.root, + resourcesPath: '/unused', + isPackaged: false, + platform: 'darwin', + isFile: (filePath) => filePath === path.join(development.directory, 'deepchat') + }) + ).toBe(development.directory) + + const packagedRoot = await mkdtemp(path.join(os.tmpdir(), 'deepchat-packaged-cli-')) + temporaryDirectories.push(packagedRoot) + const packagedDirectory = path.join(packagedRoot, 'app.asar.unpacked', 'cli') + await mkdir(packagedDirectory, { recursive: true }) + await writeFile(path.join(packagedDirectory, 'deepchat.cmd'), '') + expect( + resolveBundledCliDirectory({ + appPath: '/unused', + resourcesPath: packagedRoot, + isPackaged: true, + platform: 'win32', + isFile: (filePath) => filePath === path.join(packagedDirectory, 'deepchat.cmd') + }) + ).toBe(packagedDirectory) + expect( + resolveBundledCliDirectory({ + appPath: '/missing', + resourcesPath: '/missing', + isPackaged: false, + platform: 'linux', + isFile: () => false + }) + ).toBeNull() + }) +}) diff --git a/test/main/cli/agentTokenAuthority.test.ts b/test/main/cli/agentTokenAuthority.test.ts index 7642178ec..b42baa63f 100644 --- a/test/main/cli/agentTokenAuthority.test.ts +++ b/test/main/cli/agentTokenAuthority.test.ts @@ -34,10 +34,12 @@ describe('AgentCliTokenAuthority', () => { expect(first.status).toBe('granted') if (first.status !== 'granted') throw new Error('Expected grant') expect(first.grant.consumeBytes(3)).toBe(true) + first.grant.release() const second = authority.beginRequest(issued.token) expect(second.status).toBe('granted') if (second.status !== 'granted') throw new Error('Expected grant') expect(second.grant.consumeBytes(3)).toBe(false) + second.grant.release() expect(authority.beginRequest(issued.token)).toEqual({ status: 'quota-exhausted' }) now = 2_000 @@ -87,6 +89,28 @@ describe('AgentCliTokenAuthority', () => { ) }) + it('reclaims completed exhausted grants before enforcing global capacity', () => { + const generatedTokens = [token('a'), token('b')] + let tokenId = 0 + const authority = new AgentCliTokenAuthority({ + createToken: () => generatedTokens.shift()!, + createTokenId: () => `token-id-${String((tokenId += 1)).padStart(8, '0')}`, + maxTokens: 1 + }) + const first = authority.issue({ conversationId: 'conversation-1', maxCalls: 1 }) + const active = authority.beginRequest(first.token) + if (active.status !== 'granted') throw new Error('Expected grant') + + expect(() => authority.issue({ conversationId: 'conversation-2' })).toThrow( + AgentCliTokenCapacityError + ) + active.grant.release() + const second = authority.issue({ conversationId: 'conversation-2' }) + + expect(authority.beginRequest(first.token)).toEqual({ status: 'invalid' }) + expect(authority.beginRequest(second.token).status).toBe('granted') + }) + it('never stores an invalid or duplicate generated token', () => { const authority = new AgentCliTokenAuthority({ createToken: () => 'not a token', diff --git a/test/main/cli/server.test.ts b/test/main/cli/server.test.ts index 0296acb15..9d23afe6a 100644 --- a/test/main/cli/server.test.ts +++ b/test/main/cli/server.test.ts @@ -37,13 +37,17 @@ type RpcResult = Readonly<{ body: LocalControlRpcResponse }> -function grantAgentRequest(claims: AgentCliTokenClaims): AgentCliRequestBeginResult { +function grantAgentRequest( + claims: AgentCliTokenClaims, + release: () => void = () => undefined +): AgentCliRequestBeginResult { return { status: 'granted', grant: { claims, signal: new AbortController().signal, - consumeBytes: () => true + consumeBytes: () => true, + release } } } @@ -609,16 +613,20 @@ describe('CLI local transport', () => { it('applies agent expiry and scopes independently of the bearer token', async () => { let scopes: readonly LocalControlScope[] = ['models:read'] let expiresAt = Date.now() - 1 + const release = vi.fn() const agentToken = 'g'.repeat(43) const { descriptor, dispatch } = await createTestServer({ beginAgentRequest: (token) => token === agentToken - ? grantAgentRequest({ - tokenId: 'token-id-conversation-1', - conversationId: 'conversation-1', - expiresAt, - scopes - }) + ? grantAgentRequest( + { + tokenId: 'token-id-conversation-1', + conversationId: 'conversation-1', + expiresAt, + scopes + }, + release + ) : { status: 'invalid' } }) @@ -637,6 +645,7 @@ describe('CLI local transport', () => { body: { ok: false, error: { code: 'permission_denied' } } }) expect(allowed).toMatchObject({ status: 200, body: { ok: true } }) + expect(release).toHaveBeenCalledTimes(3) expect(dispatch).toHaveBeenCalledOnce() expect(dispatch.mock.calls[0]?.[2]).toMatchObject({ kind: 'cli', diff --git a/test/main/scripts/buildCli.test.ts b/test/main/scripts/buildCli.test.ts index d4d395e3d..f63baaf8e 100644 --- a/test/main/scripts/buildCli.test.ts +++ b/test/main/scripts/buildCli.test.ts @@ -21,17 +21,24 @@ describe('CLI bundle', () => { const entryPath = path.join(outputDirectory, 'deepchat.mjs') const source = await readFile(entryPath, 'utf8') const result = await execFileAsync(process.execPath, [entryPath, 'help', 'commands']) + const launcherResult = await execFileAsync(path.join(outputDirectory, 'deepchat'), [ + 'help', + 'commands' + ]) expect(source.startsWith('#!/usr/bin/env node')).toBe(true) expect(source).not.toMatch(/from\s+["']zod["']/) expect(result.stdout).toContain('deepchat ') + expect(launcherResult.stdout).toContain('deepchat ') expect((await stat(path.join(outputDirectory, 'deepchat'))).mode & 0o111).toBe(0o111) expect(await readFile(path.join(outputDirectory, 'deepchat'), 'utf8')).toBe(POSIX_LAUNCHER) expect(await readFile(path.join(outputDirectory, 'deepchat.cmd'), 'utf8')).toBe( WINDOWS_LAUNCHER ) expect(POSIX_LAUNCHER).toContain('../runtime/node/bin/node') + expect(POSIX_LAUNCHER).toContain('../../runtime/node/bin/node') expect(WINDOWS_LAUNCHER).toContain('..\\runtime\\node\\node.exe') + expect(WINDOWS_LAUNCHER).toContain('..\\..\\runtime\\node\\node.exe') } finally { await rm(outputDirectory, { recursive: true }) } diff --git a/test/main/tool/agentTools/agentBashHandler.test.ts b/test/main/tool/agentTools/agentBashHandler.test.ts index 821fb89e1..d807f7200 100644 --- a/test/main/tool/agentTools/agentBashHandler.test.ts +++ b/test/main/tool/agentTools/agentBashHandler.test.ts @@ -125,6 +125,80 @@ describe('AgentBashHandler', () => { expect(result.output).toContain('Exit Code: 2') }) + it('creates a scoped command environment only after command approval', async () => { + const permissionService = new CommandPermissionService() + permissionService.approve('conv-1', 'deepchat model', false) + const commandEnvironment = { + createEnvironment: vi.fn(() => ({ + DEEPCHAT_CLI_AGENT_TOKEN: 'scoped-token', + PATH: '/bundled/cli' + })) + } + const handler = new AgentBashHandler( + ['/workspace'], + { get: () => undefined }, + permissionService, + commandEnvironment + ) + const prepareCommand = vi.spyOn(handler as never, 'prepareCommand' as never).mockResolvedValue({ + originalCommand: 'deepchat model invoke --prompt hello', + command: 'deepchat model invoke --prompt hello', + env: { DEEPCHAT_CLI_AGENT_TOKEN: 'scoped-token', PATH: '/bundled/cli' }, + rewritten: false, + rtkApplied: false, + rtkMode: 'bypass' + }) + vi.spyOn(handler as never, 'runShellProcess' as never).mockResolvedValue({ + kind: 'completed', + output: 'ok', + exitCode: 0, + timedOut: false, + offloaded: false + }) + + await handler.executeCommand( + { + command: 'deepchat model invoke --prompt hello', + description: 'Invoke model' + }, + { conversationId: 'conv-1' } + ) + + expect(commandEnvironment.createEnvironment).toHaveBeenCalledWith( + 'conv-1', + 'deepchat model invoke --prompt hello' + ) + expect(prepareCommand).toHaveBeenCalledWith( + 'deepchat model invoke --prompt hello', + { + DEEPCHAT_CLI_AGENT_TOKEN: 'scoped-token', + PATH: '/bundled/cli' + }, + true + ) + }) + + it('does not issue a scoped environment while command approval is pending', async () => { + const commandEnvironment = { createEnvironment: vi.fn(() => ({})) } + const handler = new AgentBashHandler( + ['/workspace'], + { get: () => undefined }, + new CommandPermissionService(), + commandEnvironment + ) + + await expect( + handler.executeCommand( + { + command: 'deepchat model invoke --prompt hello', + description: 'Invoke model' + }, + { conversationId: 'conv-1' } + ) + ).rejects.toMatchObject({ name: 'Error', message: 'Command permission required' }) + expect(commandEnvironment.createEnvironment).not.toHaveBeenCalled() + }) + it('does not fall back when the rewritten command times out', async () => { const handler = new AgentBashHandler( ['/workspace'], diff --git a/test/main/tool/permission/commandPermissionService.test.ts b/test/main/tool/permission/commandPermissionService.test.ts index d276d8584..bc3c02193 100644 --- a/test/main/tool/permission/commandPermissionService.test.ts +++ b/test/main/tool/permission/commandPermissionService.test.ts @@ -86,6 +86,13 @@ describe('CommandPermissionService', () => { expect(result.signature).toMatch(/^shell:[a-f0-9]{64}$/) }) + it('exposes shell-control classification to trusted command adapters', () => { + const service = new CommandPermissionService() + + expect(service.hasShellControlSyntax('deepchat model invoke')).toBe(false) + expect(service.hasShellControlSyntax('deepchat model invoke > output.txt')).toBe(true) + }) + it('does not let a broad command approval authorize a redirected command', () => { const service = new CommandPermissionService() service.approve('conv-1', 'deepchat model', false) From 2c7b9f9b04639a7f492f7db2dd79c73c6197d256 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 17:41:04 +0800 Subject: [PATCH 27/51] feat(cli): add bundled agent skill --- resources/skills/deepchat-cli/SKILL.md | 111 ++++++++++++++++++ src/main/skill/index.ts | 35 +++++- src/shared/types/skill.ts | 2 + .../skill/skillServiceAgentScopes.test.ts | 64 ++++++++++ 4 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 resources/skills/deepchat-cli/SKILL.md diff --git a/resources/skills/deepchat-cli/SKILL.md b/resources/skills/deepchat-cli/SKILL.md new file mode 100644 index 000000000..6d7bb8814 --- /dev/null +++ b/resources/skills/deepchat-cli/SKILL.md @@ -0,0 +1,111 @@ +--- +name: deepchat-cli +description: Use DeepChat's bundled CLI control plane for model inference, image/video/speech generation, transcription, OCR, artifact inspection, public configuration, Skills, and MCP operations. Activate when a user asks to invoke DeepChat capabilities that are not already exposed as a more specific tool, compare models, run a benchmark, inspect DeepChat runtime state, or manage DeepChat through the CLI. +allowedTools: + - exec + - process +--- + +# DeepChat CLI + +Use the bundled `deepchat` command to ask the running DeepChat main process to perform supported +operations. The main process remains the sole owner of providers, credentials, Skills, MCP servers, +artifacts, Agent runs, and approvals. + +## Command rules + +- Every command must begin exactly with `deepchat `. Put `--json`, `--jsonl`, + `--timeout`, and all domain options after the domain and verb. +- Execute one standalone command per `exec` call. Do not use pipes, redirection, command separators, + command substitution, environment assignments, or shell wrappers around `deepchat`. +- Quote every user-controlled argument for the current shell. Never interpolate untrusted text into + an unquoted command. +- Prefer `--json` for one result and `--jsonl` for streaming or benchmark collection. Use text mode + only when its output will be returned directly to the user. +- Do not inspect authentication environment variables or DeepChat's local descriptor. Authorization + is injected only after the command has passed the normal shell permission check. +- A shell approval authorizes command execution. Sensitive mutations can additionally pause for a + renderer approval; wait for that decision and never attempt to manufacture confirmation data. +- Use `deepchat help commands` or `deepchat --help` only when the options below are + insufficient. Do not probe undocumented routes. + +## Agent file and recursion boundaries + +- Agent callers may consume a DeepChat-owned artifact with `--artifact ` and inspect metadata + with `artifact describe`. +- Do not use `--file`, `--out`, `--overwrite`, `artifact get`, or `artifact delete`. Agent callers + cannot upload arbitrary local bytes, download artifact bytes, or choose output paths. +- Do not call `agent run`; an Agent cannot recursively create a detached Agent run. +- Generated media remains in DeepChat's artifact spool. Return the artifact metadata or ID so the + application can render or reuse it. + +## Discovery and model calls + +```text +deepchat system status --json +deepchat system capabilities --json +deepchat system doctor --json +deepchat provider list --enabled-only --json +deepchat model list --provider --json +deepchat model config-get --provider --model --json +deepchat model invoke --provider --model --prompt --jsonl +``` + +Always discover provider and model IDs rather than guessing them. `model invoke` is a raw provider +call: it does not create a chat session, run tools, or start an Agent loop. + +## Media, transcription, and OCR + +```text +deepchat image generate --provider --model --prompt --jsonl +deepchat video generate --provider --model --prompt --jsonl +deepchat audio speak --provider --model --text --jsonl +deepchat audio transcribe --provider --model --artifact --json +deepchat ocr status --json +deepchat ocr extract --artifact --json +deepchat artifact describe --id --json +``` + +Use the provider/model lists to choose a compatible runtime. OCR is local and does not require a +provider. OCR text is returned inline and is not written to the artifact spool. + +## Public configuration and management + +Read-only operations: + +```text +deepchat settings get --json +deepchat skill list --json +deepchat mcp list --json +``` + +Only perform a mutation when it directly satisfies the user's request. Supported examples include: + +```text +deepchat settings set --key --value --json +deepchat model enable --provider --model --json +deepchat model disable --provider --model --json +deepchat skill enable --name --json +deepchat skill disable --name --json +deepchat skill remove --name --json +deepchat mcp enable --name --json +deepchat mcp disable --name --json +deepchat mcp start --name --json +deepchat mcp stop --name --json +deepchat mcp remove --name --json +``` + +Credential writes, local Skill archives, and MCP JSON installation require stdin or local-file input +and are intentionally unavailable through Agent shell execution. Ask the user to complete those +operations through the DeepChat UI or a human terminal. + +## Benchmark discipline + +- Pin provider/model IDs and pass per-invocation options; do not mutate global defaults to prepare a + benchmark. +- Record structured output, exit status, wall time, and errors. Preserve failed samples. +- For OCR, distinguish cache hit, cache miss with warm runtime, cold runtime after app restart, and + offline availability. `ocr clear-cache` warms resources before clearing, so the next extraction is + not a cold-runtime sample. +- Run samples sequentially unless the benchmark explicitly measures concurrency; Agent compute is + rate-limited and bounded by the main process. diff --git a/src/main/skill/index.ts b/src/main/skill/index.ts index 372742f44..6241f139d 100644 --- a/src/main/skill/index.ts +++ b/src/main/skill/index.ts @@ -67,6 +67,7 @@ import { } from './agentSkillRoots' const execFileAsync = promisify(execFile) +const READ_ONLY_BUNDLED_SKILL_NAMES = new Set(['deepchat-cli']) /** * Skill system configuration constants @@ -271,6 +272,7 @@ export class SkillService implements SkillServicePort { private draftsRoot: string private metadataCache: Map = new Map() private contentCache: Map = new Map() + private readOnlyBundledSkills: SkillMetadata[] = [] private scopedCatalogs: Map = new Map() private deletedAgentScopes: Set = new Set() private activeAgentScopeOperations: Map = new Map() @@ -600,6 +602,7 @@ export class SkillService implements SkillServicePort { for (const metadata of [ ...discoveredSkills, + ...this.readOnlyBundledSkills, ...(await this.discoverPluginSkillsOnMainThread()) ]) { if (this.metadataCache.has(metadata.name)) { @@ -913,6 +916,7 @@ export class SkillService implements SkillServicePort { for (const metadata of [ ...discoveredSkills, + ...this.readOnlyBundledSkills, ...(await this.discoverPluginSkillsOnMainThread()) ]) { if (!discoveredByName.has(metadata.name)) { @@ -993,6 +997,22 @@ export class SkillService implements SkillServicePort { return discovered } + private async discoverReadOnlyBundledSkills(): Promise { + const builtinDir = this.resolveBuiltinSkillsDir() + if (!builtinDir) return [] + + const discovered: SkillMetadata[] = [] + for (const name of READ_ONLY_BUNDLED_SKILL_NAMES) { + const skillPath = path.join(builtinDir, name, 'SKILL.md') + if (!(await this.pathExists(skillPath))) continue + const metadata = await this.parseSkillMetadata(skillPath, name, undefined, builtinDir) + if (metadata && this.supportsCurrentPlatform(metadata.platforms)) { + discovered.push({ ...metadata, readOnly: true }) + } + } + return discovered + } + /** * Parse SKILL.md frontmatter to extract metadata */ @@ -1376,12 +1396,12 @@ export class SkillService implements SkillServicePort { return { ...skill, agentId: normalizedAgentId, - canonicalPath: item.canonicalPath || skill.skillRoot, - sourceType: item.source.type, + canonicalPath: skill.readOnly ? skill.skillRoot : item.canonicalPath || skill.skillRoot, + sourceType: skill.readOnly ? 'builtin' : item.source.type, disabled: item.disabled, deepchatDisabled: item.disabled, agentLinks: item.agentLinks ?? {}, - mutable: !skill.ownerPluginId + mutable: !skill.ownerPluginId && !skill.readOnly } }) } @@ -2028,12 +2048,14 @@ export class SkillService implements SkillServicePort { async installBuiltinSkills(): Promise { const builtinDir = this.resolveBuiltinSkillsDir() if (!builtinDir || !fs.existsSync(builtinDir)) { + this.readOnlyBundledSkills = [] return } const entries = fs.readdirSync(builtinDir, { withFileTypes: true }) for (const entry of entries) { if (!entry.isDirectory()) continue + if (READ_ONLY_BUNDLED_SKILL_NAMES.has(entry.name)) continue const skillDir = path.join(builtinDir, entry.name) const skillMdPath = path.join(skillDir, 'SKILL.md') if (!fs.existsSync(skillMdPath)) continue @@ -2054,6 +2076,7 @@ export class SkillService implements SkillServicePort { console.warn('[SkillService] Failed to install builtin skill:', result.error) } } + this.readOnlyBundledSkills = await this.discoverReadOnlyBundledSkills() } private supportsCurrentPlatform(platforms?: string[]): boolean { @@ -3458,6 +3481,8 @@ export class SkillService implements SkillServicePort { } this.cleanupUninstalledSkillState(name, normalizedAgentId) + const bundledFallback = this.readOnlyBundledSkills.find((skill) => skill.name === name) + if (bundledFallback) metadataCache.set(name, bundledFallback) this.publishEvent('skills.catalog.changed', { reason: 'uninstalled', @@ -3554,6 +3579,9 @@ export class SkillService implements SkillServicePort { } private assertMutableSkillOwnership(agentId: string, metadata: SkillMetadata): void { + if (metadata.readOnly) { + throw new Error('Read-only bundled Skills cannot be modified as Agent-owned files') + } if (metadata.ownerPluginId) { throw new Error('Plugin-owned Skills cannot be modified as Agent-owned files') } @@ -4576,6 +4604,7 @@ export class SkillService implements SkillServicePort { await this.stopWatching() this.metadataCache.clear() this.contentCache.clear() + this.readOnlyBundledSkills = [] this.scopedCatalogs.clear() this.deletedAgentScopes.clear() this.activeAgentScopeOperations.clear() diff --git a/src/shared/types/skill.ts b/src/shared/types/skill.ts index aec3e20d7..c69fed857 100644 --- a/src/shared/types/skill.ts +++ b/src/shared/types/skill.ts @@ -37,6 +37,8 @@ export interface SkillMetadata { allowedTools?: string[] /** Plugin owner id when the skill is contributed by a plugin */ ownerPluginId?: string + /** DeepChat-owned resource exposed read-only without copying into an Agent Skill root */ + readOnly?: boolean } /** diff --git a/test/main/skill/skillServiceAgentScopes.test.ts b/test/main/skill/skillServiceAgentScopes.test.ts index 29a633dc1..056acc166 100644 --- a/test/main/skill/skillServiceAgentScopes.test.ts +++ b/test/main/skill/skillServiceAgentScopes.test.ts @@ -367,6 +367,70 @@ describe('SkillService Agent scopes', () => { expect(fs.existsSync(writerRoot)).toBe(false) }) + it('shares the bundled CLI Skill read-only without copying it into Agent roots', async () => { + agents.push({ id: 'writer' }) + vi.mocked(app.getAppPath).mockReturnValue(temporaryRoot) + const resourceRoot = writeSkill( + path.join(temporaryRoot, 'resources', 'skills'), + 'deepchat-cli', + '# Bundled CLI' + ) + + await service.installBuiltinSkills() + const catalog = await service.getUnifiedSkillCatalog('writer') + + expect(catalog).toEqual([ + expect.objectContaining({ + name: 'deepchat-cli', + skillRoot: resourceRoot, + canonicalPath: resourceRoot, + sourceType: 'builtin', + mutable: false, + readOnly: true + }) + ]) + expect( + fs.existsSync(path.join(resolveAgentSkillsRoot(skillsRoot, 'writer'), 'deepchat-cli')) + ).toBe(false) + await expect(service.uninstallSkillForAgent('writer', 'deepchat-cli')).resolves.toMatchObject({ + success: false, + error: expect.stringContaining('Read-only bundled Skills') + }) + expect(fs.existsSync(path.join(resourceRoot, 'SKILL.md'))).toBe(true) + }) + + it('keeps an Agent-owned Skill when it collides with a read-only bundled name', async () => { + agents.push({ id: 'writer' }) + vi.mocked(app.getAppPath).mockReturnValue(temporaryRoot) + writeSkill(path.join(temporaryRoot, 'resources', 'skills'), 'deepchat-cli', '# Bundled CLI') + const writerRoot = resolveAgentSkillsRoot(skillsRoot, 'writer') + const customRoot = writeSkill(writerRoot, 'deepchat-cli', '# Agent custom CLI') + + await service.installBuiltinSkills() + const catalog = await service.getUnifiedSkillCatalog('writer') + + expect(catalog).toEqual([ + expect.objectContaining({ + name: 'deepchat-cli', + skillRoot: customRoot, + sourceType: 'created', + mutable: true + }) + ]) + expect(catalog[0]).not.toHaveProperty('readOnly', true) + + await expect(service.uninstallSkillForAgent('writer', 'deepchat-cli')).resolves.toMatchObject({ + success: true + }) + await expect(service.getUnifiedSkillCatalog('writer')).resolves.toEqual([ + expect.objectContaining({ + name: 'deepchat-cli', + readOnly: true, + mutable: false + }) + ]) + }) + it('preserves a preexisting independent Agent root instead of overwriting it', async () => { agents.push({ id: 'writer' }) const writerRoot = resolveAgentSkillsRoot(skillsRoot, 'writer') From e15fbc40e9cecd5f57b45ec24a63341d0c33e622 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 17:59:06 +0800 Subject: [PATCH 28/51] feat(cli): add reversible launcher install --- src/main/app/composition.ts | 41 +- src/main/cli/index.ts | 8 + src/main/cli/launcherRoutes.ts | 32 + src/main/cli/launcherService.ts | 1051 +++++++++++++++++++++ src/shared/contracts/routes.ts | 4 + src/shared/contracts/routes/cli.routes.ts | 46 + test/main/cli/launcherRoutes.test.ts | 46 + test/main/cli/launcherService.test.ts | 305 ++++++ 8 files changed, 1527 insertions(+), 6 deletions(-) create mode 100644 src/main/cli/launcherRoutes.ts create mode 100644 src/main/cli/launcherService.ts create mode 100644 test/main/cli/launcherRoutes.test.ts create mode 100644 test/main/cli/launcherService.test.ts diff --git a/src/main/app/composition.ts b/src/main/app/composition.ts index adf96853d..5cfaaf067 100644 --- a/src/main/app/composition.ts +++ b/src/main/app/composition.ts @@ -220,6 +220,7 @@ import { CliAudioTranscriptionService, CliAuditLog, CliComputeService, + CliLauncherService, CliMutationGuard, CliOcrService, CliRequestPolicy, @@ -229,6 +230,7 @@ import { createArtifactRoutes, createCliComputeRoutes, createCliMcpAdminRoutes, + createCliLauncherRoutes, createCliProviderModelAdminRoutes, createCliRoutes, resolveBundledCliDirectory @@ -768,15 +770,24 @@ export async function createMainProcessControl(dependencies: { acpAsLlmProviderSessionControl = providerRuntime acpAsLlmProviderPermission = providerRuntime const commandPermissionHandler = new CommandPermissionService() + const resolveCliDirectory = () => + resolveBundledCliDirectory({ + appPath: app.getAppPath(), + resourcesPath: process.resourcesPath, + isPackaged: app.isPackaged + }) + const cliLauncherService = new CliLauncherService({ + homeDirectory: app.getPath('home'), + userDataDirectory: app.getPath('userData'), + environmentPath: process.env.PATH, + shell: process.env.SHELL, + localAppDataDirectory: process.env.LOCALAPPDATA, + resolveCliDirectory + }) const agentCliCommandAccess = new AgentCliCommandAccess({ tokenAuthority: agentCliTokenAuthority, commandPermission: commandPermissionHandler, - resolveCliDirectory: () => - resolveBundledCliDirectory({ - appPath: app.getAppPath(), - resourcesPath: process.resourcesPath, - isPackaged: app.isPackaged - }) + resolveCliDirectory }) commandPermissionService = commandPermissionHandler filePermissionService = new FilePermissionService() @@ -2464,6 +2475,7 @@ export async function createMainProcessControl(dependencies: { (target) => target.kind === 'main' ) }) + const cliLauncherRoutes = createCliLauncherRoutes(cliLauncherService) const approvalRoutes = createApprovalRoutes({ resolve: (input, caller) => cliMutationGuard.resolve(input, caller) }) @@ -2526,6 +2538,7 @@ export async function createMainProcessControl(dependencies: { appRoutes, approvalRoutes, cliRoutes, + cliLauncherRoutes, artifactRoutes, cliComputeRoutes, cliProviderModelAdminRoutes, @@ -2830,6 +2843,17 @@ export async function createMainProcessControl(dependencies: { async function resetApplicationData( resetType: 'chat' | 'knowledge' | 'config' | 'all' ): Promise { + if (resetType === 'all') { + const launcherStatus = await cliLauncherService.getStatus() + if (launcherStatus.state === 'conflict' && launcherStatus.reason !== 'unowned-command') { + throw new Error( + 'Cannot reset application data while the owned DeepChat CLI launcher is inconsistent' + ) + } + if (launcherStatus.reason !== 'unowned-command') { + await cliLauncherService.setInstalled(false) + } + } await stop() await deviceService.resetDataByType(resetType) } @@ -2926,6 +2950,11 @@ export async function createMainProcessControl(dependencies: { } catch (error) { logger.error('[CLI] Failed to start local control server', error) } + try { + await cliLauncherService.reconcileOwnedLauncher() + } catch (error) { + logger.warn('[CLI] Failed to refresh the owned command launcher', error) + } init(dependencies.startupRunId) scheduleBackgroundWork() return control diff --git a/src/main/cli/index.ts b/src/main/cli/index.ts index 1315035df..a3543c146 100644 --- a/src/main/cli/index.ts +++ b/src/main/cli/index.ts @@ -24,6 +24,14 @@ export { createCliMcpAdminRoutes, type CliMcpAdminDependencies } from './mcpAdmi export { CliSkillService, type CliSkillServiceOptions } from './skillService' export { CliRunService, type CliRunServiceOptions } from './runService' export { createCliRoutes, type CliRuntimeStatus } from './routes' +export { + CliLauncherService, + type CliLauncherReason, + type CliLauncherServiceOptions, + type CliLauncherState, + type CliLauncherStatus +} from './launcherService' +export { createCliLauncherRoutes } from './launcherRoutes' export { createCliProviderModelAdminRoutes, type CliProviderModelAdminDependencies diff --git a/src/main/cli/launcherRoutes.ts b/src/main/cli/launcherRoutes.ts new file mode 100644 index 000000000..5b263c6e5 --- /dev/null +++ b/src/main/cli/launcherRoutes.ts @@ -0,0 +1,32 @@ +import { cliGetLauncherStatusRoute, cliSetLauncherInstalledRoute } from '@shared/contracts/routes' +import { + createRouteMap, + requireRendererCaller, + type DeepchatRouteMap +} from '@/routes/routeRegistry' +import type { CliLauncherService } from './launcherService' + +export function createCliLauncherRoutes( + launcher: Pick +): DeepchatRouteMap { + return createRouteMap([ + [ + cliGetLauncherStatusRoute.name, + async (rawInput, context) => { + requireRendererCaller(context) + cliGetLauncherStatusRoute.input.parse(rawInput) + return cliGetLauncherStatusRoute.output.parse(await launcher.getStatus()) + } + ], + [ + cliSetLauncherInstalledRoute.name, + async (rawInput, context) => { + requireRendererCaller(context) + const input = cliSetLauncherInstalledRoute.input.parse(rawInput) + return cliSetLauncherInstalledRoute.output.parse( + await launcher.setInstalled(input.installed) + ) + } + ] + ]) +} diff --git a/src/main/cli/launcherService.ts b/src/main/cli/launcherService.ts new file mode 100644 index 000000000..9ca5ce81e --- /dev/null +++ b/src/main/cli/launcherService.ts @@ -0,0 +1,1051 @@ +import { createHash, randomUUID } from 'node:crypto' +import { + chmod, + lstat, + mkdir, + readFile, + readlink, + realpath, + rename, + symlink, + unlink, + writeFile +} from 'node:fs/promises' +import path from 'node:path' +import type { CliLauncherStatus } from '@shared/contracts/routes' + +export type { + CliLauncherReason, + CliLauncherState, + CliLauncherStatus +} from '@shared/contracts/routes' + +const LAUNCHER_MARKER_VERSION = 1 +const LAUNCHER_MARKER_FILENAME = 'launcher.json' +const MANAGED_BLOCK_START = '# >>> DeepChat CLI >>>' +const MANAGED_BLOCK_END = '# <<< DeepChat CLI <<<' +const MAX_MARKER_BYTES = 16 * 1024 +const MAX_SHELL_CONFIG_BYTES = 1024 * 1024 + +type PosixProfileKind = 'zsh' | 'bash' | 'bash-login' | 'fish' | 'profile' + +type PosixLauncherMarker = Readonly<{ + version: 1 + platform: 'posix' + commandPath: string + launcherTarget: string + profileKind: PosixProfileKind | null + profilePrefixLength: 0 | 1 | 2 + profileCreated: boolean +}> + +type WindowsLauncherMarker = Readonly<{ + version: 1 + platform: 'windows' + commandPath: string + commandHash: string +}> + +type LauncherMarker = PosixLauncherMarker | WindowsLauncherMarker + +export type CliLauncherServiceOptions = Readonly<{ + platform?: NodeJS.Platform + homeDirectory: string + userDataDirectory: string + environmentPath?: string + shell?: string + localAppDataDirectory?: string + resolveCliDirectory(): string | null +}> + +type MarkerReadResult = + | Readonly<{ state: 'missing'; raw: null }> + | Readonly<{ state: 'invalid'; raw: string }> + | Readonly<{ state: 'valid'; raw: string; marker: LauncherMarker }> + +type ProfileInspection = Readonly<{ + kind: PosixProfileKind + path: string + content: string + exists: boolean + blockState: 'missing' | 'exact' | 'modified' +}> + +type AppendedManagedBlock = Readonly<{ + content: string + prefixLength: 0 | 1 | 2 +}> + +type CliSource = Readonly<{ + directory: string + posixLauncher: string + modulePath: string +}> + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex') +} + +function isMissingFileError(error: unknown): boolean { + return (error as NodeJS.ErrnoException).code === 'ENOENT' +} + +function isPathWithin(root: string, candidate: string): boolean { + const relative = path.relative(path.resolve(root), path.resolve(candidate)) + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) +} + +function isPosixProfileKind(value: unknown): value is PosixProfileKind { + return ( + value === 'zsh' || + value === 'bash' || + value === 'bash-login' || + value === 'fish' || + value === 'profile' + ) +} + +function parseLauncherMarker(value: unknown): LauncherMarker | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const marker = value as Record + if (marker.version !== LAUNCHER_MARKER_VERSION || typeof marker.commandPath !== 'string') { + return null + } + if ( + marker.platform === 'posix' && + typeof marker.launcherTarget === 'string' && + (marker.profileKind === null || isPosixProfileKind(marker.profileKind)) && + (marker.profilePrefixLength === 0 || + marker.profilePrefixLength === 1 || + marker.profilePrefixLength === 2) && + typeof marker.profileCreated === 'boolean' + ) { + return marker as PosixLauncherMarker + } + if ( + marker.platform === 'windows' && + typeof marker.commandHash === 'string' && + /^[0-9a-f]{64}$/.test(marker.commandHash) + ) { + return marker as WindowsLauncherMarker + } + return null +} + +function managedProfileBlock(kind: PosixProfileKind): string { + const command = + kind === 'fish' + ? 'fish_add_path --global --move "$HOME/.local/bin"' + : 'case ":$PATH:" in\n *":$HOME/.local/bin:"*) ;;\n *) export PATH="$HOME/.local/bin:$PATH" ;;\nesac' + return `${MANAGED_BLOCK_START}\n${command}\n${MANAGED_BLOCK_END}` +} + +function inspectManagedBlock(content: string, block: string): ProfileInspection['blockState'] { + const blockOccurrences = content.split(block).length - 1 + const startOccurrences = content.split(MANAGED_BLOCK_START).length - 1 + const endOccurrences = content.split(MANAGED_BLOCK_END).length - 1 + if (blockOccurrences === 1 && startOccurrences === 1 && endOccurrences === 1) return 'exact' + if (startOccurrences === 0 && endOccurrences === 0) return 'missing' + return 'modified' +} + +function appendManagedBlock(content: string, block: string): AppendedManagedBlock { + const prefixLength: AppendedManagedBlock['prefixLength'] = content + ? content.endsWith('\n') + ? 1 + : 2 + : 0 + return { + content: `${content}${'\n'.repeat(prefixLength)}${block}\n`, + prefixLength + } +} + +function hasManagedBlockPrefix(content: string, block: string, prefixLength: 0 | 1 | 2): boolean { + const index = content.indexOf(block) + return ( + index >= prefixLength && + content.slice(index - prefixLength, index) === '\n'.repeat(prefixLength) + ) +} + +function removeManagedBlock(content: string, block: string, prefixLength: 0 | 1 | 2): string { + const index = content.indexOf(block) + if (index < 0) return content + const start = index - prefixLength + let end = index + block.length + if (content[end] === '\n') end += 1 + return `${content.slice(0, start)}${content.slice(end)}` +} + +function escapeBatchLiteral(value: string): string { + return value.replaceAll('%', '%%') +} + +function createWindowsCommand(source: CliSource): string { + const cliModule = escapeBatchLiteral(source.modulePath) + const runtimeCandidates = [ + path.join(source.directory, '..', 'runtime', 'node', 'node.exe'), + path.join(source.directory, '..', '..', 'runtime', 'node', 'node.exe') + ].map((candidate) => escapeBatchLiteral(path.resolve(candidate))) + return [ + '@echo off', + 'setlocal', + `set "cli_module=${cliModule}"`, + `set "runtime_node=${runtimeCandidates[0]}"`, + `if not exist "%runtime_node%" set "runtime_node=${runtimeCandidates[1]}"`, + 'if exist "%runtime_node%" goto bundled_runtime', + 'where node >nul 2>&1', + 'if errorlevel 1 goto missing_runtime', + 'node "%cli_module%" %*', + 'exit /b %errorlevel%', + ':bundled_runtime', + '"%runtime_node%" "%cli_module%" %*', + 'exit /b %errorlevel%', + ':missing_runtime', + 'echo DeepChat CLI requires the bundled Node.js runtime or node on PATH. 1>&2', + 'exit /b 127', + '' + ].join('\r\n') +} + +export class CliLauncherService { + private readonly platform: NodeJS.Platform + private operationQueue: Promise = Promise.resolve() + + constructor(private readonly options: CliLauncherServiceOptions) { + this.platform = options.platform ?? process.platform + } + + async getStatus(): Promise { + return await this.runExclusive(() => this.inspectStatus()) + } + + async setInstalled(installed: boolean): Promise { + return await this.runExclusive(async () => { + if (installed) await this.installOrRepair() + else await this.uninstall() + return await this.inspectStatus() + }) + } + + async reconcileOwnedLauncher(): Promise { + await this.runExclusive(async () => { + const status = await this.inspectStatus() + if (status.state !== 'stale') return + await this.installOrRepair() + }) + } + + private async runExclusive(operation: () => Promise): Promise { + const run = this.operationQueue.then(operation, operation) + this.operationQueue = run.then( + () => undefined, + () => undefined + ) + return await run + } + + private get markerPath(): string { + return path.join(this.options.userDataDirectory, 'local-control', LAUNCHER_MARKER_FILENAME) + } + + private get isSupportedPlatform(): boolean { + return this.platform === 'darwin' || this.platform === 'linux' || this.platform === 'win32' + } + + private get commandPath(): string | null { + if (this.platform === 'darwin' || this.platform === 'linux') { + return path.join(this.options.homeDirectory, '.local', 'bin', 'deepchat') + } + if (this.platform === 'win32') { + const localAppData = + this.options.localAppDataDirectory ?? + path.join(this.options.homeDirectory, 'AppData', 'Local') + return path.join(localAppData, 'Microsoft', 'WindowsApps', 'deepchat.cmd') + } + return null + } + + private async resolveSource(): Promise { + const directory = this.options.resolveCliDirectory() + if (!directory) return null + const resolvedDirectory = path.resolve(directory) + const source = { + directory: resolvedDirectory, + posixLauncher: path.join(resolvedDirectory, 'deepchat'), + modulePath: path.join(resolvedDirectory, 'deepchat.mjs') + } + const requiredPaths = + this.platform === 'win32' ? [source.modulePath] : [source.posixLauncher, source.modulePath] + for (const requiredPath of requiredPaths) { + try { + const stats = await lstat(requiredPath) + if (!stats.isFile() || stats.isSymbolicLink()) return null + if ( + this.platform !== 'win32' && + requiredPath === source.posixLauncher && + (stats.mode & 0o111) === 0 + ) { + return null + } + } catch (error) { + if (isMissingFileError(error)) return null + throw error + } + } + return source + } + + private async inspectStatus(): Promise { + const commandPath = this.commandPath + if (!this.isSupportedPlatform || !commandPath) { + return { + state: 'unavailable', + reason: 'unsupported-platform', + commandPath: null, + shellConfigPath: null + } + } + + const markerResult = await this.readMarker() + if (markerResult.state === 'invalid') { + return { + state: 'conflict', + reason: 'ownership-marker-invalid', + commandPath, + shellConfigPath: null + } + } + + if (markerResult.state === 'missing') { + if ((await this.pathEntryExists(commandPath)) || (await this.hasOrphanedManagedBlock())) { + return { + state: 'conflict', + reason: 'unowned-command', + commandPath, + shellConfigPath: null + } + } + if (this.platform === 'win32' && !this.isCommandDirectoryOnPath()) { + return { + state: 'unavailable', + reason: 'path-unavailable', + commandPath, + shellConfigPath: null + } + } + const source = await this.resolveSource() + return { + state: source ? 'not-installed' : 'unavailable', + reason: source ? null : 'source-missing', + commandPath, + shellConfigPath: null + } + } + + const { marker } = markerResult + const expectedPlatform = this.platform === 'win32' ? 'windows' : 'posix' + if (marker.platform !== expectedPlatform || path.resolve(marker.commandPath) !== commandPath) { + return { + state: 'conflict', + reason: 'ownership-marker-invalid', + commandPath, + shellConfigPath: null + } + } + + const profile = + marker.platform === 'posix' ? await this.inspectProfile(marker.profileKind) : null + if ( + profile?.blockState === 'modified' || + (profile?.blockState === 'exact' && + marker.platform === 'posix' && + !hasManagedBlockPrefix( + profile.content, + managedProfileBlock(profile.kind), + marker.profilePrefixLength + )) + ) { + return { + state: 'conflict', + reason: 'shell-config-modified', + commandPath, + shellConfigPath: profile.path + } + } + + const commandState = await this.inspectOwnedCommand(marker) + if (commandState === 'modified') { + return { + state: 'conflict', + reason: 'command-modified', + commandPath, + shellConfigPath: profile?.path ?? null + } + } + if (commandState === 'missing') { + return { + state: 'needs-repair', + reason: 'command-missing', + commandPath, + shellConfigPath: profile?.path ?? null + } + } + if (profile?.blockState === 'missing') { + return { + state: 'needs-repair', + reason: 'shell-config-missing', + commandPath, + shellConfigPath: profile.path + } + } + if (this.platform === 'win32' && !this.isCommandDirectoryOnPath()) { + return { + state: 'needs-repair', + reason: 'path-unavailable', + commandPath, + shellConfigPath: null + } + } + + const source = await this.resolveSource() + if (!source) { + return { + state: 'unavailable', + reason: 'source-missing', + commandPath, + shellConfigPath: profile?.path ?? null + } + } + const current = this.markerForSource( + source, + marker.platform === 'posix' ? marker.profileKind : null, + marker.platform === 'posix' ? marker.profilePrefixLength : 0, + marker.platform === 'posix' ? marker.profileCreated : false + ) + const stale = + marker.platform === 'posix' + ? marker.launcherTarget !== (current as PosixLauncherMarker).launcherTarget + : marker.commandHash !== (current as WindowsLauncherMarker).commandHash + return { + state: stale ? 'stale' : 'installed', + reason: stale ? 'upgrade-required' : null, + commandPath, + shellConfigPath: profile?.path ?? null + } + } + + private async installOrRepair(): Promise { + if (!this.isSupportedPlatform || !this.commandPath) { + throw new Error('DeepChat CLI launcher installation is not supported on this platform') + } + const source = await this.resolveSource() + if (!source) throw new Error('The bundled DeepChat CLI is unavailable') + if (this.platform === 'win32' && !this.isCommandDirectoryOnPath()) { + throw new Error('The Windows user command directory is not available on PATH') + } + const markerResult = await this.readMarker() + if (markerResult.state === 'invalid') { + throw new Error('The DeepChat CLI ownership marker is invalid') + } + + let previousMarker: LauncherMarker | null = null + let previousMarkerRaw: string | null = null + let profileKind: PosixProfileKind | null = null + if (markerResult.state === 'valid') { + previousMarker = markerResult.marker + previousMarkerRaw = markerResult.raw + const expectedPlatform = this.platform === 'win32' ? 'windows' : 'posix' + if ( + previousMarker.platform !== expectedPlatform || + path.resolve(previousMarker.commandPath) !== this.commandPath + ) { + throw new Error('The DeepChat CLI ownership marker does not match this installation') + } + profileKind = previousMarker.platform === 'posix' ? previousMarker.profileKind : null + } else { + if ( + (await this.pathEntryExists(this.commandPath)) || + (await this.hasOrphanedManagedBlock()) + ) { + throw new Error('A DeepChat CLI command or shell block exists without an ownership marker') + } + profileKind = this.platform === 'win32' ? null : await this.selectProfileKind() + } + + const profile = this.platform === 'win32' ? null : await this.inspectProfile(profileKind) + if (profile?.blockState === 'modified') { + throw new Error('The managed DeepChat CLI shell block has been modified') + } + + const previousCommand = await this.captureOwnedCommand(previousMarker) + const previousProfileContent = profile?.exists ? profile.content : null + const appendedProfile = + profile && profile.blockState === 'missing' + ? appendManagedBlock(profile.content, managedProfileBlock(profile.kind)) + : null + const profilePrefixLength = + appendedProfile?.prefixLength ?? + (previousMarker?.platform === 'posix' ? previousMarker.profilePrefixLength : 0) + const profileCreated = + previousMarker?.platform === 'posix' + ? previousMarker.profileCreated + : Boolean(profile && !profile.exists) + if ( + profile?.blockState === 'exact' && + previousMarker?.platform === 'posix' && + !hasManagedBlockPrefix( + profile.content, + managedProfileBlock(profile.kind), + previousMarker.profilePrefixLength + ) + ) { + throw new Error('The managed DeepChat CLI shell block prefix has been modified') + } + const nextProfileContent = appendedProfile?.content ?? previousProfileContent + const nextMarker = this.markerForSource( + source, + profileKind, + profilePrefixLength, + profileCreated + ) + const nextMarkerRaw = `${JSON.stringify(nextMarker)}\n` + const nextCommand = this.commandForSource(source) + let commandChanged = false + let profileChanged = false + try { + if (previousCommand !== nextCommand) { + await this.writeOwnedCommand(source, previousCommand) + commandChanged = true + } + if (profile && nextProfileContent !== null && nextProfileContent !== previousProfileContent) { + await this.prepareHomeManagedDirectory(path.dirname(profile.path)) + await this.atomicWriteText(profile.path, nextProfileContent, previousProfileContent, 0o644) + profileChanged = true + } + if (nextMarkerRaw !== previousMarkerRaw) { + await this.writeMarker(nextMarkerRaw, previousMarkerRaw) + } + } catch (error) { + if (profileChanged && profile && nextProfileContent !== null) { + await this.restoreTextFile(profile.path, previousProfileContent, nextProfileContent).catch( + () => undefined + ) + } + if (commandChanged) { + await this.restoreOwnedCommand(previousCommand, nextCommand).catch(() => undefined) + } + throw error + } + } + + private async uninstall(): Promise { + const commandPath = this.commandPath + if (!this.isSupportedPlatform || !commandPath) { + throw new Error('DeepChat CLI launcher removal is not supported on this platform') + } + const markerResult = await this.readMarker() + if (markerResult.state === 'invalid') { + throw new Error('The DeepChat CLI ownership marker is invalid') + } + if (markerResult.state === 'missing') { + if ((await this.pathEntryExists(commandPath)) || (await this.hasOrphanedManagedBlock())) { + throw new Error('Refusing to remove a CLI command without an ownership marker') + } + return + } + + const { marker, raw: markerRaw } = markerResult + const expectedPlatform = this.platform === 'win32' ? 'windows' : 'posix' + if (marker.platform !== expectedPlatform || path.resolve(marker.commandPath) !== commandPath) { + throw new Error('The DeepChat CLI ownership marker does not match this installation') + } + const profile = + marker.platform === 'posix' ? await this.inspectProfile(marker.profileKind) : null + if ( + profile?.blockState === 'modified' || + (profile?.blockState === 'exact' && + marker.platform === 'posix' && + !hasManagedBlockPrefix( + profile.content, + managedProfileBlock(profile.kind), + marker.profilePrefixLength + )) + ) { + throw new Error('Refusing to edit a modified DeepChat CLI shell block') + } + const previousCommand = await this.captureOwnedCommand(marker) + const previousProfileContent = profile?.exists ? profile.content : null + const nextProfileContent = + profile?.blockState === 'exact' && marker.platform === 'posix' + ? removeManagedBlock( + profile.content, + managedProfileBlock(profile.kind), + marker.profilePrefixLength + ) + : previousProfileContent + let commandRemoved = false + let profileChanged = false + let profileRemoved = false + try { + if (previousCommand !== null) { + await this.removeOwnedCommand(previousCommand) + commandRemoved = true + } + if (profile && nextProfileContent !== null && nextProfileContent !== previousProfileContent) { + await this.prepareHomeManagedDirectory(path.dirname(profile.path)) + if (marker.platform === 'posix' && marker.profileCreated && nextProfileContent === '') { + await this.unlinkTextIfMatches(profile.path, previousProfileContent ?? '') + profileRemoved = true + } else { + await this.atomicWriteText( + profile.path, + nextProfileContent, + previousProfileContent, + 0o644 + ) + } + profileChanged = true + } + await this.removeMarker(markerRaw) + } catch (error) { + if (profileChanged && profile && nextProfileContent !== null) { + await this.restoreTextFile( + profile.path, + previousProfileContent, + profileRemoved ? null : nextProfileContent + ).catch(() => undefined) + } + if (commandRemoved) { + await this.restoreOwnedCommand(previousCommand, null).catch(() => undefined) + } + throw error + } + } + + private async selectProfileKind(): Promise { + if (this.isCommandDirectoryOnPath()) return null + switch (path.basename(this.options.shell ?? '')) { + case 'zsh': + return 'zsh' + case 'bash': + if (this.platform === 'darwin') { + if (await this.pathEntryExists(path.join(this.options.homeDirectory, '.bash_profile'))) { + return 'bash' + } + if (await this.pathEntryExists(path.join(this.options.homeDirectory, '.bash_login'))) { + return 'bash-login' + } + return 'profile' + } + return 'bash' + case 'fish': + return 'fish' + default: + return 'profile' + } + } + + private isCommandDirectoryOnPath(): boolean { + const commandPath = this.commandPath + if (!commandPath) return false + const commandDirectory = path.resolve(path.dirname(commandPath)) + const delimiter = this.platform === 'win32' ? ';' : ':' + return (this.options.environmentPath ?? '').split(delimiter).some((entry) => { + if (!entry) return false + const candidate = path.resolve(entry.replace(/^"|"$/g, '')) + return this.platform === 'win32' + ? candidate.toLowerCase() === commandDirectory.toLowerCase() + : candidate === commandDirectory + }) + } + + private profilePath(kind: PosixProfileKind): string { + switch (kind) { + case 'zsh': + return path.join( + this.options.homeDirectory, + this.platform === 'darwin' ? '.zprofile' : '.zshrc' + ) + case 'bash': + return path.join( + this.options.homeDirectory, + this.platform === 'darwin' ? '.bash_profile' : '.bashrc' + ) + case 'bash-login': + return path.join(this.options.homeDirectory, '.bash_login') + case 'fish': + return path.join( + this.options.homeDirectory, + '.config', + 'fish', + 'conf.d', + 'deepchat-cli.fish' + ) + case 'profile': + return path.join(this.options.homeDirectory, '.profile') + } + } + + private async inspectProfile(kind: PosixProfileKind | null): Promise { + if (!kind) return null + const profilePath = this.profilePath(kind) + if (!isPathWithin(this.options.homeDirectory, profilePath)) { + throw new Error('Shell configuration path is outside the user home directory') + } + let content = '' + let exists = false + try { + const stats = await lstat(profilePath) + if (!stats.isFile() || stats.isSymbolicLink()) { + return { kind, path: profilePath, content, exists: true, blockState: 'modified' } + } + if (stats.size > MAX_SHELL_CONFIG_BYTES) { + return { kind, path: profilePath, content, exists: true, blockState: 'modified' } + } + content = await readFile(profilePath, 'utf8') + exists = true + } catch (error) { + if (!isMissingFileError(error)) throw error + } + return { + kind, + path: profilePath, + content, + exists, + blockState: inspectManagedBlock(content, managedProfileBlock(kind)) + } + } + + private async hasOrphanedManagedBlock(): Promise { + if (this.platform === 'win32') return false + for (const kind of ['zsh', 'bash', 'bash-login', 'fish', 'profile'] as const) { + const profile = await this.inspectProfile(kind) + if (profile && profile.blockState !== 'missing') return true + } + return false + } + + private markerForSource( + source: CliSource, + profileKind: PosixProfileKind | null, + profilePrefixLength: 0 | 1 | 2, + profileCreated: boolean + ): LauncherMarker { + const commandPath = this.commandPath + if (!commandPath) throw new Error('CLI launcher command path is unavailable') + if (this.platform === 'win32') { + return { + version: LAUNCHER_MARKER_VERSION, + platform: 'windows', + commandPath, + commandHash: sha256(createWindowsCommand(source)) + } + } + return { + version: LAUNCHER_MARKER_VERSION, + platform: 'posix', + commandPath, + launcherTarget: source.posixLauncher, + profileKind, + profilePrefixLength, + profileCreated + } + } + + private async inspectOwnedCommand( + marker: LauncherMarker + ): Promise<'current' | 'missing' | 'modified'> { + try { + if (marker.platform === 'posix') { + const target = await this.readPosixCommandTarget() + if (target === null) return 'missing' + return target === path.resolve(marker.launcherTarget) ? 'current' : 'modified' + } + const content = await this.readWindowsCommand() + if (content === null) return 'missing' + return sha256(content) === marker.commandHash ? 'current' : 'modified' + } catch { + return 'modified' + } + } + + private async captureOwnedCommand(marker: LauncherMarker | null): Promise { + if (this.platform === 'win32') { + const content = await this.readWindowsCommand() + if (content === null) return null + if (!marker || marker.platform !== 'windows' || sha256(content) !== marker.commandHash) { + throw new Error('Refusing to replace an unowned DeepChat CLI command') + } + return content + } + const target = await this.readPosixCommandTarget() + if (target === null) return null + if (!marker || marker.platform !== 'posix' || target !== path.resolve(marker.launcherTarget)) { + throw new Error('Refusing to replace an unowned DeepChat CLI command') + } + return target + } + + private async writeOwnedCommand( + source: CliSource, + previousCommand: string | null + ): Promise { + const commandPath = this.commandPath + if (!commandPath) throw new Error('CLI launcher command path is unavailable') + await this.prepareCommandDirectory(path.dirname(commandPath)) + if (this.platform === 'win32') { + await this.atomicWriteText(commandPath, createWindowsCommand(source), previousCommand, 0o755) + return + } + await this.atomicWriteLink(commandPath, source.posixLauncher, previousCommand) + } + + private commandForSource(source: CliSource): string { + return this.platform === 'win32' + ? createWindowsCommand(source) + : path.resolve(source.posixLauncher) + } + + private async restoreOwnedCommand( + previousCommand: string | null, + expectedCurrent: string | null + ): Promise { + const commandPath = this.commandPath + if (!commandPath) return + if (previousCommand === null) { + if (expectedCurrent === null) return + if (this.platform === 'win32') { + await this.unlinkTextIfMatches(commandPath, expectedCurrent) + } else { + await this.unlinkLinkIfMatches(commandPath, expectedCurrent) + } + return + } + if (this.platform === 'win32') { + await this.atomicWriteText(commandPath, previousCommand, expectedCurrent, 0o755) + } else { + await this.atomicWriteLink(commandPath, previousCommand, expectedCurrent) + } + } + + private async removeOwnedCommand(previousCommand: string): Promise { + const commandPath = this.commandPath + if (!commandPath) return + if (this.platform === 'win32') { + await this.unlinkTextIfMatches(commandPath, previousCommand) + } else { + await this.unlinkLinkIfMatches(commandPath, previousCommand) + } + } + + private async readPosixCommandTarget(): Promise { + const commandPath = this.commandPath + if (!commandPath) return null + try { + const stats = await lstat(commandPath) + if (!stats.isSymbolicLink()) throw new Error('DeepChat CLI command is not a symbolic link') + const target = await readlink(commandPath) + return path.resolve(path.dirname(commandPath), target) + } catch (error) { + if (isMissingFileError(error)) return null + throw error + } + } + + private async readWindowsCommand(): Promise { + const commandPath = this.commandPath + if (!commandPath) return null + try { + const stats = await lstat(commandPath) + if (!stats.isFile() || stats.isSymbolicLink() || stats.size > 64 * 1024) { + throw new Error('DeepChat CLI command is not an owned launcher file') + } + return await readFile(commandPath, 'utf8') + } catch (error) { + if (isMissingFileError(error)) return null + throw error + } + } + + private async prepareCommandDirectory(directory: string): Promise { + if (this.platform !== 'win32') { + await this.prepareHomeManagedDirectory(directory) + return + } + await mkdir(directory, { recursive: true, mode: 0o755 }) + const stats = await lstat(directory) + if (!stats.isDirectory() || stats.isSymbolicLink()) { + throw new Error('CLI launcher directory is not a real directory') + } + } + + private async prepareHomeManagedDirectory(directory: string): Promise { + await mkdir(directory, { recursive: true, mode: 0o755 }) + const stats = await lstat(directory) + if (!stats.isDirectory() || stats.isSymbolicLink()) { + throw new Error('Managed user directory is not a real directory') + } + const [physicalHome, physicalDirectory] = await Promise.all([ + realpath(this.options.homeDirectory), + realpath(directory) + ]) + if (!isPathWithin(physicalHome, physicalDirectory)) { + throw new Error('Managed user directory is outside the user home directory') + } + } + + private async atomicWriteLink( + commandPath: string, + target: string, + expectedTarget: string | null + ): Promise { + const currentTarget = await this.readPosixCommandTarget() + if (currentTarget !== (expectedTarget && path.resolve(expectedTarget))) { + throw new Error('CLI launcher changed during installation') + } + const tempPath = path.join(path.dirname(commandPath), `.deepchat-${randomUUID()}.tmp`) + try { + await symlink(path.resolve(target), tempPath) + const verifiedTarget = await this.readPosixCommandTarget() + if (verifiedTarget !== currentTarget) + throw new Error('CLI launcher changed during installation') + await rename(tempPath, commandPath) + } catch (error) { + await unlink(tempPath).catch(() => undefined) + throw error + } + } + + private async atomicWriteText( + filePath: string, + content: string, + expectedContent: string | null, + mode: number + ): Promise { + await mkdir(path.dirname(filePath), { recursive: true, mode: 0o755 }) + const currentContent = await this.readRegularText( + filePath, + Math.max(MAX_SHELL_CONFIG_BYTES, 64 * 1024) + ) + if (currentContent !== expectedContent) throw new Error('Managed file changed during operation') + const tempPath = path.join(path.dirname(filePath), `.deepchat-${randomUUID()}.tmp`) + try { + await writeFile(tempPath, content, { encoding: 'utf8', flag: 'wx', mode }) + if (this.platform !== 'win32') await chmod(tempPath, mode) + const verifiedContent = await this.readRegularText( + filePath, + Math.max(MAX_SHELL_CONFIG_BYTES, 64 * 1024) + ) + if (verifiedContent !== currentContent) + throw new Error('Managed file changed during operation') + await rename(tempPath, filePath) + } catch (error) { + await unlink(tempPath).catch(() => undefined) + throw error + } + } + + private async restoreTextFile( + filePath: string, + previousContent: string | null, + currentContent: string | null + ): Promise { + if (previousContent === null) { + if (currentContent === null) return + await this.unlinkTextIfMatches(filePath, currentContent) + return + } + await this.atomicWriteText(filePath, previousContent, currentContent, 0o644) + } + + private async readRegularText(filePath: string, maxBytes: number): Promise { + try { + const stats = await lstat(filePath) + if (!stats.isFile() || stats.isSymbolicLink() || stats.size > maxBytes) { + throw new Error('Managed path is not a supported regular file') + } + return await readFile(filePath, 'utf8') + } catch (error) { + if (isMissingFileError(error)) return null + throw error + } + } + + private async unlinkLinkIfMatches(commandPath: string, expectedTarget: string): Promise { + const currentTarget = await this.readPosixCommandTarget() + if (currentTarget !== path.resolve(expectedTarget)) { + throw new Error('Refusing to remove a changed CLI launcher') + } + await unlink(commandPath) + } + + private async unlinkTextIfMatches(filePath: string, expectedContent: string): Promise { + const currentContent = await this.readRegularText(filePath, 64 * 1024) + if (currentContent !== expectedContent) { + throw new Error('Refusing to remove a changed CLI launcher') + } + await unlink(filePath) + } + + private async pathEntryExists(filePath: string): Promise { + try { + await lstat(filePath) + return true + } catch (error) { + if (isMissingFileError(error)) return false + throw error + } + } + + private async readMarker(): Promise { + try { + const directory = path.dirname(this.markerPath) + const directoryStats = await lstat(directory) + if (!directoryStats.isDirectory() || directoryStats.isSymbolicLink()) { + return { state: 'invalid', raw: '' } + } + const [physicalUserData, physicalDirectory] = await Promise.all([ + realpath(this.options.userDataDirectory), + realpath(directory) + ]) + if (!isPathWithin(physicalUserData, physicalDirectory)) { + return { state: 'invalid', raw: '' } + } + const stats = await lstat(this.markerPath) + if (!stats.isFile() || stats.isSymbolicLink() || stats.size > MAX_MARKER_BYTES) { + return { state: 'invalid', raw: '' } + } + const raw = await readFile(this.markerPath, 'utf8') + const marker = parseLauncherMarker(JSON.parse(raw)) + return marker ? { state: 'valid', raw, marker } : { state: 'invalid', raw } + } catch (error) { + if (isMissingFileError(error)) return { state: 'missing', raw: null } + if (error instanceof SyntaxError) return { state: 'invalid', raw: '' } + throw error + } + } + + private async writeMarker(content: string, expectedContent: string | null): Promise { + const directory = path.dirname(this.markerPath) + await mkdir(directory, { recursive: true, mode: 0o700 }) + const directoryStats = await lstat(directory) + if (!directoryStats.isDirectory() || directoryStats.isSymbolicLink()) { + throw new Error('CLI launcher ownership directory is not a real directory') + } + const [physicalUserData, physicalDirectory] = await Promise.all([ + realpath(this.options.userDataDirectory), + realpath(directory) + ]) + if (!isPathWithin(physicalUserData, physicalDirectory)) { + throw new Error('CLI launcher ownership directory is outside application data') + } + if (this.platform !== 'win32') await chmod(directory, 0o700) + await this.atomicWriteText(this.markerPath, content, expectedContent, 0o600) + } + + private async removeMarker(expectedContent: string): Promise { + await this.unlinkTextIfMatches(this.markerPath, expectedContent) + } +} diff --git a/src/shared/contracts/routes.ts b/src/shared/contracts/routes.ts index c77852269..fa24c73a9 100644 --- a/src/shared/contracts/routes.ts +++ b/src/shared/contracts/routes.ts @@ -84,6 +84,8 @@ import { import { cliCapabilitiesRoute, cliDoctorRoute, + cliGetLauncherStatusRoute, + cliSetLauncherInstalledRoute, cliStatusRoute, cliVersionRoute } from './routes/cli.routes' @@ -1017,6 +1019,8 @@ const DEEPCHAT_ROUTE_CATALOG_PART_5 = { [cliVersionRoute.name]: cliVersionRoute, [cliCapabilitiesRoute.name]: cliCapabilitiesRoute, [cliDoctorRoute.name]: cliDoctorRoute, + [cliGetLauncherStatusRoute.name]: cliGetLauncherStatusRoute, + [cliSetLauncherInstalledRoute.name]: cliSetLauncherInstalledRoute, [chatCancelSubmissionRoute.name]: chatCancelSubmissionRoute, [chatSendMessageRoute.name]: chatSendMessageRoute, [chatSteerActiveTurnRoute.name]: chatSteerActiveTurnRoute, diff --git a/src/shared/contracts/routes/cli.routes.ts b/src/shared/contracts/routes/cli.routes.ts index 125512ce7..239d6f97d 100644 --- a/src/shared/contracts/routes/cli.routes.ts +++ b/src/shared/contracts/routes/cli.routes.ts @@ -12,6 +12,37 @@ import { export const LocalControlTransportSchema = z.enum(['rpc', 'stream', 'upload', 'download']) export const LocalControlApprovalModeSchema = z.enum(['never', 'policy']) +export const CliLauncherStateSchema = z.enum([ + 'not-installed', + 'installed', + 'stale', + 'needs-repair', + 'conflict', + 'unavailable' +]) + +export const CliLauncherReasonSchema = z.enum([ + 'unsupported-platform', + 'source-missing', + 'path-unavailable', + 'ownership-marker-invalid', + 'unowned-command', + 'command-modified', + 'command-missing', + 'shell-config-modified', + 'shell-config-missing', + 'upgrade-required' +]) + +export const CliLauncherStatusSchema = z + .object({ + state: CliLauncherStateSchema, + reason: CliLauncherReasonSchema.nullable(), + commandPath: z.string().nullable(), + shellConfigPath: z.string().nullable() + }) + .strict() + export const LocalControlCapabilitySchema = z .object({ method: LocalControlMethodSchema, @@ -76,4 +107,19 @@ export const cliDoctorRoute = defineRouteContract({ }) }) +export const cliGetLauncherStatusRoute = defineRouteContract({ + name: 'cli.getLauncherStatus', + input: z.object({}).default({}), + output: CliLauncherStatusSchema +}) + +export const cliSetLauncherInstalledRoute = defineRouteContract({ + name: 'cli.setLauncherInstalled', + input: z.object({ installed: z.boolean() }).strict(), + output: CliLauncherStatusSchema +}) + export type CliCapability = z.infer +export type CliLauncherState = z.infer +export type CliLauncherReason = z.infer +export type CliLauncherStatus = z.infer diff --git a/test/main/cli/launcherRoutes.test.ts b/test/main/cli/launcherRoutes.test.ts new file mode 100644 index 000000000..93b744732 --- /dev/null +++ b/test/main/cli/launcherRoutes.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest' +import { cliGetLauncherStatusRoute, cliSetLauncherInstalledRoute } from '@shared/contracts/routes' +import { createCliLauncherRoutes } from '@/cli/launcherRoutes' + +const installedStatus = { + state: 'installed' as const, + reason: null, + commandPath: '/home/user/.local/bin/deepchat', + shellConfigPath: '/home/user/.zprofile' +} + +describe('createCliLauncherRoutes', () => { + it('exposes launcher state only to renderer callers', async () => { + const launcher = { + getStatus: vi.fn(async () => installedStatus), + setInstalled: vi.fn(async () => installedStatus) + } + const routes = createCliLauncherRoutes(launcher) + const getStatus = routes.get(cliGetLauncherStatusRoute.name) + const setInstalled = routes.get(cliSetLauncherInstalledRoute.name) + if (!getStatus || !setInstalled) throw new Error('Expected CLI launcher routes') + const rendererContext = { + caller: { kind: 'renderer' as const, webContentsId: 1, windowId: 2 } + } + + await expect(getStatus({}, rendererContext)).resolves.toEqual(installedStatus) + await expect(setInstalled({ installed: true }, rendererContext)).resolves.toEqual( + installedStatus + ) + expect(launcher.setInstalled).toHaveBeenCalledWith(true) + + await expect( + getStatus( + {}, + { + caller: { + kind: 'cli', + principal: 'human', + connectionId: 'connection-1', + scopes: ['system:read'] + } + } + ) + ).rejects.toThrow('renderer caller') + }) +}) diff --git a/test/main/cli/launcherService.test.ts b/test/main/cli/launcherService.test.ts new file mode 100644 index 000000000..c04e20e6d --- /dev/null +++ b/test/main/cli/launcherService.test.ts @@ -0,0 +1,305 @@ +import { lstat, mkdir, mkdtemp, readFile, readlink, rm, symlink, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { CliLauncherService } from '@/cli/launcherService' + +const temporaryDirectories: string[] = [] + +async function createFixture(platform: NodeJS.Platform = 'darwin') { + const root = await mkdtemp(path.join(os.tmpdir(), 'deepchat-cli-launcher-')) + temporaryDirectories.push(root) + const homeDirectory = path.join(root, 'home') + const userDataDirectory = path.join(root, 'user-data') + const localAppDataDirectory = path.join(root, 'local-app-data') + const cliDirectory = path.join(root, 'cli-v1') + await mkdir(homeDirectory, { recursive: true }) + await mkdir(userDataDirectory, { recursive: true }) + await mkdir(cliDirectory, { recursive: true }) + await writeFile(path.join(cliDirectory, 'deepchat'), '#!/bin/sh\n', { mode: 0o755 }) + await writeFile(path.join(cliDirectory, 'deepchat.cmd'), '@echo off\r\n') + await writeFile(path.join(cliDirectory, 'deepchat.mjs'), 'console.log("deepchat")\n') + let currentCliDirectory: string | null = cliDirectory + const service = new CliLauncherService({ + platform, + homeDirectory, + userDataDirectory, + localAppDataDirectory, + environmentPath: + platform === 'win32' + ? `${path.join(localAppDataDirectory, 'Microsoft', 'WindowsApps')};C:\\Windows\\System32` + : '/usr/bin:/bin', + shell: '/bin/zsh', + resolveCliDirectory: () => currentCliDirectory + }) + return { + root, + homeDirectory, + userDataDirectory, + localAppDataDirectory, + cliDirectory, + service, + setCliDirectory: (directory: string | null) => { + currentCliDirectory = directory + } + } +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })) + ) +}) + +describe('CliLauncherService', () => { + it('installs and reverses a POSIX launcher without changing existing shell content', async () => { + const fixture = await createFixture() + const profilePath = path.join(fixture.homeDirectory, '.zprofile') + const commandPath = path.join(fixture.homeDirectory, '.local', 'bin', 'deepchat') + await writeFile(profilePath, 'export EDITOR=vim\n') + + await expect(fixture.service.getStatus()).resolves.toMatchObject({ + state: 'not-installed', + commandPath, + shellConfigPath: null + }) + await expect(fixture.service.setInstalled(true)).resolves.toMatchObject({ + state: 'installed', + commandPath, + shellConfigPath: profilePath + }) + expect(path.resolve(path.dirname(commandPath), await readlink(commandPath))).toBe( + path.join(fixture.cliDirectory, 'deepchat') + ) + expect(await readFile(profilePath, 'utf8')).toBe( + [ + 'export EDITOR=vim', + '', + '# >>> DeepChat CLI >>>', + 'case ":$PATH:" in', + ' *":$HOME/.local/bin:"*) ;;', + ' *) export PATH="$HOME/.local/bin:$PATH" ;;', + 'esac', + '# <<< DeepChat CLI <<<', + '' + ].join('\n') + ) + expect( + (await lstat(path.join(fixture.userDataDirectory, 'local-control', 'launcher.json'))).mode & + 0o777 + ).toBe(0o600) + + await expect(fixture.service.setInstalled(false)).resolves.toMatchObject({ + state: 'not-installed' + }) + await expect(lstat(commandPath)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await readFile(profilePath, 'utf8')).toBe('export EDITOR=vim\n') + await expect( + lstat(path.join(fixture.userDataDirectory, 'local-control', 'launcher.json')) + ).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('does not edit a shell profile when the user command directory is already on PATH', async () => { + const fixture = await createFixture() + const binDirectory = path.join(fixture.homeDirectory, '.local', 'bin') + const service = new CliLauncherService({ + platform: 'linux', + homeDirectory: fixture.homeDirectory, + userDataDirectory: fixture.userDataDirectory, + environmentPath: `/usr/bin:${binDirectory}`, + shell: '/bin/bash', + resolveCliDirectory: () => fixture.cliDirectory + }) + + await expect(service.setInstalled(true)).resolves.toMatchObject({ + state: 'installed', + shellConfigPath: null + }) + await expect(lstat(path.join(fixture.homeDirectory, '.bashrc'))).rejects.toMatchObject({ + code: 'ENOENT' + }) + }) + + it('restores a profile byte-for-byte and removes a profile it created', async () => { + const fixture = await createFixture() + const profilePath = path.join(fixture.homeDirectory, '.zprofile') + await writeFile(profilePath, 'export EDITOR=vim') + + await fixture.service.setInstalled(true) + await fixture.service.setInstalled(false) + expect(await readFile(profilePath, 'utf8')).toBe('export EDITOR=vim') + + await rm(profilePath) + await fixture.service.setInstalled(true) + expect((await lstat(profilePath)).isFile()).toBe(true) + await fixture.service.setInstalled(false) + await expect(lstat(profilePath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('refuses to overwrite an unowned command or an orphaned managed block', async () => { + const fixture = await createFixture() + const commandPath = path.join(fixture.homeDirectory, '.local', 'bin', 'deepchat') + await mkdir(path.dirname(commandPath), { recursive: true }) + await writeFile(commandPath, 'foreign') + + await expect(fixture.service.getStatus()).resolves.toMatchObject({ + state: 'conflict', + reason: 'unowned-command' + }) + await expect(fixture.service.setInstalled(true)).rejects.toThrow('without an ownership marker') + expect(await readFile(commandPath, 'utf8')).toBe('foreign') + + await rm(commandPath) + await writeFile( + path.join(fixture.homeDirectory, '.zprofile'), + '# >>> DeepChat CLI >>>\ncustom\n# <<< DeepChat CLI <<<\n' + ) + await expect(fixture.service.setInstalled(true)).rejects.toThrow('without an ownership marker') + }) + + it('fails closed when an owned command or shell block is modified', async () => { + const fixture = await createFixture() + const commandPath = path.join(fixture.homeDirectory, '.local', 'bin', 'deepchat') + const profilePath = path.join(fixture.homeDirectory, '.zprofile') + await fixture.service.setInstalled(true) + await rm(commandPath) + await symlink('/tmp/not-deepchat', commandPath) + + await expect(fixture.service.getStatus()).resolves.toMatchObject({ + state: 'conflict', + reason: 'command-modified' + }) + await expect(fixture.service.setInstalled(false)).rejects.toThrow('unowned') + expect(await readlink(commandPath)).toBe('/tmp/not-deepchat') + + await rm(commandPath) + await symlink(path.join(fixture.cliDirectory, 'deepchat'), commandPath) + await writeFile( + profilePath, + (await readFile(profilePath, 'utf8')) + .replace('fish_add_path', 'changed') + .replace('export PATH=', 'export CHANGED=') + ) + await expect(fixture.service.getStatus()).resolves.toMatchObject({ + state: 'conflict', + reason: 'shell-config-modified' + }) + await expect(fixture.service.setInstalled(false)).rejects.toThrow('modified') + }) + + it('repairs missing owned files only after an explicit install request', async () => { + const fixture = await createFixture() + const commandPath = path.join(fixture.homeDirectory, '.local', 'bin', 'deepchat') + await fixture.service.setInstalled(true) + await rm(commandPath) + + await expect(fixture.service.getStatus()).resolves.toMatchObject({ + state: 'needs-repair', + reason: 'command-missing' + }) + await fixture.service.reconcileOwnedLauncher() + await expect(lstat(commandPath)).rejects.toMatchObject({ code: 'ENOENT' }) + + await expect(fixture.service.setInstalled(true)).resolves.toMatchObject({ state: 'installed' }) + expect((await lstat(commandPath)).isSymbolicLink()).toBe(true) + }) + + it('refreshes only a stale launcher whose previous target is still owned', async () => { + const fixture = await createFixture() + const commandPath = path.join(fixture.homeDirectory, '.local', 'bin', 'deepchat') + await fixture.service.setInstalled(true) + const nextCliDirectory = path.join(fixture.root, 'cli-v2') + await mkdir(nextCliDirectory) + await writeFile(path.join(nextCliDirectory, 'deepchat'), '#!/bin/sh\n', { mode: 0o755 }) + await writeFile(path.join(nextCliDirectory, 'deepchat.mjs'), 'console.log("v2")\n') + fixture.setCliDirectory(nextCliDirectory) + + await expect(fixture.service.getStatus()).resolves.toMatchObject({ + state: 'stale', + reason: 'upgrade-required' + }) + await fixture.service.reconcileOwnedLauncher() + expect(path.resolve(path.dirname(commandPath), await readlink(commandPath))).toBe( + path.join(nextCliDirectory, 'deepchat') + ) + await expect(fixture.service.getStatus()).resolves.toMatchObject({ state: 'installed' }) + }) + + it('uses an owned Windows command shim and refreshes it across app paths', async () => { + const fixture = await createFixture('win32') + const commandPath = path.join( + fixture.localAppDataDirectory, + 'Microsoft', + 'WindowsApps', + 'deepchat.cmd' + ) + + await expect(fixture.service.setInstalled(true)).resolves.toMatchObject({ + state: 'installed', + commandPath, + shellConfigPath: null + }) + expect(await readFile(commandPath, 'utf8')).toContain( + `set "cli_module=${path.join(fixture.cliDirectory, 'deepchat.mjs')}"` + ) + + const nextCliDirectory = path.join(fixture.root, 'cli-win-v2') + await mkdir(nextCliDirectory) + await writeFile(path.join(nextCliDirectory, 'deepchat.mjs'), 'console.log("v2")\n') + fixture.setCliDirectory(nextCliDirectory) + await expect(fixture.service.getStatus()).resolves.toMatchObject({ state: 'stale' }) + await fixture.service.reconcileOwnedLauncher() + expect(await readFile(commandPath, 'utf8')).toContain( + `set "cli_module=${path.join(nextCliDirectory, 'deepchat.mjs')}"` + ) + + await expect(fixture.service.setInstalled(false)).resolves.toMatchObject({ + state: 'not-installed' + }) + await expect(lstat(commandPath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('does not claim Windows installation when its user command directory is off PATH', async () => { + const fixture = await createFixture('win32') + const service = new CliLauncherService({ + platform: 'win32', + homeDirectory: fixture.homeDirectory, + userDataDirectory: fixture.userDataDirectory, + localAppDataDirectory: fixture.localAppDataDirectory, + environmentPath: 'C:\\Windows\\System32', + resolveCliDirectory: () => fixture.cliDirectory + }) + + await expect(service.getStatus()).resolves.toMatchObject({ + state: 'unavailable', + reason: 'path-unavailable' + }) + await expect(service.setInstalled(true)).rejects.toThrow('not available on PATH') + }) + + it('reports an unavailable source without creating installation state', async () => { + const fixture = await createFixture() + fixture.setCliDirectory(null) + + await expect(fixture.service.getStatus()).resolves.toMatchObject({ + state: 'unavailable', + reason: 'source-missing' + }) + await expect(fixture.service.setInstalled(true)).rejects.toThrow('unavailable') + }) + + it('can remove owned integration after the packaged source disappears', async () => { + const fixture = await createFixture() + const commandPath = path.join(fixture.homeDirectory, '.local', 'bin', 'deepchat') + const profilePath = path.join(fixture.homeDirectory, '.zprofile') + await fixture.service.setInstalled(true) + fixture.setCliDirectory(null) + + await expect(fixture.service.setInstalled(false)).resolves.toMatchObject({ + state: 'unavailable', + reason: 'source-missing' + }) + await expect(lstat(commandPath)).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(lstat(profilePath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) +}) From 2a31128b7b798ff2f6f0dbc169ce003b0afcc2e7 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 18:06:46 +0800 Subject: [PATCH 29/51] feat(settings): manage CLI launcher --- src/main/cli/launcherService.ts | 33 +++- src/renderer/api/CliClient.ts | 20 ++ src/renderer/api/index.ts | 1 + .../settings/components/CommonSettings.vue | 2 + .../common/CliLauncherSettingsSection.vue | 138 +++++++++++++ src/shared/contracts/routes/cli.routes.ts | 1 + test/main/cli/launcherRoutes.test.ts | 15 ++ test/main/cli/launcherService.test.ts | 40 +++- test/renderer/api/cliClient.test.ts | 35 ++++ .../CliLauncherSettingsSection.test.ts | 182 ++++++++++++++++++ 10 files changed, 451 insertions(+), 16 deletions(-) create mode 100644 src/renderer/api/CliClient.ts create mode 100644 src/renderer/settings/components/common/CliLauncherSettingsSection.vue create mode 100644 test/renderer/api/cliClient.test.ts create mode 100644 test/renderer/components/CliLauncherSettingsSection.test.ts diff --git a/src/main/cli/launcherService.ts b/src/main/cli/launcherService.ts index 9ca5ce81e..cc284c1d1 100644 --- a/src/main/cli/launcherService.ts +++ b/src/main/cli/launcherService.ts @@ -303,6 +303,7 @@ export class CliLauncherService { return { state: 'unavailable', reason: 'unsupported-platform', + owned: false, commandPath: null, shellConfigPath: null } @@ -313,6 +314,7 @@ export class CliLauncherService { return { state: 'conflict', reason: 'ownership-marker-invalid', + owned: false, commandPath, shellConfigPath: null } @@ -323,6 +325,7 @@ export class CliLauncherService { return { state: 'conflict', reason: 'unowned-command', + owned: false, commandPath, shellConfigPath: null } @@ -331,6 +334,7 @@ export class CliLauncherService { return { state: 'unavailable', reason: 'path-unavailable', + owned: false, commandPath, shellConfigPath: null } @@ -339,6 +343,7 @@ export class CliLauncherService { return { state: source ? 'not-installed' : 'unavailable', reason: source ? null : 'source-missing', + owned: false, commandPath, shellConfigPath: null } @@ -350,6 +355,7 @@ export class CliLauncherService { return { state: 'conflict', reason: 'ownership-marker-invalid', + owned: false, commandPath, shellConfigPath: null } @@ -370,6 +376,7 @@ export class CliLauncherService { return { state: 'conflict', reason: 'shell-config-modified', + owned: true, commandPath, shellConfigPath: profile.path } @@ -380,6 +387,18 @@ export class CliLauncherService { return { state: 'conflict', reason: 'command-modified', + owned: true, + commandPath, + shellConfigPath: profile?.path ?? null + } + } + + const source = await this.resolveSource() + if (!source) { + return { + state: 'unavailable', + reason: 'source-missing', + owned: true, commandPath, shellConfigPath: profile?.path ?? null } @@ -388,6 +407,7 @@ export class CliLauncherService { return { state: 'needs-repair', reason: 'command-missing', + owned: true, commandPath, shellConfigPath: profile?.path ?? null } @@ -396,6 +416,7 @@ export class CliLauncherService { return { state: 'needs-repair', reason: 'shell-config-missing', + owned: true, commandPath, shellConfigPath: profile.path } @@ -404,20 +425,11 @@ export class CliLauncherService { return { state: 'needs-repair', reason: 'path-unavailable', + owned: true, commandPath, shellConfigPath: null } } - - const source = await this.resolveSource() - if (!source) { - return { - state: 'unavailable', - reason: 'source-missing', - commandPath, - shellConfigPath: profile?.path ?? null - } - } const current = this.markerForSource( source, marker.platform === 'posix' ? marker.profileKind : null, @@ -431,6 +443,7 @@ export class CliLauncherService { return { state: stale ? 'stale' : 'installed', reason: stale ? 'upgrade-required' : null, + owned: true, commandPath, shellConfigPath: profile?.path ?? null } diff --git a/src/renderer/api/CliClient.ts b/src/renderer/api/CliClient.ts new file mode 100644 index 000000000..1171eae4a --- /dev/null +++ b/src/renderer/api/CliClient.ts @@ -0,0 +1,20 @@ +import type { DeepchatBridge } from '@shared/contracts/bridge' +import { cliGetLauncherStatusRoute, cliSetLauncherInstalledRoute } from '@shared/contracts/routes' +import { getDeepchatBridge } from './core' + +export function createCliClient(bridge: DeepchatBridge = getDeepchatBridge()) { + async function getLauncherStatus() { + return await bridge.invoke(cliGetLauncherStatusRoute.name, {}) + } + + async function setLauncherInstalled(installed: boolean) { + return await bridge.invoke(cliSetLauncherInstalledRoute.name, { installed }) + } + + return { + getLauncherStatus, + setLauncherInstalled + } +} + +export type CliClient = ReturnType diff --git a/src/renderer/api/index.ts b/src/renderer/api/index.ts index 4d032a374..adcb2d66f 100644 --- a/src/renderer/api/index.ts +++ b/src/renderer/api/index.ts @@ -3,6 +3,7 @@ export * from './AppRuntimeClient' export * from './BrowserClient' export * from './ConfigClient' export * from './ChatClient' +export * from './CliClient' export * from './ContextMenuClient' export * from './CronJobsClient' export * from './DeviceClient' diff --git a/src/renderer/settings/components/CommonSettings.vue b/src/renderer/settings/components/CommonSettings.vue index 0f0b5c04c..e3065b3bd 100644 --- a/src/renderer/settings/components/CommonSettings.vue +++ b/src/renderer/settings/components/CommonSettings.vue @@ -14,6 +14,7 @@ :model-value="launchAtLoginEnabled" @update:model-value="handleLaunchAtLoginChange" /> + +
+
+
+ +
+
+ DeepChat CLI + + {{ statusLabel }} + +
+ + {{ status.commandPath }} + +
+
+ + + +
+ + +
+ + + diff --git a/src/shared/contracts/routes/cli.routes.ts b/src/shared/contracts/routes/cli.routes.ts index 239d6f97d..f2beb5f6c 100644 --- a/src/shared/contracts/routes/cli.routes.ts +++ b/src/shared/contracts/routes/cli.routes.ts @@ -38,6 +38,7 @@ export const CliLauncherStatusSchema = z .object({ state: CliLauncherStateSchema, reason: CliLauncherReasonSchema.nullable(), + owned: z.boolean(), commandPath: z.string().nullable(), shellConfigPath: z.string().nullable() }) diff --git a/test/main/cli/launcherRoutes.test.ts b/test/main/cli/launcherRoutes.test.ts index 93b744732..6e488f671 100644 --- a/test/main/cli/launcherRoutes.test.ts +++ b/test/main/cli/launcherRoutes.test.ts @@ -5,6 +5,7 @@ import { createCliLauncherRoutes } from '@/cli/launcherRoutes' const installedStatus = { state: 'installed' as const, reason: null, + owned: true, commandPath: '/home/user/.local/bin/deepchat', shellConfigPath: '/home/user/.zprofile' } @@ -42,5 +43,19 @@ describe('createCliLauncherRoutes', () => { } ) ).rejects.toThrow('renderer caller') + await expect( + setInstalled( + { installed: false }, + { + caller: { + kind: 'cli', + principal: 'human', + connectionId: 'connection-1', + scopes: ['system:write'] + } + } + ) + ).rejects.toThrow('renderer caller') + expect(launcher.setInstalled).toHaveBeenCalledTimes(1) }) }) diff --git a/test/main/cli/launcherService.test.ts b/test/main/cli/launcherService.test.ts index c04e20e6d..44a2f7072 100644 --- a/test/main/cli/launcherService.test.ts +++ b/test/main/cli/launcherService.test.ts @@ -60,11 +60,13 @@ describe('CliLauncherService', () => { await expect(fixture.service.getStatus()).resolves.toMatchObject({ state: 'not-installed', + owned: false, commandPath, shellConfigPath: null }) await expect(fixture.service.setInstalled(true)).resolves.toMatchObject({ state: 'installed', + owned: true, commandPath, shellConfigPath: profilePath }) @@ -90,7 +92,8 @@ describe('CliLauncherService', () => { ).toBe(0o600) await expect(fixture.service.setInstalled(false)).resolves.toMatchObject({ - state: 'not-installed' + state: 'not-installed', + owned: false }) await expect(lstat(commandPath)).rejects.toMatchObject({ code: 'ENOENT' }) expect(await readFile(profilePath, 'utf8')).toBe('export EDITOR=vim\n') @@ -144,7 +147,8 @@ describe('CliLauncherService', () => { await expect(fixture.service.getStatus()).resolves.toMatchObject({ state: 'conflict', - reason: 'unowned-command' + reason: 'unowned-command', + owned: false }) await expect(fixture.service.setInstalled(true)).rejects.toThrow('without an ownership marker') expect(await readFile(commandPath, 'utf8')).toBe('foreign') @@ -167,7 +171,8 @@ describe('CliLauncherService', () => { await expect(fixture.service.getStatus()).resolves.toMatchObject({ state: 'conflict', - reason: 'command-modified' + reason: 'command-modified', + owned: true }) await expect(fixture.service.setInstalled(false)).rejects.toThrow('unowned') expect(await readlink(commandPath)).toBe('/tmp/not-deepchat') @@ -195,7 +200,8 @@ describe('CliLauncherService', () => { await expect(fixture.service.getStatus()).resolves.toMatchObject({ state: 'needs-repair', - reason: 'command-missing' + reason: 'command-missing', + owned: true }) await fixture.service.reconcileOwnedLauncher() await expect(lstat(commandPath)).rejects.toMatchObject({ code: 'ENOENT' }) @@ -283,7 +289,8 @@ describe('CliLauncherService', () => { await expect(fixture.service.getStatus()).resolves.toMatchObject({ state: 'unavailable', - reason: 'source-missing' + reason: 'source-missing', + owned: false }) await expect(fixture.service.setInstalled(true)).rejects.toThrow('unavailable') }) @@ -295,11 +302,32 @@ describe('CliLauncherService', () => { await fixture.service.setInstalled(true) fixture.setCliDirectory(null) + await expect(fixture.service.getStatus()).resolves.toMatchObject({ + state: 'unavailable', + reason: 'source-missing', + owned: true + }) + await expect(fixture.service.setInstalled(false)).resolves.toMatchObject({ state: 'unavailable', - reason: 'source-missing' + reason: 'source-missing', + owned: false }) await expect(lstat(commandPath)).rejects.toMatchObject({ code: 'ENOENT' }) await expect(lstat(profilePath)).rejects.toMatchObject({ code: 'ENOENT' }) }) + + it('does not offer repair when owned files and the packaged source are both missing', async () => { + const fixture = await createFixture() + const commandPath = path.join(fixture.homeDirectory, '.local', 'bin', 'deepchat') + await fixture.service.setInstalled(true) + await rm(commandPath) + fixture.setCliDirectory(null) + + await expect(fixture.service.getStatus()).resolves.toMatchObject({ + state: 'unavailable', + reason: 'source-missing', + owned: true + }) + }) }) diff --git a/test/renderer/api/cliClient.test.ts b/test/renderer/api/cliClient.test.ts new file mode 100644 index 000000000..67888f756 --- /dev/null +++ b/test/renderer/api/cliClient.test.ts @@ -0,0 +1,35 @@ +import type { DeepchatBridge } from '@shared/contracts/bridge' +import type { CliLauncherStatus } from '@shared/contracts/routes' +import { createCliClient } from '../../../src/renderer/api/CliClient' + +const installedStatus = { + state: 'installed', + reason: null, + owned: true, + commandPath: '/home/user/.local/bin/deepchat', + shellConfigPath: '/home/user/.zprofile' +} satisfies CliLauncherStatus + +describe('CliClient', () => { + it('invokes the typed launcher routes with exact inputs', async () => { + const invoke = vi + .fn() + .mockResolvedValueOnce(installedStatus) + .mockResolvedValueOnce({ ...installedStatus, state: 'not-installed', owned: false }) + const bridge: DeepchatBridge = { + invoke, + on: vi.fn(() => () => undefined) + } + const client = createCliClient(bridge) + + await expect(client.getLauncherStatus()).resolves.toEqual(installedStatus) + await expect(client.setLauncherInstalled(false)).resolves.toMatchObject({ + state: 'not-installed', + owned: false + }) + expect(invoke).toHaveBeenNthCalledWith(1, 'cli.getLauncherStatus', {}) + expect(invoke).toHaveBeenNthCalledWith(2, 'cli.setLauncherInstalled', { + installed: false + }) + }) +}) diff --git a/test/renderer/components/CliLauncherSettingsSection.test.ts b/test/renderer/components/CliLauncherSettingsSection.test.ts new file mode 100644 index 000000000..62b68f5b6 --- /dev/null +++ b/test/renderer/components/CliLauncherSettingsSection.test.ts @@ -0,0 +1,182 @@ +import { defineComponent } from 'vue' +import { flushPromises, mount } from '@vue/test-utils' +import type { CliLauncherStatus } from '@shared/contracts/routes' + +const installedStatus = { + state: 'installed', + reason: null, + owned: true, + commandPath: '/home/user/.local/bin/deepchat', + shellConfigPath: '/home/user/.zprofile' +} satisfies CliLauncherStatus + +const switchStub = defineComponent({ + name: 'Switch', + inheritAttrs: false, + props: { + modelValue: Boolean, + disabled: Boolean + }, + emits: ['update:model-value'], + template: + '' +}) + +async function setup(initialStatus: CliLauncherStatus | Error) { + vi.resetModules() + const cliClient = { + getLauncherStatus: + initialStatus instanceof Error + ? vi.fn().mockRejectedValue(initialStatus) + : vi.fn().mockResolvedValue(initialStatus), + setLauncherInstalled: vi.fn() + } + vi.doMock('@api/CliClient', () => ({ createCliClient: () => cliClient })) + vi.doMock('vue-i18n', () => ({ + useI18n: () => ({ t: (key: string) => key }) + })) + + const CliLauncherSettingsSection = ( + await import('../../../src/renderer/settings/components/common/CliLauncherSettingsSection.vue') + ).default + const wrapper = mount(CliLauncherSettingsSection, { + global: { + stubs: { + Icon: true, + Button: buttonStub, + Switch: switchStub + } + } + }) + await flushPromises() + return { wrapper, cliClient } +} + +describe('CliLauncherSettingsSection', () => { + it('shows an installed owned launcher and its command path', async () => { + const { wrapper } = await setup(installedStatus) + + expect(wrapper.get('[data-testid="cli-launcher-status"]').text()).toBe('common.enabled') + expect(wrapper.get('[data-testid="cli-launcher-switch"]').attributes('data-model-value')).toBe( + 'true' + ) + expect(wrapper.text()).toContain(installedStatus.commandPath) + }) + + it('installs an unowned launcher from the disabled state', async () => { + const notInstalled: CliLauncherStatus = { + state: 'not-installed', + reason: null, + owned: false, + commandPath: '/home/user/.local/bin/deepchat', + shellConfigPath: null + } + const { wrapper, cliClient } = await setup(notInstalled) + cliClient.setLauncherInstalled.mockResolvedValue(installedStatus) + + await wrapper.get('[data-testid="cli-launcher-switch"]').trigger('click') + await flushPromises() + + expect(cliClient.setLauncherInstalled).toHaveBeenCalledWith(true) + expect(wrapper.get('[data-testid="cli-launcher-switch"]').attributes('data-model-value')).toBe( + 'true' + ) + }) + + it('allows removing an owned launcher when its bundled source is unavailable', async () => { + const unavailable: CliLauncherStatus = { + ...installedStatus, + state: 'unavailable', + reason: 'source-missing' + } + const removed: CliLauncherStatus = { + ...unavailable, + owned: false + } + const { wrapper, cliClient } = await setup(unavailable) + cliClient.setLauncherInstalled.mockResolvedValue(removed) + + await wrapper.get('[data-testid="cli-launcher-switch"]').trigger('click') + await flushPromises() + + expect(cliClient.setLauncherInstalled).toHaveBeenCalledWith(false) + expect(wrapper.get('[data-testid="cli-launcher-status"]').text()).toBe('common.disabled') + }) + + it('repairs owned stale launchers but disables conflicting integration', async () => { + const stale: CliLauncherStatus = { + ...installedStatus, + state: 'stale', + reason: 'upgrade-required' + } + const staleSetup = await setup(stale) + staleSetup.cliClient.setLauncherInstalled.mockResolvedValue(installedStatus) + + await staleSetup.wrapper.get('[data-testid="cli-launcher-repair"]').trigger('click') + await flushPromises() + expect(staleSetup.cliClient.setLauncherInstalled).toHaveBeenCalledWith(true) + + const conflictSetup = await setup({ + ...installedStatus, + state: 'conflict', + reason: 'unowned-command', + owned: false + }) + expect( + conflictSetup.wrapper.get('[data-testid="cli-launcher-switch"]').attributes('disabled') + ).toBeDefined() + expect(conflictSetup.wrapper.get('[data-testid="cli-launcher-status"]').text()).toBe( + 'common.error.operationFailed' + ) + }) + + it('reports initial failures and reconciles an ambiguous mutation response', async () => { + const initialFailure = await setup(new Error('unavailable')) + expect(initialFailure.wrapper.get('[role="alert"]').text()).toBe( + 'common.notifications.actionFailed' + ) + expect(initialFailure.wrapper.get('[data-testid="cli-launcher-status"]').text()).toBe( + 'common.error.operationFailed' + ) + + const mutationFailure = await setup(installedStatus) + const removed: CliLauncherStatus = { + state: 'not-installed', + reason: null, + owned: false, + commandPath: installedStatus.commandPath, + shellConfigPath: null + } + mutationFailure.cliClient.setLauncherInstalled.mockRejectedValue(new Error('denied')) + mutationFailure.cliClient.getLauncherStatus.mockResolvedValueOnce(removed) + await mutationFailure.wrapper.get('[data-testid="cli-launcher-switch"]').trigger('click') + await flushPromises() + expect(mutationFailure.cliClient.getLauncherStatus).toHaveBeenCalledTimes(2) + expect(mutationFailure.wrapper.find('[role="alert"]').exists()).toBe(false) + expect( + mutationFailure.wrapper + .get('[data-testid="cli-launcher-switch"]') + .attributes('data-model-value') + ).toBe('false') + }) + + it('keeps an error visible when mutation reconciliation does not reach the target', async () => { + const { wrapper, cliClient } = await setup(installedStatus) + cliClient.setLauncherInstalled.mockRejectedValue(new Error('denied')) + + await wrapper.get('[data-testid="cli-launcher-switch"]').trigger('click') + await flushPromises() + + expect(wrapper.get('[role="alert"]').text()).toBe('common.notifications.actionFailed') + expect(wrapper.get('[data-testid="cli-launcher-switch"]').attributes('data-model-value')).toBe( + 'true' + ) + }) +}) From 9ed6cbaaf79808ce8a51c38276d34ea7bdc41eaa Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 18:35:35 +0800 Subject: [PATCH 30/51] feat(cli): automate desktop lifecycle --- docs/architecture/local-control-plane/plan.md | 3 +- docs/architecture/local-control-plane/spec.md | 17 +- .../architecture/local-control-plane/tasks.md | 3 +- src/main/app/composition.ts | 15 +- src/main/cli/index.ts | 1 - src/main/cli/launcherRoutes.ts | 32 --- src/main/cli/launcherService.ts | 63 +++--- src/renderer/api/CliClient.ts | 20 -- src/renderer/api/index.ts | 1 - .../settings/components/CommonSettings.vue | 2 - .../common/CliLauncherSettingsSection.vue | 138 ------------- src/shared/contracts/routes.ts | 4 - src/shared/contracts/routes/cli.routes.ts | 47 ----- test/main/cli/launcherRoutes.test.ts | 61 ------ test/main/cli/launcherService.test.ts | 122 +++++++----- test/main/cli/server.test.ts | 77 ++++++-- test/renderer/api/cliClient.test.ts | 35 ---- .../CliLauncherSettingsSection.test.ts | 182 ------------------ 18 files changed, 191 insertions(+), 632 deletions(-) delete mode 100644 src/main/cli/launcherRoutes.ts delete mode 100644 src/renderer/api/CliClient.ts delete mode 100644 src/renderer/settings/components/common/CliLauncherSettingsSection.vue delete mode 100644 test/main/cli/launcherRoutes.test.ts delete mode 100644 test/renderer/api/cliClient.test.ts delete mode 100644 test/renderer/components/CliLauncherSettingsSection.test.ts diff --git a/docs/architecture/local-control-plane/plan.md b/docs/architecture/local-control-plane/plan.md index 583395261..1580b1287 100644 --- a/docs/architecture/local-control-plane/plan.md +++ b/docs/architecture/local-control-plane/plan.md @@ -70,7 +70,8 @@ ## Stage E: Packaged Product and Agent Integration 1. Build the CLI as a packaged application resource that runs on the bundled Node runtime. -2. Add opt-in platform launchers and PATH installation/removal with explicit, reversible ownership. +2. Automatically reconcile platform launchers after server startup, with no settings toggle and with + explicit, reversible ownership that never overwrites foreign commands or shell content. 3. Add the internal scoped-token issuer, conversation binding, expiry/revocation, call/byte quotas, and main-enforced Agent restrictions. 4. Harden `CommandPermissionService` so redirection and compound shell syntax cannot inherit a safe diff --git a/docs/architecture/local-control-plane/spec.md b/docs/architecture/local-control-plane/spec.md index afa984b79..9ad7948b2 100644 --- a/docs/architecture/local-control-plane/spec.md +++ b/docs/architecture/local-control-plane/spec.md @@ -546,9 +546,17 @@ and `8` internal/protocol failure. The packaged CLI source lives in `src/cli`; main-side transport adapters live in `src/main/cli`. The built standalone entry and launchers use the bundled Node runtime and ship outside `app.asar` as -application resources. Installation is opt-in and places a small launcher in the platform's user -command location. It does not install an npm package or copy credentials. Upgrades replace app-owned -resources while keeping the launcher stable. +application resources. After the local control server is listening, startup automatically and +idempotently places a small launcher in the platform's user command location; there is no settings +toggle. It never overwrites an unowned command or modified shell block, does not install an npm +package or copy credentials, and records enough ownership state for exact rollback during full data +reset. Upgrades replace app-owned resources while keeping the launcher stable. + +Main owns the server lifetime. Desktop shutdown first stops accepting new work, aborts every pending +request and stream with a typed `unavailable` result when the connection remains writable, then +closes idle and active sockets within a bounded grace period. A thin CLI invocation has no daemon +mode and must exit after that terminal result or EOF; it must never outlive DeepChat waiting on a +stale local endpoint. ## Agent Token and Bundled Skill @@ -602,7 +610,8 @@ and cold application restarts. - Surface tests prove every exposed method is declared, registered, classified, bounded, and allowed only for its caller/scopes; internal routes are unreachable. - Transport tests cover descriptor permissions/rotation, token comparison, stale endpoints, malformed - HTTP, fixed and chunked body limits, spill cleanup, aborts, backpressure, and shutdown ordering. + HTTP, fixed and chunked body limits, spill cleanup, aborts, backpressure, shutdown ordering, and + termination of active CLI streams when the desktop exits. - Caller migration tests prove renderer-only routes reject CLI/internal callers without sentinel IDs. - Approval tests cover binding, redaction, timeout, abort, scope cancellation, single consumption, concurrent identical CLI mutations, renderer-only resolution, and preserved tool behavior. diff --git a/docs/architecture/local-control-plane/tasks.md b/docs/architecture/local-control-plane/tasks.md index 25d37fbb7..a1b606e86 100644 --- a/docs/architecture/local-control-plane/tasks.md +++ b/docs/architecture/local-control-plane/tasks.md @@ -74,7 +74,8 @@ ## Packaging and Agent Use - [x] Package the CLI with the bundled Node runtime on all supported targets. -- [ ] Add opt-in, reversible platform launcher/PATH integration. +- [x] Add automatic, idempotent, reversible platform launcher/PATH integration with no settings + toggle. - [ ] Add in-memory scoped Agent token issuance, expiry, revocation, and quotas. - [ ] Harden shell permission checks for redirection and compound syntax before Agent enablement. - [ ] Keep `deepchat` out of `SAFE_COMMANDS`, enforce domain/verb-first grammar, and deny Agent diff --git a/src/main/app/composition.ts b/src/main/app/composition.ts index 5cfaaf067..f697cec23 100644 --- a/src/main/app/composition.ts +++ b/src/main/app/composition.ts @@ -230,7 +230,6 @@ import { createArtifactRoutes, createCliComputeRoutes, createCliMcpAdminRoutes, - createCliLauncherRoutes, createCliProviderModelAdminRoutes, createCliRoutes, resolveBundledCliDirectory @@ -2475,7 +2474,6 @@ export async function createMainProcessControl(dependencies: { (target) => target.kind === 'main' ) }) - const cliLauncherRoutes = createCliLauncherRoutes(cliLauncherService) const approvalRoutes = createApprovalRoutes({ resolve: (input, caller) => cliMutationGuard.resolve(input, caller) }) @@ -2538,7 +2536,6 @@ export async function createMainProcessControl(dependencies: { appRoutes, approvalRoutes, cliRoutes, - cliLauncherRoutes, artifactRoutes, cliComputeRoutes, cliProviderModelAdminRoutes, @@ -2851,7 +2848,7 @@ export async function createMainProcessControl(dependencies: { ) } if (launcherStatus.reason !== 'unowned-command') { - await cliLauncherService.setInstalled(false) + await cliLauncherService.removeOwnedLauncher() } } await stop() @@ -2950,10 +2947,12 @@ export async function createMainProcessControl(dependencies: { } catch (error) { logger.error('[CLI] Failed to start local control server', error) } - try { - await cliLauncherService.reconcileOwnedLauncher() - } catch (error) { - logger.warn('[CLI] Failed to refresh the owned command launcher', error) + if (cliServer.getStatus().running) { + try { + await cliLauncherService.ensureInstalled() + } catch (error) { + logger.warn('[CLI] Failed to install or refresh the command launcher', error) + } } init(dependencies.startupRunId) scheduleBackgroundWork() diff --git a/src/main/cli/index.ts b/src/main/cli/index.ts index a3543c146..ddebae5e5 100644 --- a/src/main/cli/index.ts +++ b/src/main/cli/index.ts @@ -31,7 +31,6 @@ export { type CliLauncherState, type CliLauncherStatus } from './launcherService' -export { createCliLauncherRoutes } from './launcherRoutes' export { createCliProviderModelAdminRoutes, type CliProviderModelAdminDependencies diff --git a/src/main/cli/launcherRoutes.ts b/src/main/cli/launcherRoutes.ts deleted file mode 100644 index 5b263c6e5..000000000 --- a/src/main/cli/launcherRoutes.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { cliGetLauncherStatusRoute, cliSetLauncherInstalledRoute } from '@shared/contracts/routes' -import { - createRouteMap, - requireRendererCaller, - type DeepchatRouteMap -} from '@/routes/routeRegistry' -import type { CliLauncherService } from './launcherService' - -export function createCliLauncherRoutes( - launcher: Pick -): DeepchatRouteMap { - return createRouteMap([ - [ - cliGetLauncherStatusRoute.name, - async (rawInput, context) => { - requireRendererCaller(context) - cliGetLauncherStatusRoute.input.parse(rawInput) - return cliGetLauncherStatusRoute.output.parse(await launcher.getStatus()) - } - ], - [ - cliSetLauncherInstalledRoute.name, - async (rawInput, context) => { - requireRendererCaller(context) - const input = cliSetLauncherInstalledRoute.input.parse(rawInput) - return cliSetLauncherInstalledRoute.output.parse( - await launcher.setInstalled(input.installed) - ) - } - ] - ]) -} diff --git a/src/main/cli/launcherService.ts b/src/main/cli/launcherService.ts index cc284c1d1..726daa9d6 100644 --- a/src/main/cli/launcherService.ts +++ b/src/main/cli/launcherService.ts @@ -12,13 +12,33 @@ import { writeFile } from 'node:fs/promises' import path from 'node:path' -import type { CliLauncherStatus } from '@shared/contracts/routes' -export type { - CliLauncherReason, - CliLauncherState, - CliLauncherStatus -} from '@shared/contracts/routes' +export type CliLauncherState = + | 'not-installed' + | 'installed' + | 'stale' + | 'needs-repair' + | 'conflict' + | 'unavailable' + +export type CliLauncherReason = + | 'unsupported-platform' + | 'source-missing' + | 'path-unavailable' + | 'ownership-marker-invalid' + | 'unowned-command' + | 'command-modified' + | 'command-missing' + | 'shell-config-modified' + | 'shell-config-missing' + | 'upgrade-required' + +export type CliLauncherStatus = Readonly<{ + state: CliLauncherState + reason: CliLauncherReason | null + commandPath: string | null + shellConfigPath: string | null +}> const LAUNCHER_MARKER_VERSION = 1 const LAUNCHER_MARKER_FILENAME = 'launcher.json' @@ -221,19 +241,17 @@ export class CliLauncherService { return await this.runExclusive(() => this.inspectStatus()) } - async setInstalled(installed: boolean): Promise { + async ensureInstalled(): Promise { return await this.runExclusive(async () => { - if (installed) await this.installOrRepair() - else await this.uninstall() + await this.installOrRepair() return await this.inspectStatus() }) } - async reconcileOwnedLauncher(): Promise { - await this.runExclusive(async () => { - const status = await this.inspectStatus() - if (status.state !== 'stale') return - await this.installOrRepair() + async removeOwnedLauncher(): Promise { + return await this.runExclusive(async () => { + await this.uninstall() + return await this.inspectStatus() }) } @@ -303,7 +321,6 @@ export class CliLauncherService { return { state: 'unavailable', reason: 'unsupported-platform', - owned: false, commandPath: null, shellConfigPath: null } @@ -314,7 +331,6 @@ export class CliLauncherService { return { state: 'conflict', reason: 'ownership-marker-invalid', - owned: false, commandPath, shellConfigPath: null } @@ -325,7 +341,6 @@ export class CliLauncherService { return { state: 'conflict', reason: 'unowned-command', - owned: false, commandPath, shellConfigPath: null } @@ -334,7 +349,6 @@ export class CliLauncherService { return { state: 'unavailable', reason: 'path-unavailable', - owned: false, commandPath, shellConfigPath: null } @@ -343,7 +357,6 @@ export class CliLauncherService { return { state: source ? 'not-installed' : 'unavailable', reason: source ? null : 'source-missing', - owned: false, commandPath, shellConfigPath: null } @@ -355,7 +368,6 @@ export class CliLauncherService { return { state: 'conflict', reason: 'ownership-marker-invalid', - owned: false, commandPath, shellConfigPath: null } @@ -376,7 +388,6 @@ export class CliLauncherService { return { state: 'conflict', reason: 'shell-config-modified', - owned: true, commandPath, shellConfigPath: profile.path } @@ -387,7 +398,6 @@ export class CliLauncherService { return { state: 'conflict', reason: 'command-modified', - owned: true, commandPath, shellConfigPath: profile?.path ?? null } @@ -398,7 +408,6 @@ export class CliLauncherService { return { state: 'unavailable', reason: 'source-missing', - owned: true, commandPath, shellConfigPath: profile?.path ?? null } @@ -407,7 +416,6 @@ export class CliLauncherService { return { state: 'needs-repair', reason: 'command-missing', - owned: true, commandPath, shellConfigPath: profile?.path ?? null } @@ -416,7 +424,6 @@ export class CliLauncherService { return { state: 'needs-repair', reason: 'shell-config-missing', - owned: true, commandPath, shellConfigPath: profile.path } @@ -425,7 +432,6 @@ export class CliLauncherService { return { state: 'needs-repair', reason: 'path-unavailable', - owned: true, commandPath, shellConfigPath: null } @@ -443,7 +449,6 @@ export class CliLauncherService { return { state: stale ? 'stale' : 'installed', reason: stale ? 'upgrade-required' : null, - owned: true, commandPath, shellConfigPath: profile?.path ?? null } @@ -639,7 +644,9 @@ export class CliLauncherService { private async selectProfileKind(): Promise { if (this.isCommandDirectoryOnPath()) return null - switch (path.basename(this.options.shell ?? '')) { + const fallbackShell = + this.platform === 'darwin' ? 'zsh' : this.platform === 'linux' ? 'bash' : '' + switch (path.basename(this.options.shell ?? fallbackShell)) { case 'zsh': return 'zsh' case 'bash': diff --git a/src/renderer/api/CliClient.ts b/src/renderer/api/CliClient.ts deleted file mode 100644 index 1171eae4a..000000000 --- a/src/renderer/api/CliClient.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { DeepchatBridge } from '@shared/contracts/bridge' -import { cliGetLauncherStatusRoute, cliSetLauncherInstalledRoute } from '@shared/contracts/routes' -import { getDeepchatBridge } from './core' - -export function createCliClient(bridge: DeepchatBridge = getDeepchatBridge()) { - async function getLauncherStatus() { - return await bridge.invoke(cliGetLauncherStatusRoute.name, {}) - } - - async function setLauncherInstalled(installed: boolean) { - return await bridge.invoke(cliSetLauncherInstalledRoute.name, { installed }) - } - - return { - getLauncherStatus, - setLauncherInstalled - } -} - -export type CliClient = ReturnType diff --git a/src/renderer/api/index.ts b/src/renderer/api/index.ts index adcb2d66f..4d032a374 100644 --- a/src/renderer/api/index.ts +++ b/src/renderer/api/index.ts @@ -3,7 +3,6 @@ export * from './AppRuntimeClient' export * from './BrowserClient' export * from './ConfigClient' export * from './ChatClient' -export * from './CliClient' export * from './ContextMenuClient' export * from './CronJobsClient' export * from './DeviceClient' diff --git a/src/renderer/settings/components/CommonSettings.vue b/src/renderer/settings/components/CommonSettings.vue index e3065b3bd..0f0b5c04c 100644 --- a/src/renderer/settings/components/CommonSettings.vue +++ b/src/renderer/settings/components/CommonSettings.vue @@ -14,7 +14,6 @@ :model-value="launchAtLoginEnabled" @update:model-value="handleLaunchAtLoginChange" /> - -
-
-
- -
-
- DeepChat CLI - - {{ statusLabel }} - -
- - {{ status.commandPath }} - -
-
- - - -
- - -
- - - diff --git a/src/shared/contracts/routes.ts b/src/shared/contracts/routes.ts index fa24c73a9..c77852269 100644 --- a/src/shared/contracts/routes.ts +++ b/src/shared/contracts/routes.ts @@ -84,8 +84,6 @@ import { import { cliCapabilitiesRoute, cliDoctorRoute, - cliGetLauncherStatusRoute, - cliSetLauncherInstalledRoute, cliStatusRoute, cliVersionRoute } from './routes/cli.routes' @@ -1019,8 +1017,6 @@ const DEEPCHAT_ROUTE_CATALOG_PART_5 = { [cliVersionRoute.name]: cliVersionRoute, [cliCapabilitiesRoute.name]: cliCapabilitiesRoute, [cliDoctorRoute.name]: cliDoctorRoute, - [cliGetLauncherStatusRoute.name]: cliGetLauncherStatusRoute, - [cliSetLauncherInstalledRoute.name]: cliSetLauncherInstalledRoute, [chatCancelSubmissionRoute.name]: chatCancelSubmissionRoute, [chatSendMessageRoute.name]: chatSendMessageRoute, [chatSteerActiveTurnRoute.name]: chatSteerActiveTurnRoute, diff --git a/src/shared/contracts/routes/cli.routes.ts b/src/shared/contracts/routes/cli.routes.ts index f2beb5f6c..125512ce7 100644 --- a/src/shared/contracts/routes/cli.routes.ts +++ b/src/shared/contracts/routes/cli.routes.ts @@ -12,38 +12,6 @@ import { export const LocalControlTransportSchema = z.enum(['rpc', 'stream', 'upload', 'download']) export const LocalControlApprovalModeSchema = z.enum(['never', 'policy']) -export const CliLauncherStateSchema = z.enum([ - 'not-installed', - 'installed', - 'stale', - 'needs-repair', - 'conflict', - 'unavailable' -]) - -export const CliLauncherReasonSchema = z.enum([ - 'unsupported-platform', - 'source-missing', - 'path-unavailable', - 'ownership-marker-invalid', - 'unowned-command', - 'command-modified', - 'command-missing', - 'shell-config-modified', - 'shell-config-missing', - 'upgrade-required' -]) - -export const CliLauncherStatusSchema = z - .object({ - state: CliLauncherStateSchema, - reason: CliLauncherReasonSchema.nullable(), - owned: z.boolean(), - commandPath: z.string().nullable(), - shellConfigPath: z.string().nullable() - }) - .strict() - export const LocalControlCapabilitySchema = z .object({ method: LocalControlMethodSchema, @@ -108,19 +76,4 @@ export const cliDoctorRoute = defineRouteContract({ }) }) -export const cliGetLauncherStatusRoute = defineRouteContract({ - name: 'cli.getLauncherStatus', - input: z.object({}).default({}), - output: CliLauncherStatusSchema -}) - -export const cliSetLauncherInstalledRoute = defineRouteContract({ - name: 'cli.setLauncherInstalled', - input: z.object({ installed: z.boolean() }).strict(), - output: CliLauncherStatusSchema -}) - export type CliCapability = z.infer -export type CliLauncherState = z.infer -export type CliLauncherReason = z.infer -export type CliLauncherStatus = z.infer diff --git a/test/main/cli/launcherRoutes.test.ts b/test/main/cli/launcherRoutes.test.ts deleted file mode 100644 index 6e488f671..000000000 --- a/test/main/cli/launcherRoutes.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { cliGetLauncherStatusRoute, cliSetLauncherInstalledRoute } from '@shared/contracts/routes' -import { createCliLauncherRoutes } from '@/cli/launcherRoutes' - -const installedStatus = { - state: 'installed' as const, - reason: null, - owned: true, - commandPath: '/home/user/.local/bin/deepchat', - shellConfigPath: '/home/user/.zprofile' -} - -describe('createCliLauncherRoutes', () => { - it('exposes launcher state only to renderer callers', async () => { - const launcher = { - getStatus: vi.fn(async () => installedStatus), - setInstalled: vi.fn(async () => installedStatus) - } - const routes = createCliLauncherRoutes(launcher) - const getStatus = routes.get(cliGetLauncherStatusRoute.name) - const setInstalled = routes.get(cliSetLauncherInstalledRoute.name) - if (!getStatus || !setInstalled) throw new Error('Expected CLI launcher routes') - const rendererContext = { - caller: { kind: 'renderer' as const, webContentsId: 1, windowId: 2 } - } - - await expect(getStatus({}, rendererContext)).resolves.toEqual(installedStatus) - await expect(setInstalled({ installed: true }, rendererContext)).resolves.toEqual( - installedStatus - ) - expect(launcher.setInstalled).toHaveBeenCalledWith(true) - - await expect( - getStatus( - {}, - { - caller: { - kind: 'cli', - principal: 'human', - connectionId: 'connection-1', - scopes: ['system:read'] - } - } - ) - ).rejects.toThrow('renderer caller') - await expect( - setInstalled( - { installed: false }, - { - caller: { - kind: 'cli', - principal: 'human', - connectionId: 'connection-1', - scopes: ['system:write'] - } - } - ) - ).rejects.toThrow('renderer caller') - expect(launcher.setInstalled).toHaveBeenCalledTimes(1) - }) -}) diff --git a/test/main/cli/launcherService.test.ts b/test/main/cli/launcherService.test.ts index 44a2f7072..c941a26d7 100644 --- a/test/main/cli/launcherService.test.ts +++ b/test/main/cli/launcherService.test.ts @@ -60,13 +60,11 @@ describe('CliLauncherService', () => { await expect(fixture.service.getStatus()).resolves.toMatchObject({ state: 'not-installed', - owned: false, commandPath, shellConfigPath: null }) - await expect(fixture.service.setInstalled(true)).resolves.toMatchObject({ + await expect(fixture.service.ensureInstalled()).resolves.toMatchObject({ state: 'installed', - owned: true, commandPath, shellConfigPath: profilePath }) @@ -91,9 +89,8 @@ describe('CliLauncherService', () => { 0o777 ).toBe(0o600) - await expect(fixture.service.setInstalled(false)).resolves.toMatchObject({ - state: 'not-installed', - owned: false + await expect(fixture.service.removeOwnedLauncher()).resolves.toMatchObject({ + state: 'not-installed' }) await expect(lstat(commandPath)).rejects.toMatchObject({ code: 'ENOENT' }) expect(await readFile(profilePath, 'utf8')).toBe('export EDITOR=vim\n') @@ -114,7 +111,7 @@ describe('CliLauncherService', () => { resolveCliDirectory: () => fixture.cliDirectory }) - await expect(service.setInstalled(true)).resolves.toMatchObject({ + await expect(service.ensureInstalled()).resolves.toMatchObject({ state: 'installed', shellConfigPath: null }) @@ -123,22 +120,63 @@ describe('CliLauncherService', () => { }) }) + it('uses platform defaults when GUI startup has no shell environment', async () => { + const macFixture = await createFixture() + const macService = new CliLauncherService({ + platform: 'darwin', + homeDirectory: macFixture.homeDirectory, + userDataDirectory: macFixture.userDataDirectory, + environmentPath: '/usr/bin:/bin', + resolveCliDirectory: () => macFixture.cliDirectory + }) + await expect(macService.ensureInstalled()).resolves.toMatchObject({ + shellConfigPath: path.join(macFixture.homeDirectory, '.zprofile') + }) + + const linuxFixture = await createFixture('linux') + const linuxService = new CliLauncherService({ + platform: 'linux', + homeDirectory: linuxFixture.homeDirectory, + userDataDirectory: linuxFixture.userDataDirectory, + environmentPath: '/usr/bin:/bin', + resolveCliDirectory: () => linuxFixture.cliDirectory + }) + await expect(linuxService.ensureInstalled()).resolves.toMatchObject({ + shellConfigPath: path.join(linuxFixture.homeDirectory, '.bashrc') + }) + }) + it('restores a profile byte-for-byte and removes a profile it created', async () => { const fixture = await createFixture() const profilePath = path.join(fixture.homeDirectory, '.zprofile') await writeFile(profilePath, 'export EDITOR=vim') - await fixture.service.setInstalled(true) - await fixture.service.setInstalled(false) + await fixture.service.ensureInstalled() + await fixture.service.removeOwnedLauncher() expect(await readFile(profilePath, 'utf8')).toBe('export EDITOR=vim') await rm(profilePath) - await fixture.service.setInstalled(true) + await fixture.service.ensureInstalled() expect((await lstat(profilePath)).isFile()).toBe(true) - await fixture.service.setInstalled(false) + await fixture.service.removeOwnedLauncher() await expect(lstat(profilePath)).rejects.toMatchObject({ code: 'ENOENT' }) }) + it('preserves user shell changes across repeated startup reconciliation', async () => { + const fixture = await createFixture() + const profilePath = path.join(fixture.homeDirectory, '.zprofile') + await writeFile(profilePath, 'export BEFORE=1\n') + await fixture.service.ensureInstalled() + const changedContent = `${await readFile(profilePath, 'utf8')}export AFTER=1\n` + await writeFile(profilePath, changedContent) + + await expect(fixture.service.ensureInstalled()).resolves.toMatchObject({ + state: 'installed' + }) + + expect(await readFile(profilePath, 'utf8')).toBe(changedContent) + }) + it('refuses to overwrite an unowned command or an orphaned managed block', async () => { const fixture = await createFixture() const commandPath = path.join(fixture.homeDirectory, '.local', 'bin', 'deepchat') @@ -147,10 +185,9 @@ describe('CliLauncherService', () => { await expect(fixture.service.getStatus()).resolves.toMatchObject({ state: 'conflict', - reason: 'unowned-command', - owned: false + reason: 'unowned-command' }) - await expect(fixture.service.setInstalled(true)).rejects.toThrow('without an ownership marker') + await expect(fixture.service.ensureInstalled()).rejects.toThrow('without an ownership marker') expect(await readFile(commandPath, 'utf8')).toBe('foreign') await rm(commandPath) @@ -158,23 +195,22 @@ describe('CliLauncherService', () => { path.join(fixture.homeDirectory, '.zprofile'), '# >>> DeepChat CLI >>>\ncustom\n# <<< DeepChat CLI <<<\n' ) - await expect(fixture.service.setInstalled(true)).rejects.toThrow('without an ownership marker') + await expect(fixture.service.ensureInstalled()).rejects.toThrow('without an ownership marker') }) it('fails closed when an owned command or shell block is modified', async () => { const fixture = await createFixture() const commandPath = path.join(fixture.homeDirectory, '.local', 'bin', 'deepchat') const profilePath = path.join(fixture.homeDirectory, '.zprofile') - await fixture.service.setInstalled(true) + await fixture.service.ensureInstalled() await rm(commandPath) await symlink('/tmp/not-deepchat', commandPath) await expect(fixture.service.getStatus()).resolves.toMatchObject({ state: 'conflict', - reason: 'command-modified', - owned: true + reason: 'command-modified' }) - await expect(fixture.service.setInstalled(false)).rejects.toThrow('unowned') + await expect(fixture.service.removeOwnedLauncher()).rejects.toThrow('unowned') expect(await readlink(commandPath)).toBe('/tmp/not-deepchat') await rm(commandPath) @@ -189,31 +225,27 @@ describe('CliLauncherService', () => { state: 'conflict', reason: 'shell-config-modified' }) - await expect(fixture.service.setInstalled(false)).rejects.toThrow('modified') + await expect(fixture.service.removeOwnedLauncher()).rejects.toThrow('modified') }) - it('repairs missing owned files only after an explicit install request', async () => { + it('repairs missing owned files while ensuring launcher availability', async () => { const fixture = await createFixture() const commandPath = path.join(fixture.homeDirectory, '.local', 'bin', 'deepchat') - await fixture.service.setInstalled(true) + await fixture.service.ensureInstalled() await rm(commandPath) await expect(fixture.service.getStatus()).resolves.toMatchObject({ state: 'needs-repair', - reason: 'command-missing', - owned: true + reason: 'command-missing' }) - await fixture.service.reconcileOwnedLauncher() - await expect(lstat(commandPath)).rejects.toMatchObject({ code: 'ENOENT' }) - - await expect(fixture.service.setInstalled(true)).resolves.toMatchObject({ state: 'installed' }) + await expect(fixture.service.ensureInstalled()).resolves.toMatchObject({ state: 'installed' }) expect((await lstat(commandPath)).isSymbolicLink()).toBe(true) }) it('refreshes only a stale launcher whose previous target is still owned', async () => { const fixture = await createFixture() const commandPath = path.join(fixture.homeDirectory, '.local', 'bin', 'deepchat') - await fixture.service.setInstalled(true) + await fixture.service.ensureInstalled() const nextCliDirectory = path.join(fixture.root, 'cli-v2') await mkdir(nextCliDirectory) await writeFile(path.join(nextCliDirectory, 'deepchat'), '#!/bin/sh\n', { mode: 0o755 }) @@ -224,7 +256,7 @@ describe('CliLauncherService', () => { state: 'stale', reason: 'upgrade-required' }) - await fixture.service.reconcileOwnedLauncher() + await fixture.service.ensureInstalled() expect(path.resolve(path.dirname(commandPath), await readlink(commandPath))).toBe( path.join(nextCliDirectory, 'deepchat') ) @@ -240,7 +272,7 @@ describe('CliLauncherService', () => { 'deepchat.cmd' ) - await expect(fixture.service.setInstalled(true)).resolves.toMatchObject({ + await expect(fixture.service.ensureInstalled()).resolves.toMatchObject({ state: 'installed', commandPath, shellConfigPath: null @@ -254,12 +286,12 @@ describe('CliLauncherService', () => { await writeFile(path.join(nextCliDirectory, 'deepchat.mjs'), 'console.log("v2")\n') fixture.setCliDirectory(nextCliDirectory) await expect(fixture.service.getStatus()).resolves.toMatchObject({ state: 'stale' }) - await fixture.service.reconcileOwnedLauncher() + await fixture.service.ensureInstalled() expect(await readFile(commandPath, 'utf8')).toContain( `set "cli_module=${path.join(nextCliDirectory, 'deepchat.mjs')}"` ) - await expect(fixture.service.setInstalled(false)).resolves.toMatchObject({ + await expect(fixture.service.removeOwnedLauncher()).resolves.toMatchObject({ state: 'not-installed' }) await expect(lstat(commandPath)).rejects.toMatchObject({ code: 'ENOENT' }) @@ -280,7 +312,7 @@ describe('CliLauncherService', () => { state: 'unavailable', reason: 'path-unavailable' }) - await expect(service.setInstalled(true)).rejects.toThrow('not available on PATH') + await expect(service.ensureInstalled()).rejects.toThrow('not available on PATH') }) it('reports an unavailable source without creating installation state', async () => { @@ -289,45 +321,41 @@ describe('CliLauncherService', () => { await expect(fixture.service.getStatus()).resolves.toMatchObject({ state: 'unavailable', - reason: 'source-missing', - owned: false + reason: 'source-missing' }) - await expect(fixture.service.setInstalled(true)).rejects.toThrow('unavailable') + await expect(fixture.service.ensureInstalled()).rejects.toThrow('unavailable') }) it('can remove owned integration after the packaged source disappears', async () => { const fixture = await createFixture() const commandPath = path.join(fixture.homeDirectory, '.local', 'bin', 'deepchat') const profilePath = path.join(fixture.homeDirectory, '.zprofile') - await fixture.service.setInstalled(true) + await fixture.service.ensureInstalled() fixture.setCliDirectory(null) await expect(fixture.service.getStatus()).resolves.toMatchObject({ state: 'unavailable', - reason: 'source-missing', - owned: true + reason: 'source-missing' }) - await expect(fixture.service.setInstalled(false)).resolves.toMatchObject({ + await expect(fixture.service.removeOwnedLauncher()).resolves.toMatchObject({ state: 'unavailable', - reason: 'source-missing', - owned: false + reason: 'source-missing' }) await expect(lstat(commandPath)).rejects.toMatchObject({ code: 'ENOENT' }) await expect(lstat(profilePath)).rejects.toMatchObject({ code: 'ENOENT' }) }) - it('does not offer repair when owned files and the packaged source are both missing', async () => { + it('prioritizes a missing packaged source over missing owned files', async () => { const fixture = await createFixture() const commandPath = path.join(fixture.homeDirectory, '.local', 'bin', 'deepchat') - await fixture.service.setInstalled(true) + await fixture.service.ensureInstalled() await rm(commandPath) fixture.setCliDirectory(null) await expect(fixture.service.getStatus()).resolves.toMatchObject({ state: 'unavailable', - reason: 'source-missing', - owned: true + reason: 'source-missing' }) }) }) diff --git a/test/main/cli/server.test.ts b/test/main/cli/server.test.ts index 9d23afe6a..ab6a39d79 100644 --- a/test/main/cli/server.test.ts +++ b/test/main/cli/server.test.ts @@ -20,7 +20,7 @@ import { type LocalControlUploadBinding } from '@shared/contracts/localControl' import { createCliRoutes } from '@/cli/routes' -import { CliServer, type CliUploadedInputFile } from '@/cli/server' +import { CliServer, type CliServerDependencies, type CliUploadedInputFile } from '@/cli/server' import { AgentCliTokenAuthority, type AgentCliRequestBeginResult, @@ -235,6 +235,7 @@ async function createTestServer( contexts?: readonly (Readonly<{ runId?: string; cursor?: string }> | undefined)[] result: unknown }> + dispatchStream?: NonNullable surface?: ReadonlyMap dispatchUpload?: ( method: string, @@ -274,27 +275,29 @@ async function createTestServer( userDataPath, appVersion: '1.2.3', dispatch, - ...(options.streamOutput - ? { - dispatchStream: async ( - method: string, - _input: unknown, - _caller: CliRouteCaller, - _requestId: string, - _signal: AbortSignal, - emit: ( - event: string, - data: JsonValue, - context?: Readonly<{ runId?: string; cursor?: string }> - ) => Promise - ) => { - for (const [index, event] of (options.streamOutput?.events ?? []).entries()) { - await emit(method, event, options.streamOutput?.contexts?.[index]) + ...(options.dispatchStream + ? { dispatchStream: options.dispatchStream } + : options.streamOutput + ? { + dispatchStream: async ( + method: string, + _input: unknown, + _caller: CliRouteCaller, + _requestId: string, + _signal: AbortSignal, + emit: ( + event: string, + data: JsonValue, + context?: Readonly<{ runId?: string; cursor?: string }> + ) => Promise + ) => { + for (const [index, event] of (options.streamOutput?.events ?? []).entries()) { + await emit(method, event, options.streamOutput?.contexts?.[index]) + } + return options.streamOutput?.result } - return options.streamOutput?.result } - } - : {}), + : {}), beginAgentRequest: options.beginAgentRequest, dispatchUpload, ...(options.authorize ? { authorize } : {}), @@ -365,6 +368,40 @@ describe('CLI local transport', () => { } }) + it('terminates active CLI streams when the desktop server stops', async () => { + const dispatchStream = vi.fn(async () => await new Promise(() => undefined)) + const { server, descriptor } = await createTestServer({ dispatchStream }) + const response = invokeLocalControlStream( + { + descriptor, + token: descriptor.token, + id: 'request-stream-shutdown', + method: 'models.invoke', + params: { + providerId: 'provider-1', + modelId: 'model-1', + messages: [{ role: 'user', content: 'wait' }] + }, + signal: new AbortController().signal + }, + async () => undefined + ) + await vi.waitFor(() => expect(dispatchStream).toHaveBeenCalledOnce()) + + await server.stop() + + await expect(response).resolves.toMatchObject({ + ok: false, + error: { code: 'unavailable', retriable: true } + }) + expect(server.getStatus()).toMatchObject({ + running: false, + activeConnections: 0, + pendingRequests: 0, + descriptorReady: false + }) + }) + it('fails closed on invalid authentication without dispatching', async () => { const { descriptor, dispatch } = await createTestServer() diff --git a/test/renderer/api/cliClient.test.ts b/test/renderer/api/cliClient.test.ts deleted file mode 100644 index 67888f756..000000000 --- a/test/renderer/api/cliClient.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { DeepchatBridge } from '@shared/contracts/bridge' -import type { CliLauncherStatus } from '@shared/contracts/routes' -import { createCliClient } from '../../../src/renderer/api/CliClient' - -const installedStatus = { - state: 'installed', - reason: null, - owned: true, - commandPath: '/home/user/.local/bin/deepchat', - shellConfigPath: '/home/user/.zprofile' -} satisfies CliLauncherStatus - -describe('CliClient', () => { - it('invokes the typed launcher routes with exact inputs', async () => { - const invoke = vi - .fn() - .mockResolvedValueOnce(installedStatus) - .mockResolvedValueOnce({ ...installedStatus, state: 'not-installed', owned: false }) - const bridge: DeepchatBridge = { - invoke, - on: vi.fn(() => () => undefined) - } - const client = createCliClient(bridge) - - await expect(client.getLauncherStatus()).resolves.toEqual(installedStatus) - await expect(client.setLauncherInstalled(false)).resolves.toMatchObject({ - state: 'not-installed', - owned: false - }) - expect(invoke).toHaveBeenNthCalledWith(1, 'cli.getLauncherStatus', {}) - expect(invoke).toHaveBeenNthCalledWith(2, 'cli.setLauncherInstalled', { - installed: false - }) - }) -}) diff --git a/test/renderer/components/CliLauncherSettingsSection.test.ts b/test/renderer/components/CliLauncherSettingsSection.test.ts deleted file mode 100644 index 62b68f5b6..000000000 --- a/test/renderer/components/CliLauncherSettingsSection.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { defineComponent } from 'vue' -import { flushPromises, mount } from '@vue/test-utils' -import type { CliLauncherStatus } from '@shared/contracts/routes' - -const installedStatus = { - state: 'installed', - reason: null, - owned: true, - commandPath: '/home/user/.local/bin/deepchat', - shellConfigPath: '/home/user/.zprofile' -} satisfies CliLauncherStatus - -const switchStub = defineComponent({ - name: 'Switch', - inheritAttrs: false, - props: { - modelValue: Boolean, - disabled: Boolean - }, - emits: ['update:model-value'], - template: - '' -}) - -async function setup(initialStatus: CliLauncherStatus | Error) { - vi.resetModules() - const cliClient = { - getLauncherStatus: - initialStatus instanceof Error - ? vi.fn().mockRejectedValue(initialStatus) - : vi.fn().mockResolvedValue(initialStatus), - setLauncherInstalled: vi.fn() - } - vi.doMock('@api/CliClient', () => ({ createCliClient: () => cliClient })) - vi.doMock('vue-i18n', () => ({ - useI18n: () => ({ t: (key: string) => key }) - })) - - const CliLauncherSettingsSection = ( - await import('../../../src/renderer/settings/components/common/CliLauncherSettingsSection.vue') - ).default - const wrapper = mount(CliLauncherSettingsSection, { - global: { - stubs: { - Icon: true, - Button: buttonStub, - Switch: switchStub - } - } - }) - await flushPromises() - return { wrapper, cliClient } -} - -describe('CliLauncherSettingsSection', () => { - it('shows an installed owned launcher and its command path', async () => { - const { wrapper } = await setup(installedStatus) - - expect(wrapper.get('[data-testid="cli-launcher-status"]').text()).toBe('common.enabled') - expect(wrapper.get('[data-testid="cli-launcher-switch"]').attributes('data-model-value')).toBe( - 'true' - ) - expect(wrapper.text()).toContain(installedStatus.commandPath) - }) - - it('installs an unowned launcher from the disabled state', async () => { - const notInstalled: CliLauncherStatus = { - state: 'not-installed', - reason: null, - owned: false, - commandPath: '/home/user/.local/bin/deepchat', - shellConfigPath: null - } - const { wrapper, cliClient } = await setup(notInstalled) - cliClient.setLauncherInstalled.mockResolvedValue(installedStatus) - - await wrapper.get('[data-testid="cli-launcher-switch"]').trigger('click') - await flushPromises() - - expect(cliClient.setLauncherInstalled).toHaveBeenCalledWith(true) - expect(wrapper.get('[data-testid="cli-launcher-switch"]').attributes('data-model-value')).toBe( - 'true' - ) - }) - - it('allows removing an owned launcher when its bundled source is unavailable', async () => { - const unavailable: CliLauncherStatus = { - ...installedStatus, - state: 'unavailable', - reason: 'source-missing' - } - const removed: CliLauncherStatus = { - ...unavailable, - owned: false - } - const { wrapper, cliClient } = await setup(unavailable) - cliClient.setLauncherInstalled.mockResolvedValue(removed) - - await wrapper.get('[data-testid="cli-launcher-switch"]').trigger('click') - await flushPromises() - - expect(cliClient.setLauncherInstalled).toHaveBeenCalledWith(false) - expect(wrapper.get('[data-testid="cli-launcher-status"]').text()).toBe('common.disabled') - }) - - it('repairs owned stale launchers but disables conflicting integration', async () => { - const stale: CliLauncherStatus = { - ...installedStatus, - state: 'stale', - reason: 'upgrade-required' - } - const staleSetup = await setup(stale) - staleSetup.cliClient.setLauncherInstalled.mockResolvedValue(installedStatus) - - await staleSetup.wrapper.get('[data-testid="cli-launcher-repair"]').trigger('click') - await flushPromises() - expect(staleSetup.cliClient.setLauncherInstalled).toHaveBeenCalledWith(true) - - const conflictSetup = await setup({ - ...installedStatus, - state: 'conflict', - reason: 'unowned-command', - owned: false - }) - expect( - conflictSetup.wrapper.get('[data-testid="cli-launcher-switch"]').attributes('disabled') - ).toBeDefined() - expect(conflictSetup.wrapper.get('[data-testid="cli-launcher-status"]').text()).toBe( - 'common.error.operationFailed' - ) - }) - - it('reports initial failures and reconciles an ambiguous mutation response', async () => { - const initialFailure = await setup(new Error('unavailable')) - expect(initialFailure.wrapper.get('[role="alert"]').text()).toBe( - 'common.notifications.actionFailed' - ) - expect(initialFailure.wrapper.get('[data-testid="cli-launcher-status"]').text()).toBe( - 'common.error.operationFailed' - ) - - const mutationFailure = await setup(installedStatus) - const removed: CliLauncherStatus = { - state: 'not-installed', - reason: null, - owned: false, - commandPath: installedStatus.commandPath, - shellConfigPath: null - } - mutationFailure.cliClient.setLauncherInstalled.mockRejectedValue(new Error('denied')) - mutationFailure.cliClient.getLauncherStatus.mockResolvedValueOnce(removed) - await mutationFailure.wrapper.get('[data-testid="cli-launcher-switch"]').trigger('click') - await flushPromises() - expect(mutationFailure.cliClient.getLauncherStatus).toHaveBeenCalledTimes(2) - expect(mutationFailure.wrapper.find('[role="alert"]').exists()).toBe(false) - expect( - mutationFailure.wrapper - .get('[data-testid="cli-launcher-switch"]') - .attributes('data-model-value') - ).toBe('false') - }) - - it('keeps an error visible when mutation reconciliation does not reach the target', async () => { - const { wrapper, cliClient } = await setup(installedStatus) - cliClient.setLauncherInstalled.mockRejectedValue(new Error('denied')) - - await wrapper.get('[data-testid="cli-launcher-switch"]').trigger('click') - await flushPromises() - - expect(wrapper.get('[role="alert"]').text()).toBe('common.notifications.actionFailed') - expect(wrapper.get('[data-testid="cli-launcher-switch"]').attributes('data-model-value')).toBe( - 'true' - ) - }) -}) From 153e928a4aa5ced7f3600de2e41d628f1f106fc9 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 18:48:19 +0800 Subject: [PATCH 31/51] test(cli): add bundled lifecycle smoke --- .../architecture/local-control-plane/tasks.md | 2 +- test/main/cli/packagedSmoke.test.ts | 262 ++++++++++++++++++ test/main/scripts/buildCli.test.ts | 23 +- 3 files changed, 282 insertions(+), 5 deletions(-) create mode 100644 test/main/cli/packagedSmoke.test.ts diff --git a/docs/architecture/local-control-plane/tasks.md b/docs/architecture/local-control-plane/tasks.md index a1b606e86..6db866816 100644 --- a/docs/architecture/local-control-plane/tasks.md +++ b/docs/architecture/local-control-plane/tasks.md @@ -81,7 +81,7 @@ - [ ] Keep `deepchat` out of `SAFE_COMMANDS`, enforce domain/verb-first grammar, and deny Agent artifact-byte/output-path access. - [ ] Add the bundled CLI Skill without exposing the human descriptor. -- [ ] Add packaged diagnostics/compute/artifact/OCR/Agent-policy smoke coverage. +- [x] Add bundled diagnostics/compute/artifact/OCR/Agent-policy and desktop-shutdown smoke coverage. ## Validation and Delivery diff --git a/test/main/cli/packagedSmoke.test.ts b/test/main/cli/packagedSmoke.test.ts new file mode 100644 index 000000000..527289e0c --- /dev/null +++ b/test/main/cli/packagedSmoke.test.ts @@ -0,0 +1,262 @@ +import { execFile } from 'node:child_process' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { promisify } from 'node:util' +import { + LOCAL_CONTROL_AGENT_TOKEN_ENV, + LOCAL_CONTROL_PROTOCOL_VERSION, + LOCAL_CONTROL_SCOPES, + LOCAL_CONTROL_SURFACE_VERSION, + LocalControlRpcResponseSchema, + LocalControlStreamRecordSchema +} from '@shared/contracts/localControl' +import { + artifactsDescribeRoute, + cliVersionRoute, + modelsInvokeRoute, + ocrExtractUploadRoute +} from '@shared/contracts/routes' +import { ArtifactSpool } from '@/cli/artifactSpool' +import { CliServer, type CliServerDependencies } from '@/cli/server' +import type { CliRouteCaller } from '@/routes/routeRegistry' +import { buildCli } from '../../../scripts/build-cli.mjs' + +const execFileAsync = promisify(execFile) +const CLI_PROCESS_TIMEOUT_MS = 5_000 + +function cliEnvironment( + userDataPath: string, + overrides: NodeJS.ProcessEnv = {} +): NodeJS.ProcessEnv { + const environment = { + ...process.env, + DEEPCHAT_E2E_USER_DATA_DIR: userDataPath, + ...overrides + } + if (!Object.prototype.hasOwnProperty.call(overrides, LOCAL_CONTROL_AGENT_TOKEN_ENV)) { + delete environment[LOCAL_CONTROL_AGENT_TOKEN_ENV] + } + return environment +} + +describe('packaged CLI smoke', () => { + it('covers diagnostics, compute, artifacts, OCR, Agent policy, and desktop shutdown', async () => { + const temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), 'deepchat-cli-smoke-')) + const outputDirectory = path.join(temporaryDirectory, 'cli') + const entryPath = path.join(outputDirectory, 'deepchat.mjs') + const userDataPath = path.join(temporaryDirectory, 'profile') + const spool = new ArtifactSpool({ + directory: path.join(temporaryDirectory, 'artifacts'), + cleanupIntervalMs: 60_000 + }) + let server: CliServer | undefined + + try { + await buildCli({ outDir: outputDirectory, logLevel: 'silent' }) + const seedCaller: CliRouteCaller = { + kind: 'cli', + principal: 'human', + connectionId: 'smoke-seed', + scopes: LOCAL_CONTROL_SCOPES + } + const artifactBytes = Buffer.from('packaged artifact smoke\n') + const artifact = await spool.write({ + caller: seedCaller, + requestId: 'packaged-smoke-artifact', + mimeType: 'text/plain', + suggestedFilename: 'smoke.txt', + data: artifactBytes + }) + const ocrInput = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + const ocrInputPath = path.join(temporaryDirectory, 'smoke.png') + const artifactOutputPath = path.join(temporaryDirectory, 'artifact-output.txt') + await writeFile(ocrInputPath, ocrInput) + + let resolveShutdownDispatch!: () => void + const shutdownDispatchStarted = new Promise((resolve) => { + resolveShutdownDispatch = resolve + }) + const dispatchStream: NonNullable = async ( + method, + input, + _caller, + _requestId, + _signal, + emit + ) => { + if (method !== modelsInvokeRoute.name) throw new Error(`Unexpected stream route: ${method}`) + const parsedInput = modelsInvokeRoute.input.parse(input) + if (parsedInput.messages.at(-1)?.content === 'wait-for-shutdown') { + resolveShutdownDispatch() + return await new Promise(() => undefined) + } + await emit(method, { type: 'text_delta', text: 'fixture reply' }) + await emit(method, { type: 'stop', reason: 'complete' }) + return { + providerId: parsedInput.providerId, + modelId: parsedInput.modelId, + text: 'fixture reply', + finishReason: 'complete', + durationMs: 5, + ttftMs: 1 + } + } + server = new CliServer({ + userDataPath, + appVersion: 'packaged-smoke', + artifactSpool: spool, + dispatch: async (method, input, caller) => { + if (method === cliVersionRoute.name) { + return { + appVersion: 'packaged-smoke', + protocolVersion: LOCAL_CONTROL_PROTOCOL_VERSION, + surfaceVersion: LOCAL_CONTROL_SURFACE_VERSION + } + } + if (method === artifactsDescribeRoute.name) { + const parsedInput = artifactsDescribeRoute.input.parse(input) + return { artifact: await spool.describe(parsedInput.id, caller) } + } + throw new Error(`Unexpected RPC route: ${method}`) + }, + dispatchStream, + dispatchUpload: async (method, input, upload) => { + if (method !== ocrExtractUploadRoute.name) { + throw new Error(`Unexpected upload route: ${method}`) + } + expect(ocrExtractUploadRoute.input.parse(input)).toMatchObject({ + mimeType: 'image/png', + backend: 'auto' + }) + expect(await readFile(upload.path)).toEqual(ocrInput) + return { + kind: 'image', + mimeType: 'image/png', + text: 'fixture OCR text', + tokenCount: 3, + truncated: false, + engine: { + coreVersion: 'smoke', + modelBundleId: 'smoke-bundle', + requestedBackend: 'auto', + strategy: 'bounded-960', + detection: { providerChain: ['cpu'], precision: 'fp32' }, + recognition: { providerChain: ['cpu'], precision: 'fp32' } + }, + cacheHit: false, + benchmark: { + state: 'miss-warm', + runtimeStateBefore: 'ready', + runtimeWasReady: true, + inputBytes: ocrInput.length, + durationMs: 4, + appVersion: 'packaged-smoke', + protocolVersion: LOCAL_CONTROL_PROTOCOL_VERSION, + surfaceVersion: LOCAL_CONTROL_SURFACE_VERSION + }, + imageWidth: 1, + imageHeight: 1, + strategy: 'bounded-960', + timingMs: { snapshot: 1, preprocessing: 1, recognition: 2, total: 4 } + } + } + }) + const environment = cliEnvironment(userDataPath) + const runPackagedCli = async (args: readonly string[], env = environment) => + await execFileAsync(process.execPath, [entryPath, ...args], { + env, + timeout: CLI_PROCESS_TIMEOUT_MS + }) + + await server.start() + + const version = await runPackagedCli(['system', 'version', '--json']) + expect(LocalControlRpcResponseSchema.parse(JSON.parse(version.stdout))).toMatchObject({ + ok: true, + result: { appVersion: 'packaged-smoke' } + }) + + const model = await runPackagedCli([ + 'model', + 'invoke', + '--provider', + 'fixture-provider', + '--model', + 'fixture-model', + '--prompt', + 'hello', + '--jsonl' + ]) + const modelRecords = model.stdout + .trimEnd() + .split('\n') + .map((line) => LocalControlStreamRecordSchema.parse(JSON.parse(line))) + expect(modelRecords).toHaveLength(3) + expect(modelRecords[0]).toMatchObject({ + event: modelsInvokeRoute.name, + data: { type: 'text_delta', text: 'fixture reply' } + }) + expect(modelRecords.at(-1)).toMatchObject({ + ok: true, + result: { text: 'fixture reply' } + }) + + const artifactResult = await runPackagedCli([ + 'artifact', + 'get', + '--id', + artifact.id, + '--out', + artifactOutputPath, + '--json' + ]) + expect(LocalControlRpcResponseSchema.parse(JSON.parse(artifactResult.stdout))).toMatchObject({ + ok: true, + result: { artifact: { id: artifact.id } } + }) + expect(await readFile(artifactOutputPath)).toEqual(artifactBytes) + + const ocr = await runPackagedCli(['ocr', 'extract', '--file', ocrInputPath, '--json']) + expect(LocalControlRpcResponseSchema.parse(JSON.parse(ocr.stdout))).toMatchObject({ + ok: true, + result: { + text: 'fixture OCR text', + benchmark: { state: 'miss-warm', runtimeWasReady: true } + } + }) + + const agentEnvironment = cliEnvironment(userDataPath, { + [LOCAL_CONTROL_AGENT_TOKEN_ENV]: 'a'.repeat(43) + }) + await expect( + runPackagedCli(['ocr', 'extract', '--file', ocrInputPath, '--json'], agentEnvironment) + ).rejects.toMatchObject({ + code: 4, + stdout: expect.stringContaining('"code":"permission_denied"') + }) + + const activeCli = runPackagedCli([ + 'model', + 'invoke', + '--provider', + 'fixture-provider', + '--model', + 'fixture-model', + '--prompt', + 'wait-for-shutdown', + '--jsonl' + ]) + await shutdownDispatchStarted + await server.stop() + await expect(activeCli).rejects.toMatchObject({ + code: 3, + stdout: expect.stringContaining('"code":"unavailable"') + }) + } finally { + await server?.stop() + await spool.close() + await rm(temporaryDirectory, { recursive: true }) + } + }) +}) diff --git a/test/main/scripts/buildCli.test.ts b/test/main/scripts/buildCli.test.ts index f63baaf8e..bd00f6bda 100644 --- a/test/main/scripts/buildCli.test.ts +++ b/test/main/scripts/buildCli.test.ts @@ -13,6 +13,24 @@ import { const execFileAsync = promisify(execFile) +async function runGeneratedLauncher(outputDirectory: string) { + const environment = { + ...process.env, + PATH: [path.dirname(process.execPath), process.env.PATH].filter(Boolean).join(path.delimiter) + } + if (process.platform === 'win32') { + const launcherPath = path.join(outputDirectory, 'deepchat.cmd') + return await execFileAsync( + process.env.ComSpec ?? 'cmd.exe', + ['/d', '/s', '/c', `"${launcherPath}" help commands`], + { env: environment } + ) + } + return await execFileAsync(path.join(outputDirectory, 'deepchat'), ['help', 'commands'], { + env: environment + }) +} + describe('CLI bundle', () => { it('builds a standalone Node entry and explicit bundled-runtime launchers', async () => { const outputDirectory = await mkdtemp(path.join(os.tmpdir(), 'deepchat-cli-build-')) @@ -21,10 +39,7 @@ describe('CLI bundle', () => { const entryPath = path.join(outputDirectory, 'deepchat.mjs') const source = await readFile(entryPath, 'utf8') const result = await execFileAsync(process.execPath, [entryPath, 'help', 'commands']) - const launcherResult = await execFileAsync(path.join(outputDirectory, 'deepchat'), [ - 'help', - 'commands' - ]) + const launcherResult = await runGeneratedLauncher(outputDirectory) expect(source.startsWith('#!/usr/bin/env node')).toBe(true) expect(source).not.toMatch(/from\s+["']zod["']/) From a87a1c8193cf02073cc587d5903006c63e528278 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 19:33:32 +0800 Subject: [PATCH 32/51] fix(cli): constrain agent admin access --- docs/architecture/local-control-plane/plan.md | 3 +- docs/architecture/local-control-plane/spec.md | 13 +- resources/skills/deepchat-cli/SKILL.md | 30 ++-- src/main/cli/mcpAdminRoutes.ts | 26 +++- src/main/cli/policy.ts | 18 +-- src/main/cli/skillService.ts | 2 +- src/main/cli/surface.ts | 144 +++++++++++++++-- test/main/cli/mcpAdminRoutes.test.ts | 22 ++- test/main/cli/policy.test.ts | 125 ++++++++++++++- test/main/cli/skillService.test.ts | 15 +- test/main/cli/surface.test.ts | 145 ++++++++++++++++-- 11 files changed, 470 insertions(+), 73 deletions(-) diff --git a/docs/architecture/local-control-plane/plan.md b/docs/architecture/local-control-plane/plan.md index 1580b1287..65fa1627b 100644 --- a/docs/architecture/local-control-plane/plan.md +++ b/docs/architecture/local-control-plane/plan.md @@ -53,7 +53,8 @@ 5. Expose redacted provider/model reads and separated configuration/credential mutations. 6. Expose reviewed Skill list/enable/install/uninstall adapters without arbitrary Agent paths. 7. Expose reviewed MCP list/add/update/remove/enable/start/stop adapters without raw tool calls or - secret-bearing output. + secret-bearing output. Agent access is limited to redacted list and a bounded, fully reviewable, + disabled HTTPS remote add; updates remain human-only because they can restart a running server. 8. Add policy-matrix, approval-state, redaction, compatibility, and administration tests. ## Stage D: Typed Events and Detached Agent Runs diff --git a/docs/architecture/local-control-plane/spec.md b/docs/architecture/local-control-plane/spec.md index 9ad7948b2..d8652fd01 100644 --- a/docs/architecture/local-control-plane/spec.md +++ b/docs/architecture/local-control-plane/spec.md @@ -245,7 +245,7 @@ confirmation flag. | 7. Settings | `settings.getPublic`, `settings.updatePublic`; `deepchat settings …` | read or key-derived mutation | H; scoped A for allowlisted keys | policy by effect | redacted JSON | | 8. Provider/model administration | `providers.listPublic`, `providers.testPublicConnection`, `providers.addPublic`, `providers.updatePublic`, `providers.remove`, `providers.setCredential`, `models.listRuntime`, `models.setStatus`, `models.getPublicConfig`, `models.setPublicConfig`, `models.resetConfig`; `deepchat provider …`, `deepchat model config …` | read / execution-config / credential / destructive | H; A is read-only | policy for mutations | redacted JSON | | 9. Skills | `skills.listPublic`, `skills.setPublicStatus`, `skills.installPublicUrl`, `skills.installUpload`, `skills.uninstallPublic`; `deepchat skill …` | read / execution-config / supply-chain / destructive | H; scoped A may request allowlisted mutations | policy for mutations | JSON | -| 10. MCP | `mcp.listPublic`, `mcp.addPublic`, `mcp.updatePublic`, `mcp.removePublic`, `mcp.setPublicStatus`, `mcp.startPublic`, `mcp.stopPublic`; `deepchat mcp …` | read / execution-config / security-config / supply-chain / credential / destructive | H; scoped A may request allowlisted non-credential mutations | policy for mutations | redacted JSON/events | +| 10. MCP | `mcp.listPublic`, `mcp.addPublic`, `mcp.updatePublic`, `mcp.removePublic`, `mcp.setPublicStatus`, `mcp.startPublic`, `mcp.stopPublic`; `deepchat mcp …` | read / execution-config / security-config / supply-chain / credential / destructive | H; scoped A may list and request one reviewed disabled non-credential add | policy for mutations | redacted JSON/events | | 11. Runs, events, artifacts | `runs.get`, `runs.cancel`, `events.subscribe`, `artifacts.describe`, `artifacts.read`, `artifacts.delete`; `deepchat run …` | read / local-maintenance | H owns all; A may inspect/pass owned IDs but cannot read bytes, delete, or cancel unrelated work | never | JSONL or binary artifact for H; metadata for A | | 12. CLI diagnostics | `cli.status`, `cli.version`, `cli.capabilities`, `cli.doctor`; top-level commands | read | H, A | never | stable JSON/text | | 13. Benchmark automation | client-side stable modes over compute methods; `--json`, `--jsonl`, stdin, timeout, cancel | inherited | H, scoped A | inherited | reproducible result envelope | @@ -331,6 +331,17 @@ Authorization is `approvalPolicy(effect, caller, operation)`, not `isWrite`. Per-invocation provider/model selection is compute input, not an execution-config mutation. Benchmark harnesses must use those per-call fields rather than changing global defaults. +Agent Skill installation accepts only a query-free HTTPS URL with no embedded credentials or +fragment. Human CLI may still use a signed URL; its query remains bound to the approval arguments but +is represented only as `queryPresent` in display and audit projections. + +An Agent MCP add is limited to an unauthenticated HTTPS remote endpoint, always stores the server +disabled, and requires a bounded, complete renderer view of its endpoint and public metadata. Agent +input rejects stdio commands, headers, authorization bindings, non-HTTPS endpoints, and +configurations larger than the approval UI can safely review. MCP update remains human-only because +the current MCP service immediately restarts a running server after any update; treating it as a +passive configuration mutation would hide an execution side effect. + Every policy decision is audited with timestamp, caller kind, connection/conversation scope, operation, effect, outcome, request ID, and redacted argument hash. Tokens, secrets, raw prompts, uploaded bytes, and full generated output are not audit fields. diff --git a/resources/skills/deepchat-cli/SKILL.md b/resources/skills/deepchat-cli/SKILL.md index 6d7bb8814..c00ded94f 100644 --- a/resources/skills/deepchat-cli/SKILL.md +++ b/resources/skills/deepchat-cli/SKILL.md @@ -79,25 +79,23 @@ deepchat skill list --json deepchat mcp list --json ``` -Only perform a mutation when it directly satisfies the user's request. Supported examples include: +Agent callers may request renderer approval for preference-only settings, query-free HTTPS Skill +installation, and adding a new disabled HTTPS remote MCP configuration. Only perform one when it +directly satisfies the user's request: ```text deepchat settings set --key --value --json -deepchat model enable --provider --model --json -deepchat model disable --provider --model --json -deepchat skill enable --name --json -deepchat skill disable --name --json -deepchat skill remove --name --json -deepchat mcp enable --name --json -deepchat mcp disable --name --json -deepchat mcp start --name --json -deepchat mcp stop --name --json -deepchat mcp remove --name --json +deepchat skill install --url --json +deepchat mcp add --name --stdin --json ``` -Credential writes, local Skill archives, and MCP JSON installation require stdin or local-file input -and are intentionally unavailable through Agent shell execution. Ask the user to complete those -operations through the DeepChat UI or a human terminal. +The Agent setting allowlist is limited to presentation preferences such as font size/family, +artifact effects, auto-scroll, notifications, and copy-with-reasoning. Agent Skill URLs cannot carry +credentials, query parameters, or fragments. The main process classifies MCP input before approval +and rejects stdio commands, non-HTTPS endpoints, headers, authorization bindings, or configurations +too large to review safely. Provider/model configuration, credential writes, local Skill archives, +Skill enable/disable/removal, MCP update/runtime control/removal, and every destructive operation +require the DeepChat UI or a human terminal. ## Benchmark discipline @@ -105,7 +103,7 @@ operations through the DeepChat UI or a human terminal. benchmark. - Record structured output, exit status, wall time, and errors. Preserve failed samples. - For OCR, distinguish cache hit, cache miss with warm runtime, cold runtime after app restart, and - offline availability. `ocr clear-cache` warms resources before clearing, so the next extraction is - not a cold-runtime sample. + offline availability. `ocr clear-cache` initializes the resource graph but does not start the OCR + helper, so classify the next extraction from its reported pre-extraction runtime state. - Run samples sequentially unless the benchmark explicitly measures concurrency; Agent compute is rate-limited and bounded by the main process. diff --git a/src/main/cli/mcpAdminRoutes.ts b/src/main/cli/mcpAdminRoutes.ts index f5039e73f..9b0a0c163 100644 --- a/src/main/cli/mcpAdminRoutes.ts +++ b/src/main/cli/mcpAdminRoutes.ts @@ -16,7 +16,12 @@ import { type SettingsActivityInput } from '@shared/contracts/routes' import type { MCPServerConfig, McpServicePort } from '@shared/types/mcp' -import { createRouteMap, type DeepchatRouteMap, type RouteCaller } from '@/routes/routeRegistry' +import { + createRouteMap, + type CliRouteCaller, + type DeepchatRouteMap, + type RouteCaller +} from '@/routes/routeRegistry' import { CliRequestError } from './errors' import { compareStableText, sanitizePublicText } from './publicText' @@ -41,8 +46,19 @@ export type CliMcpAdminDependencies = Readonly<{ log?: Pick }> -function requireHumanCliCaller(caller: RouteCaller): void { - if (caller.kind !== 'cli' || caller.principal !== 'human') { +function requireCliCaller(caller: RouteCaller): asserts caller is CliRouteCaller { + if (caller.kind !== 'cli') { + throw new CliRequestError('permission_denied', 'MCP administration requires a CLI caller', { + httpStatus: 403 + }) + } +} + +function requireHumanCliCaller( + caller: RouteCaller +): asserts caller is CliRouteCaller & { principal: 'human' } { + requireCliCaller(caller) + if (caller.principal !== 'human') { throw new CliRequestError( 'permission_denied', 'MCP administration requires a human CLI caller', @@ -345,7 +361,7 @@ export function createCliMcpAdminRoutes(dependencies: CliMcpAdminDependencies): [ mcpListPublicRoute.name, async (rawInput, context) => { - requireHumanCliCaller(context.caller) + requireCliCaller(context.caller) mcpListPublicRoute.input.parse(rawInput) const entries = Object.entries(await loadServers()) const selected = entries @@ -367,7 +383,7 @@ export function createCliMcpAdminRoutes(dependencies: CliMcpAdminDependencies): [ mcpAddPublicRoute.name, async (rawInput, context) => { - requireHumanCliCaller(context.caller) + requireCliCaller(context.caller) const input = mcpAddPublicRoute.input.parse(rawInput) let result: Awaited> try { diff --git a/src/main/cli/policy.ts b/src/main/cli/policy.ts index d218c1738..628fd7a00 100644 --- a/src/main/cli/policy.ts +++ b/src/main/cli/policy.ts @@ -50,7 +50,6 @@ export type CliRequestAdmission = Readonly<{ export type CliRequestPolicyOptions = Readonly<{ mutationGuard: CliMutationGuard audit(record: CliPolicyAuditRecord): void | Promise - agentApprovalOperations?: ReadonlySet agentComputeLimit?: number agentComputeStartsPerMinute?: number now?: () => number @@ -61,8 +60,7 @@ type EffectDecision = 'allow' | 'deny' | 'approval' function resolveEffectDecision( effect: LocalControlEffect, caller: CliRouteCaller, - operation: string, - agentApprovalOperations: ReadonlySet + agentPolicy: CliSurfaceEntry['agentPolicy'] ): EffectDecision { if (caller.principal === 'human') { return effect === 'read' || @@ -74,9 +72,10 @@ function resolveEffectDecision( } if (effect === 'read' || effect === 'compute') return 'allow' + if (effect === 'local-maintenance' && agentPolicy === 'allow') return 'allow' if ( (effect === 'preference-write' || effect === 'security-config' || effect === 'supply-chain') && - agentApprovalOperations.has(operation) + agentPolicy === 'approval' ) { return 'approval' } @@ -98,7 +97,6 @@ function auditProjection(entry: CliSurfaceEntry, input: unknown): JsonValue { export class CliRequestPolicy { private readonly now: () => number - private readonly agentApprovalOperations: ReadonlySet private readonly agentComputeLimit: number private readonly agentComputeStartsPerMinute: number private readonly activeAgentCompute = new Map() @@ -107,7 +105,6 @@ export class CliRequestPolicy { constructor(private readonly options: CliRequestPolicyOptions) { this.now = options.now ?? Date.now - this.agentApprovalOperations = new Set(options.agentApprovalOperations ?? []) this.agentComputeLimit = positiveInteger( options.agentComputeLimit ?? DEFAULT_AGENT_COMPUTE_LIMIT, 'agentComputeLimit' @@ -177,12 +174,7 @@ export class CliRequestPolicy { }) } - const effectDecision = resolveEffectDecision( - effect, - input.caller, - input.entry.contract.name, - this.agentApprovalOperations - ) + const effectDecision = resolveEffectDecision(effect, input.caller, input.entry.agentPolicy) if (effectDecision === 'deny') { await audit('denied') throw new CliRequestError('permission_denied', 'Operation is denied for this caller', { @@ -199,7 +191,7 @@ export class CliRequestPolicy { } let approvalRequestId: string try { - const routeDisplayData = input.entry.approvalDisplay(input.input) + const routeDisplayData = input.entry.approvalDisplay(input.input, input.caller) const approvalDisplayData = input.transportBinding !== undefined ? { request: routeDisplayData, transport: input.transportBinding } diff --git a/src/main/cli/skillService.ts b/src/main/cli/skillService.ts index 01c70f926..9ce193e9c 100644 --- a/src/main/cli/skillService.ts +++ b/src/main/cli/skillService.ts @@ -143,7 +143,7 @@ export class CliSkillService { [ skillsInstallPublicUrlRoute.name, async (rawInput, context) => { - requireHumanCliCaller(context.caller) + requireCliCaller(context.caller) const input = skillsInstallPublicUrlRoute.input.parse(rawInput) await this.requireAgent(input.agentId) let result: SkillInstallResult diff --git a/src/main/cli/surface.ts b/src/main/cli/surface.ts index 53dcfead4..0fd5c7cf7 100644 --- a/src/main/cli/surface.ts +++ b/src/main/cli/surface.ts @@ -82,12 +82,19 @@ export type CliSurfaceEntry = Readonly<{ scopes: readonly LocalControlScope[] transport: LocalControlTransport approval: LocalControlApprovalMode + // Agent mutations fail closed unless the operation explicitly opts into a narrow policy path. + agentPolicy?: 'allow' | 'approval' auditProjection?: (input: unknown) => JsonValue - approvalDisplay?: (input: unknown) => JsonValue + approvalDisplay?: ( + input: unknown, + caller: Readonly<{ principal: LocalControlPrincipal }> + ) => JsonValue agentInputAllowed?: (input: unknown) => boolean limits: CliRouteLimits }> +const AGENT_MCP_APPROVAL_MAX_REVIEW_BYTES = 16 * 1024 + const PREFERENCE_SETTING_KEYS = new Set([ 'fontSizeLevel', 'fontFamily', @@ -218,17 +225,46 @@ function skillUrlDisplay(input: unknown): JsonValue { } } -function mcpConfigProjection(input: unknown, field: 'config' | 'updates'): JsonValue { +function agentSkillUrlInputAllowed(input: unknown): boolean { + if (!input || typeof input !== 'object' || Array.isArray(input)) return false + const rawUrl = (input as Record).url + if (typeof rawUrl !== 'string') return false + try { + const url = new URL(rawUrl) + return ( + url.protocol === 'https:' && + !url.username && + !url.password && + !url.search && + !url.hash && + !containsDirectionalControl(rawUrl) + ) + } catch { + return false + } +} + +function mcpConfigProjection( + input: unknown, + field: 'config' | 'updates', + includeReviewableValues = false +): JsonValue { const config = jsonObjectField(input, field) const projection: Record = { fields: Object.keys(config).sort() } if (typeof config.type === 'string') projection.type = config.type if (typeof config.description === 'string') { - const description = sanitizePublicText(config.description, 512) - projection.description = description.value - projection.descriptionTruncated = description.truncated + if (includeReviewableValues) { + projection.description = config.description + projection.descriptionTruncated = false + } else { + const description = sanitizePublicText(config.description, 512) + projection.description = description.value + projection.descriptionTruncated = description.truncated + } } + if (includeReviewableValues && typeof config.icon === 'string') projection.icon = config.icon if (typeof config.command === 'string') { const commandName = config.command.split(/[\\/]/).at(-1) ?? '' projection.commandName = sanitizePublicText(commandName, 256).value @@ -241,7 +277,10 @@ function mcpConfigProjection(input: unknown, field: 'config' | 'updates'): JsonV if (config.headers && typeof config.headers === 'object') { projection.headers = mcpKeySummary(config.headers) } - if (typeof config.baseUrl === 'string') projection.endpoint = mcpUrlSummary(config.baseUrl) + if (typeof config.baseUrl === 'string') { + projection.endpoint = mcpUrlSummary(config.baseUrl) + if (includeReviewableValues) projection.endpointUrl = config.baseUrl + } if (typeof config.customNpmRegistry === 'string') { projection.npmRegistry = mcpUrlSummary(config.customNpmRegistry) } else if (config.customNpmRegistry === null) { @@ -258,6 +297,49 @@ function mcpConfigProjection(input: unknown, field: 'config' | 'updates'): JsonV } } +function containsDirectionalControl(value: string): boolean { + return /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u.test(value) +} + +function agentMcpAddInputAllowed(input: unknown): boolean { + const config = jsonObjectField(input, 'config') + const headers = jsonObjectField(config, 'headers') + if ( + (config.type !== 'sse' && config.type !== 'http') || + Object.keys(headers).length > 0 || + config.authorization !== undefined || + typeof config.baseUrl !== 'string' + ) { + return false + } + + let endpoint: URL + try { + endpoint = new URL(config.baseUrl) + } catch { + return false + } + if ( + endpoint.protocol !== 'https:' || + [config.baseUrl, config.description, config.icon].some( + (value) => typeof value === 'string' && containsDirectionalControl(value) + ) + ) { + return false + } + + const reviewableValues: JsonValue = { + type: config.type, + description: config.description ?? '', + icon: config.icon ?? '', + endpointUrl: config.baseUrl + } + return ( + Buffer.byteLength(JSON.stringify(reviewableValues), 'utf8') <= + AGENT_MCP_APPROVAL_MAX_REVIEW_BYTES + ) +} + function mcpConfigAudit(input: unknown, field: 'config' | 'updates'): JsonValue { const config = jsonObjectField(input, field) return { @@ -487,6 +569,7 @@ const CLI_SURFACE_V1_ENTRIES = [ scopes: ['runs:cancel'], transport: 'rpc', approval: 'never', + agentPolicy: 'allow', auditProjection: (input) => selectAuditFields(input, ['runId']), limits: RUN_CONTROL_LIMITS }, @@ -663,6 +746,7 @@ const CLI_SURFACE_V1_ENTRIES = [ scopes: ['settings:write'], transport: 'rpc', approval: 'policy', + agentPolicy: 'approval', auditProjection: (input) => ({ keys: settingChangeKeys(input) }), approvalDisplay: (input) => ({ changes: settingChangesForDisplay(input) }), agentInputAllowed: (input) => @@ -672,7 +756,7 @@ const CLI_SURFACE_V1_ENTRIES = [ { contract: skillsListPublicRoute, effect: 'read', - callers: ['human'], + callers: ['human', 'agent'], scopes: ['skills:read'], transport: 'rpc', approval: 'never', @@ -682,12 +766,14 @@ const CLI_SURFACE_V1_ENTRIES = [ { contract: skillsInstallPublicUrlRoute, effect: 'supply-chain', - callers: ['human'], + callers: ['human', 'agent'], scopes: ['skills:write'], transport: 'rpc', approval: 'policy', + agentPolicy: 'approval', auditProjection: skillUrlDisplay, approvalDisplay: skillUrlDisplay, + agentInputAllowed: agentSkillUrlInputAllowed, limits: { maxBodyBytes: 16 * 1024, timeoutMs: LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS } }, { @@ -729,7 +815,7 @@ const CLI_SURFACE_V1_ENTRIES = [ { contract: mcpListPublicRoute, effect: 'read', - callers: ['human'], + callers: ['human', 'agent'], scopes: ['mcp:read'], transport: 'rpc', approval: 'never', @@ -739,25 +825,29 @@ const CLI_SURFACE_V1_ENTRIES = [ { contract: mcpAddPublicRoute, effect: { - possible: ['supply-chain', 'credential'], + possible: ['security-config', 'supply-chain', 'credential'], resolve: (input) => { const config = jsonObjectField(input, 'config') const environment = config.environment const headers = config.headers - return (environment && - typeof environment === 'object' && - Object.keys(environment).length > 0) || + if ( + (environment && typeof environment === 'object' && Object.keys(environment).length > 0) || (headers && typeof headers === 'object' && Object.keys(headers).length > 0) - ? 'credential' - : 'supply-chain' + ) { + return 'credential' + } + return config.authorization !== undefined ? 'security-config' : 'supply-chain' } }, - callers: ['human'], + callers: ['human', 'agent'], scopes: ['mcp:write'], transport: 'rpc', approval: 'policy', + agentPolicy: 'approval', auditProjection: (input) => mcpConfigAudit(input, 'config'), - approvalDisplay: (input) => mcpConfigProjection(input, 'config'), + approvalDisplay: (input, caller) => + mcpConfigProjection(input, 'config', caller.principal === 'agent'), + agentInputAllowed: agentMcpAddInputAllowed, limits: { maxBodyBytes: PUBLIC_MCP_CONFIG_MAX_BYTES + 64 * 1024, timeoutMs: 5 * 60_000 @@ -880,6 +970,26 @@ function createSurfaceRegistry( if (registry.has(entry.contract.name)) { throw new Error(`Duplicate CLI surface method: ${entry.contract.name}`) } + if (entry.agentPolicy && !entry.callers.includes('agent')) { + throw new Error(`Invalid Agent policy surface: ${entry.contract.name}`) + } + const effects = listCliSurfaceEffects(entry) + if (entry.agentPolicy === 'allow' && effects.some((effect) => effect !== 'local-maintenance')) { + throw new Error(`Invalid Agent allow surface: ${entry.contract.name}`) + } + if ( + entry.agentPolicy === 'approval' && + (entry.approval !== 'policy' || + entry.approvalDisplay === undefined || + !effects.some( + (effect) => + effect === 'preference-write' || + effect === 'security-config' || + effect === 'supply-chain' + )) + ) { + throw new Error(`Invalid Agent approval surface: ${entry.contract.name}`) + } registry.set(entry.contract.name, entry) } return registry diff --git a/test/main/cli/mcpAdminRoutes.test.ts b/test/main/cli/mcpAdminRoutes.test.ts index 1d0aa89f3..9a5fc1c01 100644 --- a/test/main/cli/mcpAdminRoutes.test.ts +++ b/test/main/cli/mcpAdminRoutes.test.ts @@ -349,7 +349,7 @@ describe('CLI MCP administration routes', () => { ).rejects.toMatchObject({ code: 'invalid_request' }) }) - it('blocks plugin and in-memory mutations and rejects non-human callers', async () => { + it('allows policy-gated Agent adapters but keeps runtime and destructive mutations human-only', async () => { const harness = createHarness({ plugin: stdioConfig({ ownerPluginId: 'plugin-1' }), builtin: stdioConfig({ type: 'inmemory', command: '', args: [], env: {} }) @@ -377,6 +377,26 @@ describe('CLI MCP administration routes', () => { conversationId: 'conversation-1', expiresAt: Date.now() + 60_000 } + await expect( + harness.invoke(mcpListPublicRoute.name, {}, { caller: agentCaller }) + ).resolves.toMatchObject({ truncated: false }) + await expect( + harness.invoke( + mcpAddPublicRoute.name, + { + serverName: 'agent-server', + config: { type: 'http', baseUrl: 'https://mcp.example/agent' } + }, + { caller: agentCaller } + ) + ).resolves.toMatchObject({ server: { name: 'agent-server', enabled: false } }) + await expect( + harness.invoke( + mcpUpdatePublicRoute.name, + { serverName: 'agent-server', updates: { command: 'pnpm' } }, + { caller: agentCaller } + ) + ).rejects.toMatchObject({ code: 'permission_denied' }) await expect( harness.invoke(mcpRemovePublicRoute.name, { serverName: 'plugin' }, { caller: agentCaller }) ).rejects.toMatchObject({ code: 'permission_denied' }) diff --git a/test/main/cli/policy.test.ts b/test/main/cli/policy.test.ts index d8efbb264..bf97588cf 100644 --- a/test/main/cli/policy.test.ts +++ b/test/main/cli/policy.test.ts @@ -5,7 +5,7 @@ import type { LocalControlEffect } from '@shared/contracts/localControl' import type { CliRouteCaller } from '@/routes/routeRegistry' import { CliRequestPolicy, type CliPolicyAuditRecord } from '@/cli/policy' import type { CliMutationGuard } from '@/cli/mutationGuard' -import type { CliSurfaceEntry } from '@/cli/surface' +import { getCliSurfaceEntry, type CliSurfaceEntry } from '@/cli/surface' const testRoute = defineRouteContract({ name: 'settings.testMutation', @@ -30,7 +30,11 @@ const agentCaller: CliRouteCaller = { scopes: ['settings:write'] } -function entry(effect: LocalControlEffect, approval: 'never' | 'policy' = 'never') { +function entry( + effect: LocalControlEffect, + approval: 'never' | 'policy' = 'never', + agentPolicy?: CliSurfaceEntry['agentPolicy'] +) { return { contract: testRoute, effect, @@ -38,6 +42,7 @@ function entry(effect: LocalControlEffect, approval: 'never' | 'policy' = 'never scopes: ['settings:write'], transport: 'rpc', approval, + ...(agentPolicy ? { agentPolicy } : {}), auditProjection: () => ({ target: 'safe-setting' }), ...(approval === 'policy' ? { approvalDisplay: () => ({ target: 'safe-setting' }) } : {}), limits: { maxBodyBytes: 1024, timeoutMs: 5_000 } @@ -58,7 +63,6 @@ function createHarness( const policy = new CliRequestPolicy({ mutationGuard, audit: options.audit ?? ((record) => auditRecords.push(record)), - agentApprovalOperations: options.allowlisted ? new Set([testRoute.name]) : new Set(), agentComputeLimit: options.agentComputeLimit, agentComputeStartsPerMinute: options.agentComputeStartsPerMinute }) @@ -68,7 +72,7 @@ function createHarness( approval: 'never' | 'policy' = 'never' ) => policy.authorize({ - entry: entry(effect, approval), + entry: entry(effect, approval, options.allowlisted ? 'approval' : undefined), input: { secret: 'must-not-appear-in-audit' }, caller, requestId: 'request-1', @@ -117,6 +121,22 @@ describe('CliRequestPolicy', () => { expect(harness.auditRecords.map((record) => record.outcome)).toEqual(['denied', 'denied']) }) + it('allows only explicitly opted-in Agent maintenance operations', async () => { + const harness = createHarness() + + await expect( + harness.policy.authorize({ + entry: entry('local-maintenance', 'never', 'allow'), + input: {}, + caller: agentCaller, + requestId: 'request-agent-maintenance', + signal: new AbortController().signal + }) + ).resolves.toBeDefined() + expect(harness.authorize).not.toHaveBeenCalled() + expect(harness.auditRecords.at(-1)?.outcome).toBe('allowed') + }) + it('requires an explicit operation allowlist before an agent may request approval', async () => { const denied = createHarness() await expect(denied.invoke('supply-chain', agentCaller, 'policy')).rejects.toMatchObject({ @@ -126,6 +146,103 @@ describe('CliRequestPolicy', () => { const allowed = createHarness({ allowlisted: true }) await expect(allowed.invoke('supply-chain', agentCaller, 'policy')).resolves.toBeDefined() expect(allowed.authorize).toHaveBeenCalledOnce() + await expect(allowed.invoke('execution-config', agentCaller, 'policy')).rejects.toMatchObject({ + code: 'permission_denied' + }) + await expect(allowed.invoke('credential', agentCaller, 'policy')).rejects.toMatchObject({ + code: 'permission_denied' + }) + expect(allowed.authorize).toHaveBeenCalledOnce() + }) + + it('applies concrete Agent surface policies without widening denied effects', async () => { + const harness = createHarness() + const authorize = (input: { + entry: CliSurfaceEntry + params: unknown + caller: CliRouteCaller + requestId: string + }) => + harness.policy.authorize({ + entry: input.entry, + input: input.params, + caller: input.caller, + requestId: input.requestId, + signal: new AbortController().signal + }) + const settingEntry = getCliSurfaceEntry('settings.updatePublic')! + + await expect( + authorize({ + entry: settingEntry, + params: { changes: [{ key: 'fontSizeLevel', value: 3 }] }, + caller: agentCaller, + requestId: 'request-setting-preference' + }) + ).resolves.toBeDefined() + await expect( + authorize({ + entry: settingEntry, + params: { changes: [{ key: 'loggingEnabled', value: true }] }, + caller: agentCaller, + requestId: 'request-setting-security' + }) + ).rejects.toMatchObject({ code: 'permission_denied' }) + + const mcpCaller: CliRouteCaller = { ...agentCaller, scopes: ['mcp:write'] } + const mcpEntry = getCliSurfaceEntry('mcp.addPublic')! + await expect( + authorize({ + entry: mcpEntry, + params: { + serverName: 'safe-server', + config: { + type: 'http', + description: '', + icon: '', + baseUrl: 'https://mcp.example/api', + headers: {} + } + }, + caller: mcpCaller, + requestId: 'request-mcp-supply-chain' + }) + ).resolves.toBeDefined() + await expect( + authorize({ + entry: mcpEntry, + params: { + serverName: 'credential-server', + config: { + type: 'http', + baseUrl: 'https://mcp.example/api', + headers: { Authorization: 'Bearer secret' } + } + }, + caller: mcpCaller, + requestId: 'request-mcp-credential' + }) + ).rejects.toMatchObject({ code: 'permission_denied' }) + + await expect( + authorize({ + entry: getCliSurfaceEntry('mcp.updatePublic')!, + params: { serverName: 'safe-server', updates: { description: 'renamed' } }, + caller: mcpCaller, + requestId: 'request-mcp-update' + }) + ).rejects.toMatchObject({ code: 'permission_denied' }) + + const runCaller: CliRouteCaller = { ...agentCaller, scopes: ['runs:cancel'] } + await expect( + authorize({ + entry: getCliSurfaceEntry('runs.cancel')!, + params: { runId: 'conversation-1' }, + caller: runCaller, + requestId: 'request-run-cancel' + }) + ).resolves.toBeDefined() + expect(harness.authorize).toHaveBeenCalledTimes(2) }) it('fails closed when an approval effect lacks approval surface metadata', async () => { diff --git a/test/main/cli/skillService.test.ts b/test/main/cli/skillService.test.ts index 7fc17be8a..280a0833d 100644 --- a/test/main/cli/skillService.test.ts +++ b/test/main/cli/skillService.test.ts @@ -269,7 +269,7 @@ describe('CLI Skill service', () => { } }) - it('rejects Skill mutations from Agent callers', async () => { + it('allows policy-authorized Agent URL installs but rejects upload and execution mutations', async () => { const harness = createHarness() const agentCaller: CliRouteCaller = { ...caller, @@ -280,7 +280,18 @@ describe('CLI Skill service', () => { } await expect( - harness.invoke(skillsInstallPublicUrlRoute.name, {}, { caller: agentCaller }) + harness.invoke( + skillsInstallPublicUrlRoute.name, + { url: 'https://skills.example/archive.zip' }, + { caller: agentCaller } + ) + ).resolves.toMatchObject({ name: 'installed-skill', installed: true }) + await expect( + harness.invoke( + skillsSetPublicStatusRoute.name, + { name: 'safe-skill', enabled: false }, + { caller: agentCaller } + ) ).rejects.toMatchObject({ code: 'permission_denied' }) await expect( harness.service.dispatchUpload( diff --git a/test/main/cli/surface.test.ts b/test/main/cli/surface.test.ts index ad3df436e..b4ae4cf7c 100644 --- a/test/main/cli/surface.test.ts +++ b/test/main/cli/surface.test.ts @@ -7,6 +7,9 @@ import { resolveCliSurfaceEffect } from '@/cli/surface' +const humanApprovalCaller = { principal: 'human' } as const +const agentApprovalCaller = { principal: 'agent' } as const + describe('CLI surface V1', () => { it('contains only explicit canonical route contracts', () => { const methods = Array.from(CLI_SURFACE_V1.keys()).sort() @@ -75,6 +78,26 @@ describe('CLI surface V1', () => { expect(getCliSurfaceEntry('approvals.resolve')).toBeUndefined() }) + it('keeps Agent mutation policy as an explicit operation opt-in', () => { + const policies = Array.from(CLI_SURFACE_V1, ([method, entry]) => ({ method, entry })).filter( + ({ entry }) => entry.agentPolicy !== undefined + ) + + expect( + policies + .filter(({ entry }) => entry.agentPolicy === 'approval') + .map(({ method }) => method) + .sort() + ).toEqual(['mcp.addPublic', 'settings.updatePublic', 'skills.installPublicUrl']) + expect( + policies.filter(({ entry }) => entry.agentPolicy === 'allow').map(({ method }) => method) + ).toEqual(['runs.cancel']) + for (const { entry } of policies) { + expect(entry.callers).toContain('agent') + if (entry.agentPolicy === 'approval') expect(entry.approval).toBe('policy') + } + }) + it('classifies public setting changes from their validated key', () => { const entry = getCliSurfaceEntry('settings.updatePublic')! @@ -97,7 +120,10 @@ describe('CLI surface V1', () => { entry.agentInputAllowed?.({ changes: [{ key: 'privacyModeEnabled', value: true }] }) ).toBe(false) expect( - entry.approvalDisplay?.({ changes: [{ key: 'privacyModeEnabled', value: true }] }) + entry.approvalDisplay?.( + { changes: [{ key: 'privacyModeEnabled', value: true }] }, + humanApprovalCaller + ) ).toEqual({ changes: [{ key: 'privacyModeEnabled', value: true }] }) }) @@ -115,12 +141,14 @@ describe('CLI surface V1', () => { action: 'set', kind: 'api-key' }) - expect(entry.approvalDisplay?.(input)).toEqual({ + expect(entry.approvalDisplay?.(input, humanApprovalCaller)).toEqual({ providerId: 'provider-1', action: 'set', kind: 'api-key' }) - expect(JSON.stringify(entry.approvalDisplay?.(input))).not.toContain('super-secret') + expect(JSON.stringify(entry.approvalDisplay?.(input, humanApprovalCaller))).not.toContain( + 'super-secret' + ) }) it('shows safe mutation values in approvals while keeping audits structural', () => { @@ -133,7 +161,9 @@ describe('CLI surface V1', () => { providerId: 'provider-1', fields: ['baseUrl', 'enabled'] }) - expect(providerEntry.approvalDisplay?.(providerInput)).toEqual(providerInput) + expect(providerEntry.approvalDisplay?.(providerInput, humanApprovalCaller)).toEqual( + providerInput + ) const modelEntry = getCliSurfaceEntry('models.setPublicConfig')! const modelInput = { @@ -141,7 +171,7 @@ describe('CLI surface V1', () => { modelId: 'model-1', config: { maxTokens: 4096, contextLength: 32768 } } - expect(modelEntry.approvalDisplay?.(modelInput)).toEqual(modelInput) + expect(modelEntry.approvalDisplay?.(modelInput, humanApprovalCaller)).toEqual(modelInput) }) it('keeps signed Skill URL secrets out of approval and audit projections', () => { @@ -152,7 +182,7 @@ describe('CLI surface V1', () => { overwrite: true } - expect(entry.approvalDisplay?.(input)).toEqual({ + expect(entry.approvalDisplay?.(input, humanApprovalCaller)).toEqual({ agentId: 'deepchat', overwrite: true, origin: 'https://skills.example', @@ -160,6 +190,13 @@ describe('CLI surface V1', () => { queryPresent: true }) expect(JSON.stringify(entry.auditProjection?.(input))).not.toContain('private-token') + expect(entry.agentInputAllowed?.(input)).toBe(false) + expect( + entry.agentInputAllowed?.({ + ...input, + url: 'https://skills.example/archive.zip' + }) + ).toBe(true) expect(getCliSurfaceEntry('skills.setPublicStatus')?.limits.timeoutMs).toBeGreaterThanOrEqual( 2 * 60_000 ) @@ -186,7 +223,7 @@ describe('CLI surface V1', () => { } } - const approval = entry.approvalDisplay?.(input) + const approval = entry.approvalDisplay?.(input, humanApprovalCaller) const audit = entry.auditProjection?.(input) const serialized = JSON.stringify({ approval, audit }) expect(approval).toMatchObject({ @@ -217,6 +254,81 @@ describe('CLI surface V1', () => { config: { type: 'stdio', command: 'npx', environment: {} } }) ).toBe('supply-chain') + expect( + resolveCliSurfaceEffect(entry, { + serverName: 'authorized-server', + config: { + type: 'http', + baseUrl: 'https://mcp.example/api', + headers: {}, + authorization: { mode: 'interactive' } + } + }) + ).toBe('security-config') + }) + + it('limits Agent MCP additions to disabled reviewable remote configurations', () => { + const entry = getCliSurfaceEntry('mcp.addPublic')! + const input = { + serverName: 'reviewable-server', + config: { + type: 'http', + description: 'Reviewable remote server', + icon: 'cloud', + baseUrl: 'https://mcp.example/api', + headers: {} + } + } + + expect(entry.agentInputAllowed?.(input)).toBe(true) + expect(entry.approvalDisplay?.(input, agentApprovalCaller)).toMatchObject({ + serverName: 'reviewable-server', + config: { + description: 'Reviewable remote server', + icon: 'cloud', + endpointUrl: 'https://mcp.example/api' + } + }) + expect(entry.auditProjection?.(input)).not.toEqual( + expect.objectContaining({ command: expect.anything(), arguments: expect.anything() }) + ) + + expect( + entry.agentInputAllowed?.({ + ...input, + config: { ...input.config, headers: { Authorization: 'secret' } } + }) + ).toBe(false) + expect( + entry.agentInputAllowed?.({ + serverName: 'stdio-server', + config: { + type: 'stdio', + command: 'npx', + args: ['@example/mcp-server'], + environment: {}, + inheritEnv: 'minimal' + } + }) + ).toBe(false) + expect( + entry.agentInputAllowed?.({ + serverName: 'authorized-server', + config: { + type: 'http', + baseUrl: 'https://mcp.example/api', + headers: {}, + authorization: { mode: 'interactive' } + } + }) + ).toBe(false) + expect( + entry.agentInputAllowed?.({ + ...input, + config: { ...input.config, description: 'x'.repeat(16 * 1024) } + }) + ).toBe(false) + expect(getCliSurfaceEntry('mcp.updatePublic')?.callers).toEqual(['human']) }) it('gives every approval route enough server time for renderer confirmation', () => { @@ -282,11 +394,15 @@ describe('CLI surface V1', () => { }), expect.objectContaining({ method: 'mcp.addPublic', - possibleEffects: ['supply-chain', 'credential'], - callers: ['human'], + possibleEffects: ['security-config', 'supply-chain', 'credential'], + callers: ['human', 'agent'], approval: 'policy' }), - expect.objectContaining({ method: 'mcp.listPublic', possibleEffects: ['read'] }), + expect.objectContaining({ + method: 'mcp.listPublic', + possibleEffects: ['read'], + callers: ['human', 'agent'] + }), expect.objectContaining({ method: 'mcp.removePublic', possibleEffects: ['destructive'], @@ -310,6 +426,7 @@ describe('CLI surface V1', () => { expect.objectContaining({ method: 'mcp.updatePublic', possibleEffects: ['execution-config', 'security-config', 'supply-chain', 'credential'], + callers: ['human'], approval: 'policy' }), expect.objectContaining({ method: 'models.getPublicConfig', possibleEffects: ['read'] }), @@ -407,7 +524,7 @@ describe('CLI surface V1', () => { expect.objectContaining({ method: 'skills.installPublicUrl', possibleEffects: ['supply-chain'], - callers: ['human'], + callers: ['human', 'agent'], approval: 'policy' }), expect.objectContaining({ @@ -417,7 +534,11 @@ describe('CLI surface V1', () => { transport: 'upload', approval: 'policy' }), - expect.objectContaining({ method: 'skills.listPublic', possibleEffects: ['read'] }), + expect.objectContaining({ + method: 'skills.listPublic', + possibleEffects: ['read'], + callers: ['human', 'agent'] + }), expect.objectContaining({ method: 'skills.setPublicStatus', possibleEffects: ['execution-config'], From ceb1c5f26ef15d4b96c2483a38a6db939fd15186 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 19:59:04 +0800 Subject: [PATCH 33/51] docs(cli): add usage and validation guide --- docs/README.md | 4 +- .../architecture/local-control-plane/tasks.md | 49 ++-- docs/guides/cli.md | 252 ++++++++++++++++++ 3 files changed, 288 insertions(+), 17 deletions(-) create mode 100644 docs/guides/cli.md diff --git a/docs/README.md b/docs/README.md index de0ed4a61..395994908 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # DeepChat 文档索引 -本文档反映 `2026-07-30` 的当前代码。历史实施过程、已完成 issue 和一次性 SDD 通过 Git +本文档反映 `2026-08-05` 的当前代码。历史实施过程、已完成 issue 和一次性 SDD 通过 Git 历史查询,不再长期留在 `docs/`。 ## 当前必读 @@ -16,6 +16,7 @@ | [architecture/tape-system.md](./architecture/tape-system.md) | Tape、ViewManifest、回放和 Subagent lineage | | [architecture/event-system.md](./architecture/event-system.md) | typed route、typed event 和 main 内部调用规则 | | [guides/getting-started.md](./guides/getting-started.md) | 当前代码入口和本地开发命令 | +| [guides/cli.md](./guides/cli.md) | 随包 CLI 的能力、生命周期、安全边界和 benchmark 合同 | | [guides/plugin-packaging.md](./guides/plugin-packaging.md) | `.dcplugin` 打包、内置分发和 release 规则 | | [release-flow.md](./release-flow.md) | 版本、分支、tag 和平台构建流程 | | [spec-driven-dev.md](./spec-driven-dev.md) | SDD 分类、产物和清理规则 | @@ -26,6 +27,7 @@ | 文档 | 状态 | | --- | --- | +| [architecture/local-control-plane/](./architecture/local-control-plane/) | CLI V1 已实现;全量测试与生产构建通过,当前平台 unpack 受发布 runtime 下载网络阻塞 | | [features/acp-v1-reliability/](./features/acp-v1-reliability/) | ACP capability、auth、session lifecycle 与 diagnostics 待实施 | | [features/cua-cross-platform-computer-use/](./features/cua-cross-platform-computer-use/) | 已实现主体,等待 CI platform matrix 验证 | | [features/mcp-oauth-authentication/](./features/mcp-oauth-authentication/) | 已实现主体,等待真实 OAuth smoke | diff --git a/docs/architecture/local-control-plane/tasks.md b/docs/architecture/local-control-plane/tasks.md index 6db866816..47dd44657 100644 --- a/docs/architecture/local-control-plane/tasks.md +++ b/docs/architecture/local-control-plane/tasks.md @@ -14,7 +14,7 @@ ## Typed Foundation - [x] Add `RouteCaller` and migrate renderer-dependent integrations without behavior change. -- [ ] Add canonical local-control contracts and redacted public DTOs. +- [x] Add canonical local-control contracts and redacted public DTOs. - [x] Define and test the deny-by-default versioned surface registry. - [x] Add local-control error codes, request/result envelopes, and route limits. @@ -34,7 +34,7 @@ - [x] Add formal standalone speech generation and typed audio output. - [x] Add upload and owned-artifact transcription inputs. - [x] Implement output-only `ArtifactSpool` ownership, quotas, expiry, and cleanup. -- [ ] Add stream, media, speech, transcription, artifact, and quota tests. +- [x] Add stream, media, speech, transcription, artifact, and quota tests. ## OCR @@ -43,7 +43,7 @@ - [x] Enforce bounded text output and exclude layout/batch/model administration. - [x] Classify cache clear as audited human-only `local-maintenance` without approval. - [x] Report cache hit, warm-runtime miss, cold-runtime, and offline metrics accurately. -- [ ] Add OCR caller, input, cache, runtime-state, output-bound, and benchmark tests. +- [x] Add OCR caller, input, cache, runtime-state, output-bound, and benchmark tests. ## Effects and Approval @@ -76,24 +76,41 @@ - [x] Package the CLI with the bundled Node runtime on all supported targets. - [x] Add automatic, idempotent, reversible platform launcher/PATH integration with no settings toggle. -- [ ] Add in-memory scoped Agent token issuance, expiry, revocation, and quotas. -- [ ] Harden shell permission checks for redirection and compound syntax before Agent enablement. -- [ ] Keep `deepchat` out of `SAFE_COMMANDS`, enforce domain/verb-first grammar, and deny Agent +- [x] Add in-memory scoped Agent token issuance, expiry, revocation, and quotas. +- [x] Harden shell permission checks for redirection and compound syntax before Agent enablement. +- [x] Keep `deepchat` out of `SAFE_COMMANDS`, enforce domain/verb-first grammar, and deny Agent artifact-byte/output-path access. -- [ ] Add the bundled CLI Skill without exposing the human descriptor. +- [x] Add the bundled CLI Skill without exposing the human descriptor. - [x] Add bundled diagnostics/compute/artifact/OCR/Agent-policy and desktop-shutdown smoke coverage. ## Validation and Delivery -- [ ] Run focused tests after each implementation slice. -- [ ] Run format and i18n validation. -- [ ] Run lint and typecheck. -- [ ] Run the full test suite and production build. -- [ ] Run current-platform unsigned packaging and packaged smoke where prerequisites allow. -- [ ] Complete a severity-ranked review before every commit and resolve findings. -- [ ] Commit all V1 work locally with behavior-specific messages. -- [ ] Do not push. +- [x] Run focused tests after each implementation slice. +- [x] Run format and i18n validation. +- [x] Run lint and typecheck. +- [x] Run the full test suite and production build. +- [ ] Complete current-platform unsigned packaging with the pinned bundled runtimes. +- [x] Run packaged CLI smoke where local prerequisites allow. +- [x] Complete a severity-ranked review before every commit and resolve findings. +- [x] Commit all V1 work locally with behavior-specific messages. +- [x] Do not push. ## Local Validation Evidence -Not yet recorded. +Recorded on 2026-08-05 using macOS arm64, Node 24.14.1, and pnpm 10.33.4: + +- `pnpm exec vitest run --config vitest.config.ts test/main/cli`: 26 files and 262 tests + passed, including the real bundled CLI child-process smoke for desktop shutdown. +- `pnpm test`: 778 files and 8,199 tests passed; 26 files and 348 tests were skipped by + their existing suite configuration. +- `pnpm run build`: passed Node and renderer typecheck, all Electron production bundles, and + `out/cli/deepchat.mjs` generation. The provider catalog fetch failed closed to the committed + snapshot; the ACP registry refresh succeeded without a tracked diff. +- `pnpm run format:check`, `pnpm run i18n`, `pnpm run lint`, and `pnpm run typecheck`: passed. +- `pnpm run build:unpack`: application build, native rebuild, and Electron extraction completed, + but afterPack correctly rejected the missing pinned `runtime/node/bin/node`. Both the standard + runtime installer and a direct network probe failed during TLS setup against the upstream runtime + hosts, so a complete unsigned package remains an environment-blocked validation item. +- The partially assembled app contains `deepchat`, `deepchat.cmd`, and `deepchat.mjs`; the POSIX + launcher retains its executable bit. Running that packaged launcher against an isolated + stopped-desktop profile produced the versioned `unavailable` error envelope and exit code `3`. diff --git a/docs/guides/cli.md b/docs/guides/cli.md new file mode 100644 index 000000000..4d392a0f9 --- /dev/null +++ b/docs/guides/cli.md @@ -0,0 +1,252 @@ +# DeepChat CLI V1 + +DeepChat 随桌面应用提供 `deepchat` 命令。命令本身是薄客户端,所有 Provider、凭据、Skill、 +MCP、OCR、Artifact 和 Agent 状态仍由正在运行的 DeepChat main 进程持有。 + +## 生命周期 + +- 不提供 CLI 开关。DeepChat 启动时自动启动本机 control plane。 +- server 监听成功后,DeepChat 自动、幂等地安装或修复自己拥有的 `deepchat` launcher。 +- launcher 冲突时保持失败关闭:不会覆盖同名的外部命令、被修改的 managed block、符号链接 + profile 或不属于 DeepChat 的文件。 +- launcher 不是 daemon。DeepChat 未运行时,命令返回 `unavailable`,退出码为 `3`。 +- DeepChat 退出时先停止接收请求,再取消进行中的 RPC、上传、下载和 stream。连接会在有界宽限期 + 内关闭,所有已连接且正在等待 main 的 CLI 进程自行退出,退出码为 `3`;不会遗留 CLI 后台进程。 +- 普通退出保留 launcher,便于下次启动后直接使用。完整数据重置只删除仍能证明由 DeepChat + 拥有的 launcher 集成。 + +先用诊断命令确认桌面端和协议可用: + +```bash +deepchat system status --json +deepchat system version --json +deepchat system capabilities --json +deepchat system doctor --json +``` + +## 命令合同 + +所有命令都使用固定的两段式前缀: + +```text +deepchat [options] +``` + +`--json`、`--jsonl`、`--timeout` 和领域参数必须放在 domain 与 verb 之后。下面的形式会被拒绝: + +```text +deepchat --json image generate +``` + +这不只是命令风格。Agent shell 的会话权限按命令签名缓存,两段式前缀确保批准粒度稳定在 +`deepchat `,不会因为前置 flag 变成另一组权限。 + +查看全部命令或单个命令参数: + +```bash +deepchat help commands +deepchat image generate --help +``` + +输出模式: + +- 默认 text:给人阅读,stdout 只放结果,诊断信息写入 stderr。 +- `--json`:只输出一个稳定的 result 或 error envelope。 +- `--jsonl`:逐行输出有版本的事件,最后一行是终态 result 或 error。 +- streaming 命令在 text/JSON 模式由 CLI 有界收集;main 始终只维护一条 canonical stream。 +- machine 模式不输出 ANSI progress UI。 + +稳定退出码: + +| Code | Meaning | +| --- | --- | +| `0` | success | +| `2` | invalid command or input | +| `3` | DeepChat unavailable or protocol/surface mismatch | +| `4` | authentication or authorization failure | +| `5` | renderer approval denied or timed out | +| `6` | domain operation failed | +| `7` | timeout, signal, or cancellation | +| `8` | internal or protocol failure | + +## V1 能力清单 + +| # | 能力 | 命令域 | 关键边界 | +| --- | --- | --- | --- | +| 1 | 文本模型调用 | `model invoke` | raw provider stream,无 Session、Tool、Memory 或 Skill 副作用 | +| 2 | 图片生成 | `image generate` | 二进制结果进入 ArtifactSpool | +| 3 | 音频生成与识别 | `audio speak`, `audio transcribe` | speech 为正式 standalone 能力;转写支持上传或 owned artifact | +| 4 | 视频生成 | `video generate` | 二进制结果进入 ArtifactSpool | +| 5 | 离线 OCR | `ocr status`, `ocr extract`, `ocr clear-cache` | 图片/PDF,文本内联返回,不进入 ArtifactSpool | +| 6 | 完整 Agent run | `agent run`, `run get/watch/cancel` | durable detached Session,可恢复、订阅和幂等取消 | +| 7 | 公共设置 | `settings get/set` | 只读写 typed allowlist,不是任意配置通道 | +| 8 | Provider 管理 | `provider list/test/add/update/set-credential/clear-credential/remove` | 公共 DTO 脱敏;凭据只从 stdin 进入 main | +| 9 | Model 管理 | `model list/enable/disable/config-get/config-set/config-reset` | 运行时列表与严格公共配置分离 | +| 10 | Skill 管理 | `skill list/install/enable/disable/remove` | ZIP/HTTPS 安装有边界与供应链批准 | +| 11 | MCP 管理 | `mcp list/add/update/enable/disable/start/stop/remove` | 仅公开管理面,不暴露 raw MCP tool tunnel | +| 12 | Artifact 管理 | `artifact describe/get/delete` | ownership、TTL、hash、配额与 no-overwrite | +| 13 | 诊断和 benchmark 输出 | `system ...`, JSON/JSONL、stdin、timeout | 外部 harness 负责数据集、重复、打分和冷启动 | +| 14 | Agent scoped CLI | bundled `deepchat-cli` Skill | main 签发短期、按调用和字节限额的 token,不暴露 human descriptor | + +## 模型、媒体与 OCR + +先枚举可用 Provider 和模型 ID,不要根据 UI 名称猜测: + +```bash +deepchat provider list --enabled-only --json +deepchat model list --provider --json +deepchat model config-get --provider --model --json +``` + +模型与媒体调用示例: + +```bash +deepchat model invoke --provider --model \ + --prompt 'Explain the result' --jsonl + +deepchat image generate --provider --model \ + --prompt 'A product photo' --jsonl + +deepchat video generate --provider --model \ + --prompt 'A five second product turntable' --jsonl + +deepchat audio speak --provider --model \ + --text 'Hello from DeepChat' --jsonl + +deepchat audio transcribe --provider --model \ + --file ./sample.wav --json +``` + +较长的 prompt、speech 文本和敏感值应通过 stdin 传递,避免 shell quoting 与 process-list 暴露: + +```bash +deepchat model invoke --provider --model --stdin --jsonl +deepchat provider set-credential --provider --stdin --json +``` + +OCR 是独立的随包离线能力,不是模型别名,也不依赖聊天里的“非视觉模型自动提取附件”设置: + +```bash +deepchat ocr status --json +deepchat ocr extract --file ./scan.png --json +deepchat ocr extract --file ./document.pdf --page-count 12 --max-tokens 8000 --json +deepchat ocr clear-cache --json +``` + +OCR 输出记录真实的 cache/runtime 状态: + +- `hit`:命中派生缓存; +- `miss-warm`:未命中缓存,提取前 helper 已 ready; +- `cold-runtime`:未命中缓存,提取前 helper 尚未 ready; +- offline:runtime asset 不可用,调用以 typed unavailable error 结束。 + +`ocr clear-cache` 只清理可再生的派生缓存,不重启 helper,也不保证制造 cold-runtime 样本。严格的 +冷启动 benchmark 应由外部 harness 重启 DeepChat,并同时记录 app/protocol/surface 版本、 +`runtimeStateBefore`、输入大小、耗时和输出 token 数。 + +## Artifact 与文件边界 + +图片、视频和 speech 的二进制结果以临时 Artifact 返回。结果包含随机 ID、MIME、大小、SHA-256、 +过期时间和建议文件名,不包含 main 内部路径。 + +Human terminal 可以读取或删除自己可见的 Artifact: + +```bash +deepchat artifact describe --id --json +deepchat artifact get --id --out ./result.png --json +deepchat artifact get --id --out ./result.png --overwrite --json +deepchat artifact delete --id --json +``` + +`artifact get` 默认不覆盖现有文件。main 不接受输出路径;它只流式返回受 ownership 保护的字节, +最终路径由 human CLI 在本地处理。 + +输入同样按 caller 分流:human CLI 打开本地文件并上传有界字节,main 不接受任意输入路径;Agent +只能传递 DeepChat-owned Artifact ID。Agent 不能使用 `--file`、`--out`、`--overwrite`,不能下载或 +删除 Artifact 字节。 + +## Agent run + +完整 Agent 工作流与 raw `model invoke` 分开: + +```bash +deepchat agent run --prompt 'Inspect the project and summarize the issue' --json +deepchat run watch --run --jsonl +deepchat run get --run --json +deepchat run cancel --run --json +``` + +`agent run` 先创建 durable detached Session,再启动首轮。CLI 断开不会删除 run;可以通过 +`run get` 恢复消息,通过 cursor 续接 `run watch`。Agent caller 自身不能递归执行 `agent run`。 + +## 设置、Skill 与 MCP + +公共读取都是脱敏的: + +```bash +deepchat settings get --json +deepchat settings get --keys privacyModeEnabled,ocrBackend --json +deepchat skill list --json +deepchat mcp list --json +``` + +变更示例: + +```bash +deepchat settings set --key ocrBackend --value '"cpu"' --json +deepchat model enable --provider --model --json +deepchat skill install --url --json +deepchat mcp add --name --stdin --json +``` + +设置只覆盖 canonical contract 中的公开 key;Provider/Model、Skill、MCP 也各自使用严格输入,不能 +借 CLI 读取 secret、数据库字段、环境变量或任意内部 route。 + +## 批准与 Agent 安全模型 + +Human 发起敏感 mutation 时,请求会保持挂起,由 DeepChat renderer 展示批准。批准绑定到当前 +method、规范化参数 hash、effect、scope、有效期和 live request;CLI 只会等待结果,无法取得或重放 +ticket,也不存在 `--confirmed` 一类绕过参数。 + +Agent 调用额外经过以下控制: + +1. shell command permission; +2. main 签发的短期 scoped token; +3. deny-by-default `CLI_SURFACE_V1` caller/scope policy; +4. effect policy 与 renderer-only approval; +5. ownership、rate、call/byte quota 与脱敏审计。 + +`deepchat` 不在 `SAFE_COMMANDS`。Agent 每次只能执行一个以 `deepchat ` 开头的独立 +命令;pipeline、重定向、command substitution、separator 和 newline 都会阻止 scoped token 签发。 +bundled Skill 不读取或暴露 human descriptor。 + +Agent 的管理面只开放以下批准入口:preference-only `settings set`、不带 credentials/query/fragment +的 HTTPS `skill install`,以及新增 +一个默认禁用、无凭据且可完整审阅的 HTTPS remote `mcp add` 配置。Agent MCP 输入不能包含 stdio +command、headers、authorization、非 HTTPS endpoint,或超过批准 UI 的审阅上限;批准页展示完整 +endpoint 与公共 metadata,而审计仍只记录脱敏摘要。`mcp update` 可能立即重启正在运行的服务,因此与 +MCP runtime 控制/删除、Provider/Model 配置、Skill 启停/删除、credential 和 destructive 操作一样保持 +human-only。Skill/MCP 的脱敏列表可直接读取。 + +## Coredev 入口 + +| Owner | Path | +| --- | --- | +| thin CLI、argv、输出和本地文件 I/O | `src/cli` | +| server、surface、policy、domain adapters、ArtifactSpool | `src/main/cli` | +| 唯一 composition/start/stop owner | `src/main/app/composition.ts` | +| canonical protocol 与 route contracts | `src/shared/contracts` | +| 通用批准状态机 | `src/main/approval` | +| Agent shell gate | `src/main/tool/permission/commandPermissionService.ts` | +| bundled Agent instructions | `resources/skills/deepchat-cli/SKILL.md` | + +main 只监听 UDS 或 named pipe,不开放 TCP fallback。CLI surface 引用 canonical typed contracts,但 +不是内部 route registry 的通用代理。新增能力必须显式加入 surface,并同时定义 caller、scope、 +effect、approval、transport、输入/输出边界、quota 和 audit 语义。 + +V1 明确不包含:ACP server、远程访问、raw MCP tool invocation、TUI/交互 shell、任意配置或 secret +读取、server-side OCR batch/layout/model 管理、通用费用预算系统,以及内置 benchmark runner。 +benchmark 是建立在稳定 JSON/JSONL 合同上的外部 harness。 + +完整架构与安全不变量见 +[`docs/architecture/local-control-plane/spec.md`](../architecture/local-control-plane/spec.md)。 From 27e8e819487eea9bb6d389f11abd5a11b8037aa4 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 20:15:21 +0800 Subject: [PATCH 34/51] docs(cli): record macOS packaging validation --- .../architecture/local-control-plane/tasks.md | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/architecture/local-control-plane/tasks.md b/docs/architecture/local-control-plane/tasks.md index 47dd44657..6de7940c4 100644 --- a/docs/architecture/local-control-plane/tasks.md +++ b/docs/architecture/local-control-plane/tasks.md @@ -89,7 +89,7 @@ - [x] Run format and i18n validation. - [x] Run lint and typecheck. - [x] Run the full test suite and production build. -- [ ] Complete current-platform unsigned packaging with the pinned bundled runtimes. +- [x] Complete current-platform unsigned packaging with the pinned bundled runtimes. - [x] Run packaged CLI smoke where local prerequisites allow. - [x] Complete a severity-ranked review before every commit and resolve findings. - [x] Commit all V1 work locally with behavior-specific messages. @@ -107,10 +107,17 @@ Recorded on 2026-08-05 using macOS arm64, Node 24.14.1, and pnpm 10.33.4: `out/cli/deepchat.mjs` generation. The provider catalog fetch failed closed to the committed snapshot; the ACP registry refresh succeeded without a tracked diff. - `pnpm run format:check`, `pnpm run i18n`, `pnpm run lint`, and `pnpm run typecheck`: passed. -- `pnpm run build:unpack`: application build, native rebuild, and Electron extraction completed, - but afterPack correctly rejected the missing pinned `runtime/node/bin/node`. Both the standard - runtime installer and a direct network probe failed during TLS setup against the upstream runtime - hosts, so a complete unsigned package remains an environment-blocked validation item. -- The partially assembled app contains `deepchat`, `deepchat.cmd`, and `deepchat.mjs`; the POSIX - launcher retains its executable bit. Running that packaged launcher against an isolated - stopped-desktop profile produced the versioned `unavailable` error envelope and exit code `3`. +- `pnpm run installRuntime:mac:arm64`: installed the pinned uv 0.9.18, Node 24.14.1, and + rtk 0.43.0 runtimes with the local HTTP/SOCKS proxy configured. The installed Node executable + matched the manifest SHA-256 and reported the pinned version. +- `pnpm run build:unpack`: passed the application build, native rebuild, Electron extraction, and + unsigned macOS arm64 packaging. The completed app contains `deepchat`, `deepchat.cmd`, + `deepchat.mjs`, and the executable bundled Node runtime under `app.asar.unpacked`; the POSIX + launcher retains its executable bit. Code signing and notarization were skipped as expected for + this local unsigned build. +- The completed packaged launcher returned the versioned `unavailable` envelope and exit code `3` + against an isolated stopped-desktop profile. With the unpacked app running against another + isolated profile, packaged `system status`, `system version`, `system capabilities`, and + `system doctor` commands all exited `0`; doctor reported healthy transport, descriptor, + 46-method V1 surface, and renderer approval checks. App shutdown ran `cliServer.stop`, removed + the descriptor and socket, left no packaged app process, and restored launcher exit code `3`. From 80d2eb25badbf885974d1916ded12e8e1456448c Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 21:00:30 +0800 Subject: [PATCH 35/51] fix(events): preserve session lifecycle delivery --- src/main/events/sessionEventRouter.ts | 12 +++++- test/main/events/typedEventHub.test.ts | 54 +++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/main/events/sessionEventRouter.ts b/src/main/events/sessionEventRouter.ts index b8c09e8e9..5dbb578a4 100644 --- a/src/main/events/sessionEventRouter.ts +++ b/src/main/events/sessionEventRouter.ts @@ -33,12 +33,15 @@ function sessionIdsForEvent(name: DeepchatEventName, payload: unknown): string[] } export class SessionEventRouter { - private readonly ownershipCache = new Map() + private readonly ownershipCache = new Map() constructor(private readonly options: SessionEventRouterOptions) {} publish(name: DeepchatEventName, payload: unknown): void { const sessionIds = sessionIdsForEvent(name, payload) + if (name === 'sessions.updated') { + for (const sessionId of sessionIds) this.ownershipCache.delete(sessionId) + } const ownership = sessionIds.map((sessionId) => ({ sessionId, runId: this.resolveSessionRunId(sessionId) @@ -75,13 +78,14 @@ export class SessionEventRouter { private resolveSessionRunId(sessionId: string): string | null | undefined { if (this.ownershipCache.has(sessionId)) { - const cached = this.ownershipCache.get(sessionId) + const cached = this.ownershipCache.get(sessionId)! this.ownershipCache.delete(sessionId) this.ownershipCache.set(sessionId, cached) return cached } const runId = this.options.resolveSessionRunId(sessionId) + if (runId === undefined) return undefined this.ownershipCache.set(sessionId, runId) while (this.ownershipCache.size > MAX_OWNERSHIP_CACHE_ENTRIES) { const oldest = this.ownershipCache.keys().next().value @@ -95,6 +99,10 @@ export class SessionEventRouter { payload: ReturnType, unknownSessionIds: readonly string[] ): void { + if (payload.reason === 'deleted') { + this.options.hub.publish('sessions.updated', payload, { kind: 'renderer-all' }) + return + } const unknownIds = new Set(unknownSessionIds) const knownSessionIds = payload.sessionIds.filter((sessionId) => !unknownIds.has(sessionId)) if (knownSessionIds.length > 0) { diff --git a/test/main/events/typedEventHub.test.ts b/test/main/events/typedEventHub.test.ts index d4eedcd80..31606007d 100644 --- a/test/main/events/typedEventHub.test.ts +++ b/test/main/events/typedEventHub.test.ts @@ -248,7 +248,7 @@ describe('SessionEventRouter', () => { }) }) - it('fails closed for late events from an unknown or deleted session', () => { + it('drops late content events after session ownership can no longer be resolved', () => { const { hub, broadcast, send } = createHub() const router = new SessionEventRouter({ hub, @@ -267,4 +267,56 @@ describe('SessionEventRouter', () => { expect(broadcast).not.toHaveBeenCalled() expect(send).not.toHaveBeenCalled() }) + + it('broadcasts deletion invalidations after the session row is gone', () => { + const { hub, broadcast, send } = createHub() + const router = new SessionEventRouter({ + hub, + resolveSessionRunId: () => undefined, + getBoundRendererIds: () => [] + }) + + router.publish('sessions.updated', { + sessionIds: ['deleted-session'], + reason: 'deleted' + }) + + expect(broadcast).toHaveBeenCalledWith({ + name: 'sessions.updated', + payload: { sessionIds: ['deleted-session'], reason: 'deleted' } + }) + expect(send).not.toHaveBeenCalled() + }) + + it('retries unknown ownership and invalidates cached ownership on session updates', async () => { + const { hub, broadcast } = createHub() + let runId: string | null | undefined + const resolveSessionRunId = vi.fn(() => runId) + const router = new SessionEventRouter({ + hub, + resolveSessionRunId, + getBoundRendererIds: () => [] + }) + const subscription = hub.subscribe({ kind: 'run', runId: 'cli-run' }) + const streamEvent = { + requestId: 'request-1', + sessionId: 'session-1', + messageId: 'message-1', + completedAt: 123 + } + + router.publish('chat.stream.completed', streamEvent) + runId = null + router.publish('chat.stream.completed', streamEvent) + runId = 'cli-run' + router.publish('sessions.updated', { sessionIds: ['session-1'], reason: 'updated' }) + router.publish('chat.stream.completed', streamEvent) + + expect(resolveSessionRunId).toHaveBeenCalledTimes(3) + expect(broadcast).toHaveBeenCalledTimes(2) + await expect(nextEvent(subscription.events)).resolves.toMatchObject({ + target: { kind: 'run', runId: 'cli-run' }, + event: 'chat.stream.completed' + }) + }) }) From bd236efb878f355426c0a383e2beabc022c949f3 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 21:09:47 +0800 Subject: [PATCH 36/51] fix(events): bound run stream retention --- docs/architecture/local-control-plane/spec.md | 5 +- .../architecture/local-control-plane/tasks.md | 3 +- src/main/events/typedEventHub.ts | 155 ++++++++++++++---- test/main/cli/runService.test.ts | 12 +- test/main/events/typedEventHub.test.ts | 126 +++++++++++++- 5 files changed, 262 insertions(+), 39 deletions(-) diff --git a/docs/architecture/local-control-plane/spec.md b/docs/architecture/local-control-plane/spec.md index d8652fd01..2138cac14 100644 --- a/docs/architecture/local-control-plane/spec.md +++ b/docs/architecture/local-control-plane/spec.md @@ -507,7 +507,10 @@ events are never broadcast to all windows. Each subscriber has a bounded queue. Slow clients receive a terminal overflow error and disconnect; main does not accumulate unbounded events. Request streams preserve per-request order. Cross-request -global ordering is not promised. +global ordering is not promised. Retention is bounded both per stream and globally, inactive streams +expire after 30 minutes, and superseded full message snapshots are coalesced in recovery history +without changing live delivery. Cursors include a per-stream incarnation so an evicted and recreated +stream cannot accept a cursor from its prior history; recovery falls back to the durable run snapshot. Raw and media requests are cancelled when their connection/request aborts unless an operation explicitly supports detachment. `sessions.runDetached` first creates a detached session through the diff --git a/docs/architecture/local-control-plane/tasks.md b/docs/architecture/local-control-plane/tasks.md index 6de7940c4..359364829 100644 --- a/docs/architecture/local-control-plane/tasks.md +++ b/docs/architecture/local-control-plane/tasks.md @@ -66,7 +66,8 @@ ## Events and Agent Runs - [x] Add explicit renderer/connection/request/run Event Hub targets. -- [x] Add bounded queues, ordering, overflow, disconnect, and recovery semantics. +- [x] Add bounded subscriber queues and global retention, idle expiry, ordering, overflow, + disconnect, and incarnation-safe recovery semantics. - [x] Compose detached session creation with initial-turn execution. - [x] Add owned status, event streaming, result recovery, and idempotent cancellation. - [x] Add event-isolation, backpressure, detached-recovery, recursion-denial, and cancellation tests. diff --git a/src/main/events/typedEventHub.ts b/src/main/events/typedEventHub.ts index db69985f9..8a74beb2c 100644 --- a/src/main/events/typedEventHub.ts +++ b/src/main/events/typedEventHub.ts @@ -66,20 +66,30 @@ export type TypedEventHubOptions = Readonly<{ maxSubscribers?: number maxRetainedEvents?: number maxRetainedBytes?: number + maxTotalRetainedBytes?: number maxSubscriberEvents?: number maxSubscriberBytes?: number + streamIdleTtlMs?: number log?: Pick }> type StreamState = { target: TypedEventStreamTarget + cursorEpoch: string sequence: number - retained: TypedEventRecord[] + retained: RetainedEvent[] retainedBytes: number subscribers: Set lastUsedAt: number } +type RetainedEvent = Readonly<{ + record: TypedEventRecord + bytes: number + order: number + coalescingKey: string | null +}> + type PendingNext = Readonly<{ resolve(value: IteratorResult): void reject(error: Error): void @@ -89,8 +99,11 @@ const DEFAULT_MAX_STREAMS = 128 const DEFAULT_MAX_SUBSCRIBERS = 64 const DEFAULT_MAX_RETAINED_EVENTS = 256 const DEFAULT_MAX_RETAINED_BYTES = 4 * 1024 * 1024 +const DEFAULT_MAX_TOTAL_RETAINED_BYTES = 32 * 1024 * 1024 const DEFAULT_MAX_SUBSCRIBER_EVENTS = 64 const DEFAULT_MAX_SUBSCRIBER_BYTES = 1024 * 1024 +const DEFAULT_STREAM_IDLE_TTL_MS = 30 * 60_000 +const MAX_STREAM_SWEEP_INTERVAL_MS = 60_000 function targetKey(target: TypedEventStreamTarget): string { const field = (value: string): string => `${value.length}:${value}` @@ -110,6 +123,13 @@ function recordSize(record: TypedEventRecord): number { return Buffer.byteLength(JSON.stringify(record), 'utf8') } +function retainedEventCoalescingKey(record: TypedEventRecord): string | null { + if (record.event !== 'chat.stream.updated') return null + if (!record.data || typeof record.data !== 'object' || Array.isArray(record.data)) return null + const messageId = record.data.messageId + return typeof messageId === 'string' ? `${record.event}:${messageId}` : null +} + class EventSubscriber implements AsyncIterator, AsyncIterable { private readonly queue: Array<{ record: TypedEventRecord; bytes: number }> = [] private queuedBytes = 0 @@ -118,12 +138,11 @@ class EventSubscriber implements AsyncIterator, AsyncIterable< private failure: Error | null = null constructor( - initialRecords: readonly TypedEventRecord[], + initialRecords: readonly RetainedEvent[], private readonly limits: { maxEvents: number; maxBytes: number }, private readonly onClose: () => void ) { - for (const record of initialRecords) { - const bytes = recordSize(record) + for (const { record, bytes } of initialRecords) { this.queue.push({ record, bytes }) this.queuedBytes += bytes } @@ -154,9 +173,8 @@ class EventSubscriber implements AsyncIterator, AsyncIterable< return Promise.resolve({ value: undefined, done: true }) } - enqueue(record: TypedEventRecord): void { + enqueue(record: TypedEventRecord, bytes: number): void { if (this.closed || this.failure) return - const bytes = recordSize(record) if (bytes > this.limits.maxBytes) { this.fail(new TypedEventHubOverflowError('Event exceeds the subscriber byte limit')) return @@ -209,11 +227,17 @@ export class TypedEventHub { private readonly maxSubscribers: number private readonly maxRetainedEvents: number private readonly maxRetainedBytes: number + private readonly maxTotalRetainedBytes: number private readonly maxSubscriberEvents: number private readonly maxSubscriberBytes: number + private readonly streamIdleTtlMs: number + private readonly streamSweepTimer: NodeJS.Timeout private readonly log: Pick private readonly streams = new Map() private subscriberCount = 0 + private totalRetainedBytes = 0 + private streamIncarnation = 0 + private retentionOrder = 0 constructor(private readonly options: TypedEventHubOptions) { this.epoch = options.epoch ?? randomUUID() @@ -222,9 +246,16 @@ export class TypedEventHub { this.maxSubscribers = options.maxSubscribers ?? DEFAULT_MAX_SUBSCRIBERS this.maxRetainedEvents = options.maxRetainedEvents ?? DEFAULT_MAX_RETAINED_EVENTS this.maxRetainedBytes = options.maxRetainedBytes ?? DEFAULT_MAX_RETAINED_BYTES + this.maxTotalRetainedBytes = options.maxTotalRetainedBytes ?? DEFAULT_MAX_TOTAL_RETAINED_BYTES this.maxSubscriberEvents = options.maxSubscriberEvents ?? DEFAULT_MAX_SUBSCRIBER_EVENTS this.maxSubscriberBytes = options.maxSubscriberBytes ?? DEFAULT_MAX_SUBSCRIBER_BYTES + this.streamIdleTtlMs = options.streamIdleTtlMs ?? DEFAULT_STREAM_IDLE_TTL_MS this.log = options.log ?? console + this.streamSweepTimer = setInterval( + () => this.pruneExpiredStreams(), + Math.max(1_000, Math.min(this.streamIdleTtlMs, MAX_STREAM_SWEEP_INTERVAL_MS)) + ) + this.streamSweepTimer.unref() } publish(name: DeepchatEventName, payload: unknown, target: TypedEventTarget): void { @@ -245,27 +276,18 @@ export class TypedEventHub { const record: TypedEventRecord = { target, sequence: state.sequence, - cursor: this.cursor(state.sequence), + cursor: this.cursor(state, state.sequence), timestamp: state.lastUsedAt, event: name, data } const bytes = recordSize(record) - if (bytes <= this.maxRetainedBytes) { - state.retained.push(record) - state.retainedBytes += bytes - while ( - state.retained.length > this.maxRetainedEvents || - state.retainedBytes > this.maxRetainedBytes - ) { - const removed = state.retained.shift() - if (!removed) break - state.retainedBytes -= recordSize(removed) - } + if (bytes <= this.maxRetainedBytes && bytes <= this.maxTotalRetainedBytes) { + this.retain(state, record, bytes) } - for (const subscriber of Array.from(state.subscribers)) subscriber.enqueue(record) + for (const subscriber of Array.from(state.subscribers)) subscriber.enqueue(record, bytes) } subscribe( @@ -278,7 +300,7 @@ export class TypedEventHub { let initialRecords = recovery.sequence === null ? [] - : state.retained.filter((item) => item.sequence > recovery.sequence!) + : state.retained.filter((item) => item.record.sequence > recovery.sequence!) if (!this.recordsFitSubscriber(initialRecords)) { recovery.reason = 'cursor_expired' recovery.sequence = null @@ -308,7 +330,7 @@ export class TypedEventHub { } return { - initialCursor: this.cursor(state.sequence), + initialCursor: this.cursor(state, state.sequence), recoveryReason: recovery.reason, events: subscriber, close: () => subscriber.close() @@ -316,10 +338,12 @@ export class TypedEventHub { } close(): void { + clearInterval(this.streamSweepTimer) for (const state of this.streams.values()) { for (const subscriber of Array.from(state.subscribers)) subscriber.close() } this.streams.clear() + this.totalRetainedBytes = 0 } private deliver( @@ -336,11 +360,12 @@ export class TypedEventHub { } } - private cursor(sequence: number): string { - return `${this.epoch}:${sequence}` + private cursor(state: StreamState, sequence: number): string { + return `${state.cursorEpoch}:${sequence}` } private getOrCreateStream(target: TypedEventStreamTarget): StreamState { + this.pruneExpiredStreams() const key = targetKey(target) const existing = this.streams.get(key) if (existing) return existing @@ -348,6 +373,7 @@ export class TypedEventHub { this.trimStreamCapacity(1) const state: StreamState = { target, + cursorEpoch: `${this.epoch}_${++this.streamIncarnation}`, sequence: 0, retained: [], retainedBytes: 0, @@ -367,34 +393,105 @@ export class TypedEventHub { const epoch = separator > 0 ? cursor.slice(0, separator) : '' const rawSequence = separator > 0 ? cursor.slice(separator + 1) : '' const sequence = /^(?:0|[1-9][0-9]*)$/.test(rawSequence) ? Number(rawSequence) : Number.NaN - if (epoch !== this.epoch) return { reason: 'server_restarted', sequence: null } + if (epoch !== state.cursorEpoch) { + const sameHub = epoch === this.epoch || epoch.startsWith(`${this.epoch}_`) + return { reason: sameHub ? 'cursor_expired' : 'server_restarted', sequence: null } + } if (!Number.isSafeInteger(sequence) || sequence < 0) { return { reason: 'cursor_expired', sequence: null } } if (sequence > state.sequence) return { reason: 'cursor_ahead', sequence: null } - const oldestSequence = state.retained[0]?.sequence ?? state.sequence + 1 + const oldestSequence = state.retained[0]?.record.sequence ?? state.sequence + 1 if (sequence < oldestSequence - 1) return { reason: 'cursor_expired', sequence: null } return { reason: null, sequence } } - private recordsFitSubscriber(records: readonly TypedEventRecord[]): boolean { + private recordsFitSubscriber(records: readonly RetainedEvent[]): boolean { if (records.length > this.maxSubscriberEvents) return false let bytes = 0 for (const record of records) { - bytes += recordSize(record) + bytes += record.bytes if (bytes > this.maxSubscriberBytes) return false } return true } + private retain(state: StreamState, record: TypedEventRecord, bytes: number): void { + const coalescingKey = retainedEventCoalescingKey(record) + if (coalescingKey) { + const supersededIndex = state.retained.findIndex( + (item) => item.coalescingKey === coalescingKey + ) + if (supersededIndex >= 0) this.removeRetainedAt(state, supersededIndex) + } + + state.retained.push({ + record, + bytes, + order: ++this.retentionOrder, + coalescingKey + }) + state.retainedBytes += bytes + this.totalRetainedBytes += bytes + while ( + state.retained.length > this.maxRetainedEvents || + state.retainedBytes > this.maxRetainedBytes + ) { + this.removeRetainedAt(state, 0) + } + this.trimTotalRetainedBytes() + } + + private trimTotalRetainedBytes(): void { + while (this.totalRetainedBytes > this.maxTotalRetainedBytes) { + let candidate: StreamState | undefined + let oldestOrder = Number.POSITIVE_INFINITY + for (const state of this.streams.values()) { + const order = state.retained[0]?.order + if (order !== undefined && order < oldestOrder) { + candidate = state + oldestOrder = order + } + } + if (!candidate) break + this.removeRetainedAt(candidate, 0) + } + } + + private removeRetainedAt(state: StreamState, index: number): void { + const [removed] = state.retained.splice(index, 1) + if (!removed) return + state.retainedBytes -= removed.bytes + this.totalRetainedBytes -= removed.bytes + } + + private pruneExpiredStreams(): void { + const expirationThreshold = this.now() - this.streamIdleTtlMs + for (const [key, state] of this.streams) { + if (state.subscribers.size === 0 && state.lastUsedAt < expirationThreshold) { + this.removeStream(key, state) + } + } + } + + private removeStream(key: string, state: StreamState): void { + if (!this.streams.delete(key)) return + this.totalRetainedBytes -= state.retainedBytes + } + private trimStreamCapacity(additionalStreams = 0): void { while (this.streams.size + additionalStreams > this.maxStreams) { const candidate = Array.from(this.streams.entries()) .filter(([, state]) => state.subscribers.size === 0) .sort((left, right) => left[1].lastUsedAt - right[1].lastUsedAt)[0] - if (!candidate) return - this.streams.delete(candidate[0]) + if (!candidate) { + if (additionalStreams > 0) { + throw new TypedEventHubCapacityError('Event stream capacity is exhausted') + } + return + } + this.removeStream(candidate[0], candidate[1]) } } } diff --git a/test/main/cli/runService.test.ts b/test/main/cli/runService.test.ts index 5086f95a0..ba3c17be5 100644 --- a/test/main/cli/runService.test.ts +++ b/test/main/cli/runService.test.ts @@ -353,9 +353,9 @@ describe('CliRunService', () => { { kind: 'run', runId: 'run-1' } ) - await expect(result).resolves.toEqual({ runId: 'run-1', lastCursor: 'test-epoch:1' }) + await expect(result).resolves.toEqual({ runId: 'run-1', lastCursor: 'test-epoch_1:1' }) expect(emitted.map((entry) => entry.event)).toEqual(['runs.snapshot', 'chat.stream.completed']) - expect(emitted[0].context).toEqual({ runId: 'run-1', cursor: 'test-epoch:0' }) + expect(emitted[0].context).toEqual({ runId: 'run-1', cursor: 'test-epoch_1:0' }) }) it('does not terminate a root run watcher when a descendant session completes', async () => { @@ -406,7 +406,7 @@ describe('CliRunService', () => { { kind: 'run', runId: 'run-1' } ) - await expect(result).resolves.toEqual({ runId: 'run-1', lastCursor: 'test-epoch:2' }) + await expect(result).resolves.toEqual({ runId: 'run-1', lastCursor: 'test-epoch_1:2' }) expect(emitted).toEqual(['runs.snapshot', 'chat.stream.completed', 'chat.stream.completed']) }) @@ -423,11 +423,11 @@ describe('CliRunService', () => { new AbortController().signal, emit ) - ).resolves.toEqual({ runId: 'run-1', lastCursor: 'test-epoch:0' }) + ).resolves.toEqual({ runId: 'run-1', lastCursor: 'test-epoch_1:0' }) expect(emit).toHaveBeenCalledWith( 'runs.snapshot', expect.objectContaining({ recoveryReason: 'cursor_missing' }), - { runId: 'run-1', cursor: 'test-epoch:0' } + { runId: 'run-1', cursor: 'test-epoch_1:0' } ) }) @@ -450,7 +450,7 @@ describe('CliRunService', () => { await snapshotReady controller.abort() - await expect(result).resolves.toEqual({ runId: 'run-1', lastCursor: 'test-epoch:0' }) + await expect(result).resolves.toEqual({ runId: 'run-1', lastCursor: 'test-epoch_1:0' }) expect(turn.cancelGeneration).not.toHaveBeenCalled() }) diff --git a/test/main/events/typedEventHub.test.ts b/test/main/events/typedEventHub.test.ts index 31606007d..cffc0991f 100644 --- a/test/main/events/typedEventHub.test.ts +++ b/test/main/events/typedEventHub.test.ts @@ -67,7 +67,7 @@ describe('TypedEventHub', () => { { kind: 'run', runId: 'run-1' } ) const first = await nextEvent(runOne.events) - expect(first.cursor).toBe('test-epoch:1') + expect(first.cursor).toBe('test-epoch_1:1') const replay = hub.subscribe( { kind: 'run', runId: 'run-1' }, @@ -90,7 +90,129 @@ describe('TypedEventHub', () => { ) expect(subscription.recoveryReason).toBe('server_restarted') - expect(subscription.initialCursor).toBe('test-epoch:0') + expect(subscription.initialCursor).toBe('test-epoch_1:0') + }) + + it('expires cursors when an evicted stream is recreated in the same process', () => { + const { hub } = createHub({ maxStreams: 1 }) + const first = hub.subscribe({ kind: 'run', runId: 'run-1' }) + first.close() + const other = hub.subscribe({ kind: 'run', runId: 'run-2' }) + other.close() + + const recreated = hub.subscribe( + { kind: 'run', runId: 'run-1' }, + { afterCursor: first.initialCursor } + ) + + expect(recreated.recoveryReason).toBe('cursor_expired') + expect(recreated.initialCursor).toBe('test-epoch_3:0') + }) + + it('expires idle streams before accepting a stale cursor', () => { + let now = 0 + const { hub } = createHub({ + now: () => now, + streamIdleTtlMs: 10 + }) + const first = hub.subscribe({ kind: 'run', runId: 'run-1' }) + first.close() + hub.publish( + 'sessions.status.changed', + { sessionId: 'run-1', status: 'generating', version: 1 }, + { kind: 'run', runId: 'run-1' } + ) + now = 11 + + const recreated = hub.subscribe( + { kind: 'run', runId: 'run-1' }, + { afterCursor: first.initialCursor } + ) + + expect(recreated.recoveryReason).toBe('cursor_expired') + expect(recreated.initialCursor).toBe('test-epoch_2:0') + }) + + it('expires a cursor when the global retention budget evicts its event', () => { + const { hub } = createHub({ maxTotalRetainedBytes: 1024 }) + const first = hub.subscribe({ kind: 'run', runId: 'run-1' }) + first.close() + hub.publish( + 'chat.stream.failed', + { + requestId: 'request-1', + sessionId: 'run-1', + messageId: 'message-1', + failedAt: 1, + error: 'x'.repeat(600) + }, + { kind: 'run', runId: 'run-1' } + ) + const second = hub.subscribe({ kind: 'run', runId: 'run-2' }) + second.close() + hub.publish( + 'chat.stream.failed', + { + requestId: 'request-2', + sessionId: 'run-2', + messageId: 'message-2', + failedAt: 2, + error: 'y'.repeat(600) + }, + { kind: 'run', runId: 'run-2' } + ) + + const replay = hub.subscribe( + { kind: 'run', runId: 'run-1' }, + { afterCursor: first.initialCursor } + ) + + expect(replay.recoveryReason).toBe('cursor_expired') + expect(replay.initialCursor).toBe('test-epoch_1:1') + const retained = hub.subscribe( + { kind: 'run', runId: 'run-2' }, + { afterCursor: second.initialCursor } + ) + expect(retained.recoveryReason).toBeNull() + }) + + it('replays only the latest retained snapshot for a message', async () => { + const { hub } = createHub() + const live = hub.subscribe({ kind: 'run', runId: 'run-1' }) + hub.publish( + 'chat.stream.updated', + { + kind: 'snapshot', + requestId: 'request-1', + sessionId: 'run-1', + messageId: 'message-1', + updatedAt: 1, + blocks: [] + }, + { kind: 'run', runId: 'run-1' } + ) + const first = await nextEvent(live.events) + live.close() + hub.publish( + 'chat.stream.updated', + { + kind: 'snapshot', + requestId: 'request-1', + sessionId: 'run-1', + messageId: 'message-1', + updatedAt: 2, + blocks: [] + }, + { kind: 'run', runId: 'run-1' } + ) + + const replay = hub.subscribe({ kind: 'run', runId: 'run-1' }, { afterCursor: first.cursor }) + + expect(replay.recoveryReason).toBeNull() + await expect(nextEvent(replay.events)).resolves.toMatchObject({ + sequence: 2, + data: { messageId: 'message-1', updatedAt: 2 } + }) }) it('terminates a slow subscriber instead of growing its queue', async () => { From a5da4250b1e6ad8c098593238910453844fbac98 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 21:22:26 +0800 Subject: [PATCH 37/51] fix(cli): gate upload bytes after approval --- docs/architecture/local-control-plane/spec.md | 22 ++- src/cli/transport.ts | 7 +- src/main/cli/server.ts | 65 +++++--- src/shared/contracts/localControl.ts | 1 + test/main/cli/server.test.ts | 145 +++++++++++++++++- test/main/cli/transport.test.ts | 24 ++- 6 files changed, 222 insertions(+), 42 deletions(-) diff --git a/docs/architecture/local-control-plane/spec.md b/docs/architecture/local-control-plane/spec.md index 2138cac14..c2b5cdce8 100644 --- a/docs/architecture/local-control-plane/spec.md +++ b/docs/architecture/local-control-plane/spec.md @@ -185,20 +185,26 @@ this protocol. - `GET /v1/artifacts/:id`: ownership-checked binary output download. - `GET /v1/events`: ownership-checked NDJSON event subscription with request/run filters. -All endpoints require `Authorization: Bearer`. RPC envelopes carry a caller-generated request ID, -method, and params. Responses carry the same ID and either typed result metadata or a stable error -object. HTTP status communicates transport/authentication failure; CLI exit codes communicate the -domain outcome. Proxy environment variables are ignored for local transport. +All endpoints require `Authorization: Bearer`. RPC and stream requests also carry a singular +`X-DeepChat-Method` header. Main resolves that method's surface and byte limit before reading the +JSON body, then requires the typed envelope method to match the header. RPC envelopes carry a +caller-generated request ID, method, and params. Responses carry the same ID and either typed result +metadata or a stable error object. HTTP status communicates transport/authentication failure; CLI +exit codes communicate the domain outcome. Proxy environment variables are ignored for local +transport. `Content-Length` is rejected when missing for fixed JSON bodies, invalid, conflicting, or above the route limit. Upload metadata is the normal RPC envelope encoded as canonical base64url in a singular, 4 KiB-bounded `X-DeepChat-Upload-Request` header. This lets main authenticate and validate version, surface, caller, scopes, and typed metadata before accepting the large body. The body is raw `application/octet-stream`; uploads with or without `Content-Length` enforce a cumulative route byte -limit while reading. Bodies spill to a private `0700` directory above a route-specific memory -threshold. Upload bytes always stream into a private temporary file, so there is no multipart -extraction pass or base64 expansion. Abort, parse error, timeout, limit failure, and shutdown all -remove partial files. The public protocol does not expose those temporary paths. +limit while reading. The packaged client sends `Expect: 100-continue`; main sends `100 Continue` +only after authentication, surface checks, typed metadata validation, scope policy, and any renderer +approval complete. Rejection therefore does not read or persist the source bytes. Bodies spill to a +private `0700` directory above a route-specific memory threshold. Upload bytes always stream into a +private temporary file, so there is no multipart extraction pass or base64 expansion. Abort, parse +error, timeout, limit failure, and shutdown all remove partial files. The public protocol does not +expose those temporary paths. ## Contract Ownership and Surface diff --git a/src/cli/transport.ts b/src/cli/transport.ts index cd59a35c1..a7741f977 100644 --- a/src/cli/transport.ts +++ b/src/cli/transport.ts @@ -6,6 +6,7 @@ import { LOCAL_CONTROL_RPC_PATH, LOCAL_CONTROL_STREAM_PATH, LOCAL_CONTROL_UPLOAD_PATH, + LOCAL_CONTROL_METHOD_HEADER, LOCAL_CONTROL_UPLOAD_REQUEST_HEADER, LOCAL_CONTROL_MAX_JSON_RESPONSE_BYTES, LOCAL_CONTROL_MAX_STREAM_RECORD_BYTES, @@ -201,6 +202,7 @@ export async function invokeLocalControlRpc( authorization: `Bearer ${invocation.token}`, 'content-type': 'application/json', 'content-length': body.length, + [LOCAL_CONTROL_METHOD_HEADER]: invocation.method, connection: 'close', 'user-agent': `DeepChat-CLI/${CLI_VERSION}` } @@ -354,6 +356,7 @@ export async function invokeLocalControlUpload( authorization: `Bearer ${invocation.token}`, 'content-type': 'application/octet-stream', 'content-length': openedStat.size, + expect: '100-continue', [LOCAL_CONTROL_UPLOAD_REQUEST_HEADER]: envelope, connection: 'close', 'user-agent': `DeepChat-CLI/${CLI_VERSION}` @@ -386,7 +389,8 @@ export async function invokeLocalControlUpload( finish(() => reject(transportFailure('Upload source could not be read'))) } }) - uploadStream.pipe(request) + request.once('continue', () => uploadStream.pipe(request)) + request.flushHeaders() }) } finally { uploadStream.destroy() @@ -424,6 +428,7 @@ export async function invokeLocalControlStream( authorization: `Bearer ${invocation.token}`, 'content-type': 'application/json', 'content-length': body.length, + [LOCAL_CONTROL_METHOD_HEADER]: invocation.method, connection: 'close', 'user-agent': `DeepChat-CLI/${CLI_VERSION}` } diff --git a/src/main/cli/server.ts b/src/main/cli/server.ts index 74b23f998..475e9de78 100644 --- a/src/main/cli/server.ts +++ b/src/main/cli/server.ts @@ -9,6 +9,8 @@ import { JsonValueSchema, TimestampMsSchema, type JsonValue } from '@shared/cont import { LOCAL_CONTROL_DESCRIPTOR_FILENAME, LOCAL_CONTROL_ARTIFACT_PATH_PREFIX, + LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS, + LOCAL_CONTROL_METHOD_HEADER, LOCAL_CONTROL_PROTOCOL_VERSION, LOCAL_CONTROL_RPC_PATH, LOCAL_CONTROL_SCOPES, @@ -20,6 +22,7 @@ import { LOCAL_CONTROL_MAX_STREAM_RECORD_BYTES, LOCAL_CONTROL_MAX_UPLOAD_REQUEST_HEADER_BYTES, LocalControlEventEnvelopeSchema, + LocalControlMethodSchema, LocalControlScopesSchema, LocalControlTokenSchema, LocalControlRpcRequestSchema, @@ -60,6 +63,7 @@ const MAX_CONNECTIONS = 64 const MAX_PENDING_REQUESTS = 64 const MAX_PENDING_PER_CONNECTION = 8 const MAX_IN_MEMORY_BODY_BYTES = 256 * 1024 +const REQUEST_TIMEOUT_GRACE_MS = 5_000 const SHUTDOWN_GRACE_MS = 2_000 const UNKNOWN_REQUEST_ID = 'unknown' const emptyAdmission: CliRequestAdmission = Object.freeze({ release: () => undefined }) @@ -190,15 +194,18 @@ function parseUploadRequestHeader(request: IncomingMessage): unknown { return parseBoundedJsonBytes(decoded) } -function getMaxBodyBytes( - surface: ReadonlyMap, - transport: 'rpc' | 'stream' -): number { - let maxBytes = 1 - for (const entry of surface.values()) { - if (entry.transport === transport) maxBytes = Math.max(maxBytes, entry.limits.maxBodyBytes) +function readRequestMethodHeader(request: IncomingMessage): string { + const parsed = LocalControlMethodSchema.safeParse( + readSingularRequestHeader(request, LOCAL_CONTROL_METHOD_HEADER) + ) + if (!parsed.success) { + throw new CliRequestError('invalid_request', 'Method header is missing or invalid') } - return maxBytes + return parsed.data +} + +function requestExpectsContinue(request: IncomingMessage): boolean { + return readSingularRequestHeader(request, 'expect')?.trim().toLowerCase() === '100-continue' } function toSafeRequestId(value: unknown): string { @@ -338,7 +345,7 @@ export class CliServer { const startedAt = this.now() await prepareLocalControlLayout(layout, this.platform) - const server = createServer({ maxHeaderSize: MAX_HEADER_BYTES }, (request, response) => { + const serveRequest = (request: IncomingMessage, response: ServerResponse) => { void this.handleRequest(request, response).catch((error) => { this.log.error('[CLI] Unhandled request failure', error) if (!response.headersSent && !response.destroyed) { @@ -354,10 +361,12 @@ export class CliServer { response.destroy() } }) - }) + } + const server = createServer({ maxHeaderSize: MAX_HEADER_BYTES }, serveRequest) + server.on('checkContinue', serveRequest) server.maxConnections = MAX_CONNECTIONS server.headersTimeout = 10_000 - server.requestTimeout = 30_000 + server.requestTimeout = LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS + REQUEST_TIMEOUT_GRACE_MS server.keepAliveTimeout = 5_000 server.on('connection', (socket) => { this.sockets.add(socket) @@ -532,7 +541,8 @@ export class CliServer { ) return } - if (request.headers.expect !== undefined) { + const expectsContinue = requestExpectsContinue(request) + if (request.headers.expect !== undefined && (!isUploadRequest || !expectsContinue)) { this.sendFailure( response, 417, @@ -637,23 +647,30 @@ export class CliServer { let requestId = UNKNOWN_REQUEST_ID let routeMethod = 'unknown' try { - let bodySize = 0 let rawRequest: unknown let transportBinding: LocalControlUploadBinding | undefined + let declaredMethod: string | undefined + let entry: CliSurfaceEntry | undefined if (requestTransport === 'upload') { rawRequest = parseUploadRequestHeader(request) } else { - const maxBodyBytes = getMaxBodyBytes(this.surface, requestTransport) + declaredMethod = readRequestMethodHeader(request) + routeMethod = declaredMethod + entry = this.surface.get(declaredMethod) + if (!entry || entry.transport !== requestTransport) { + throw new CliRequestError('not_found', 'Method is not exposed by CLI surface V1', { + httpStatus: 404 + }) + } const body = await readBoundedRequestBody(request, { - maxBytes: maxBodyBytes, - memoryThresholdBytes: Math.min(maxBodyBytes, MAX_IN_MEMORY_BODY_BYTES), + maxBytes: entry.limits.maxBodyBytes, + memoryThresholdBytes: Math.min(entry.limits.maxBodyBytes, MAX_IN_MEMORY_BODY_BYTES), tempDirectory: this.layout?.tempDirectory ?? this.dependencies.userDataPath, requireContentLength: true, ...(agentGrant ? { consumeBytes: (bytes: number) => this.consumeAgentBytes(agentGrant, bytes) } : {}) }) - bodySize = body.size rawRequest = await parseBoundedJsonBody(body) } if (isRecord(rawRequest)) requestId = toSafeRequestId(rawRequest.id) @@ -686,17 +703,18 @@ export class CliServer { } requestId = rpcRequest.id routeMethod = rpcRequest.method - const entry = this.surface.get(rpcRequest.method) + if (declaredMethod && rpcRequest.method !== declaredMethod) { + throw new CliRequestError( + 'invalid_request', + 'Method header does not match the request body' + ) + } + entry ??= this.surface.get(rpcRequest.method) if (!entry || entry.transport !== requestTransport) { throw new CliRequestError('not_found', 'Method is not exposed by CLI surface V1', { httpStatus: 404 }) } - if (requestTransport !== 'upload' && bodySize > entry.limits.maxBodyBytes) { - throw new CliRequestError('body_too_large', 'Request body exceeds method limit', { - httpStatus: 413 - }) - } if (transportBinding && transportBinding.size > entry.limits.maxBodyBytes) { throw new CliRequestError('body_too_large', 'Upload body exceeds method limit', { httpStatus: 413 @@ -759,6 +777,7 @@ export class CliServer { retriable: true }) } + if (expectsContinue) response.writeContinue() const uploadBody = await readBoundedRequestBody(request, { maxBytes: entry.limits.maxBodyBytes, memoryThresholdBytes: 0, diff --git a/src/shared/contracts/localControl.ts b/src/shared/contracts/localControl.ts index 11bbfa7bf..3c35b0d64 100644 --- a/src/shared/contracts/localControl.ts +++ b/src/shared/contracts/localControl.ts @@ -10,6 +10,7 @@ export const LOCAL_CONTROL_DESCRIPTOR_FILENAME = 'local-control.json' export const LOCAL_CONTROL_RPC_PATH = '/v1/rpc' export const LOCAL_CONTROL_STREAM_PATH = '/v1/stream' export const LOCAL_CONTROL_UPLOAD_PATH = '/v1/upload' +export const LOCAL_CONTROL_METHOD_HEADER = 'x-deepchat-method' export const LOCAL_CONTROL_UPLOAD_REQUEST_HEADER = 'x-deepchat-upload-request' export const LOCAL_CONTROL_MAX_UPLOAD_REQUEST_HEADER_BYTES = 4 * 1024 export const LOCAL_CONTROL_ARTIFACT_PATH_PREFIX = '/v1/artifacts/' diff --git a/test/main/cli/server.test.ts b/test/main/cli/server.test.ts index ab6a39d79..614ae614c 100644 --- a/test/main/cli/server.test.ts +++ b/test/main/cli/server.test.ts @@ -10,6 +10,7 @@ import { LOCAL_CONTROL_PROTOCOL_VERSION, LOCAL_CONTROL_SCOPES, LOCAL_CONTROL_SURFACE_VERSION, + LOCAL_CONTROL_METHOD_HEADER, LOCAL_CONTROL_UPLOAD_REQUEST_HEADER, LocalControlDescriptorSchema, LocalControlRpcResponseSchema, @@ -21,6 +22,7 @@ import { } from '@shared/contracts/localControl' import { createCliRoutes } from '@/cli/routes' import { CliServer, type CliServerDependencies, type CliUploadedInputFile } from '@/cli/server' +import { CliRequestError } from '@/cli/errors' import { AgentCliTokenAuthority, type AgentCliRequestBeginResult, @@ -71,7 +73,11 @@ function rpcRequest( protocolVersion?: number surfaceVersion?: number }, - options: { includeContentLength?: boolean } = {} + options: { + includeContentLength?: boolean + methodHeader?: string | null + sendBody?: boolean + } = {} ): Promise { const serialized = Buffer.from( JSON.stringify({ @@ -86,6 +92,9 @@ function rpcRequest( authorization: `Bearer ${input.token ?? descriptor.token}`, 'content-type': 'application/json' } + const methodHeader = + options.methodHeader === undefined ? (input.method ?? 'cli.version') : options.methodHeader + if (methodHeader !== null) headers[LOCAL_CONTROL_METHOD_HEADER] = methodHeader if (options.includeContentLength !== false) headers['content-length'] = serialized.length return new Promise((resolve, reject) => { @@ -120,7 +129,9 @@ function rpcRequest( } ) request.once('error', reject) - if (options.includeContentLength === false) { + if (options.sendBody === false) { + request.flushHeaders() + } else if (options.includeContentLength === false) { const midpoint = Math.max(1, Math.floor(serialized.length / 2)) request.write(serialized.subarray(0, midpoint)) request.end(serialized.subarray(midpoint)) @@ -138,6 +149,8 @@ function uploadRequest( includeContentLength?: boolean signal?: AbortSignal binding?: LocalControlUploadBinding + expectContinue?: boolean + onContinue?: () => void } ): Promise { const envelope = Buffer.from( @@ -160,7 +173,9 @@ function uploadRequest( 'content-type': 'application/octet-stream', [LOCAL_CONTROL_UPLOAD_REQUEST_HEADER]: envelope } + const expectsContinue = input.expectContinue !== false if (input.includeContentLength !== false) headers['content-length'] = input.body.length + if (expectsContinue) headers.expect = '100-continue' return new Promise((resolve, reject) => { let responseReceived = false @@ -199,13 +214,22 @@ function uploadRequest( request.once('error', (error) => { if (!responseReceived) reject(error) }) - if (input.includeContentLength === false) { - const midpoint = Math.max(1, Math.floor(input.body.length / 2)) - request.write(input.body.subarray(0, midpoint)) - request.end(input.body.subarray(midpoint)) - } else { - request.end(input.body) + const sendBody = () => { + if (input.includeContentLength === false) { + const midpoint = Math.max(1, Math.floor(input.body.length / 2)) + request.write(input.body.subarray(0, midpoint)) + request.end(input.body.subarray(midpoint)) + } else { + request.end(input.body) + } } + if (expectsContinue) { + request.once('continue', () => { + input.onContinue?.() + sendBody() + }) + request.flushHeaders() + } else sendBody() }) } @@ -432,6 +456,43 @@ describe('CLI local transport', () => { expect(dispatch).not.toHaveBeenCalled() }) + it('requires the method header to match the typed request body', async () => { + const { descriptor, dispatch } = await createTestServer() + + const missing = await rpcRequest(descriptor, {}, { methodHeader: null }) + const mismatched = await rpcRequest( + descriptor, + { method: 'cli.version' }, + { methodHeader: 'providers.listPublic' } + ) + + expect(missing).toMatchObject({ + status: 400, + body: { ok: false, error: { code: 'invalid_request' } } + }) + expect(mismatched).toMatchObject({ + status: 400, + body: { ok: false, error: { code: 'invalid_request' } } + }) + expect(dispatch).not.toHaveBeenCalled() + }) + + it('rejects a declared JSON body against its route limit before reading it', async () => { + const { descriptor, dispatch } = await createTestServer() + + const response = await rpcRequest( + descriptor, + { params: { padding: 'x'.repeat(20 * 1024) } }, + { sendBody: false } + ) + + expect(response).toMatchObject({ + status: 413, + body: { ok: false, error: { code: 'body_too_large' } } + }) + expect(dispatch).not.toHaveBeenCalled() + }) + it('reports invalid route output as an internal contract failure', async () => { const { descriptor } = await createTestServer({ dispatchOutput: () => ({ appVersion: '' }) @@ -508,6 +569,74 @@ describe('CLI local transport', () => { expect(await readdir(path.join(userDataPath, 'local-control', 'tmp'))).toEqual([]) }) + it('waits for upload approval before accepting file bytes', async () => { + let resolveApproval!: (admission: CliRequestAdmission) => void + const approval = new Promise((resolve) => { + resolveApproval = resolve + }) + let continued = false + const { descriptor, userDataPath, dispatchUpload, authorize } = await createTestServer({ + surface: createUploadSurface(16), + authorize: async () => await approval, + dispatchUpload: async () => ({ + appVersion: '1.2.3', + protocolVersion: LOCAL_CONTROL_PROTOCOL_VERSION, + surfaceVersion: LOCAL_CONTROL_SURFACE_VERSION + }) + }) + + const response = uploadRequest(descriptor, { + body: Buffer.from('audio-input'), + onContinue: () => { + continued = true + } + }) + await vi.waitFor(() => expect(authorize).toHaveBeenCalledOnce()) + + expect(continued).toBe(false) + expect(dispatchUpload).not.toHaveBeenCalled() + expect(await readdir(path.join(userDataPath, 'local-control', 'tmp'))).toEqual([]) + + resolveApproval({ release: () => undefined }) + + await expect(response).resolves.toMatchObject({ status: 200, body: { ok: true } }) + expect(continued).toBe(true) + expect(dispatchUpload).toHaveBeenCalledOnce() + }) + + it('rejects an upload without accepting file bytes when approval fails', async () => { + let rejectApproval!: (reason: unknown) => void + const approval = new Promise((_resolve, reject) => { + rejectApproval = reject + }) + let continued = false + const { descriptor, userDataPath, dispatchUpload, authorize } = await createTestServer({ + surface: createUploadSurface(16), + authorize: async () => await approval + }) + + const response = uploadRequest(descriptor, { + body: Buffer.from('audio-input'), + onContinue: () => { + continued = true + } + }) + await vi.waitFor(() => expect(authorize).toHaveBeenCalledOnce()) + rejectApproval( + new CliRequestError('approval_denied', 'Approval was denied', { + httpStatus: 403 + }) + ) + + await expect(response).resolves.toMatchObject({ + status: 403, + body: { ok: false, error: { code: 'approval_denied' } } + }) + expect(continued).toBe(false) + expect(dispatchUpload).not.toHaveBeenCalled() + expect(await readdir(path.join(userDataPath, 'local-control', 'tmp'))).toEqual([]) + }) + it('rejects upload bytes that do not match the approved size or digest', async () => { const { descriptor, dispatchUpload, authorize } = await createTestServer({ surface: createUploadSurface(16), diff --git a/test/main/cli/transport.test.ts b/test/main/cli/transport.test.ts index 643153d4a..3eecf7b9f 100644 --- a/test/main/cli/transport.test.ts +++ b/test/main/cli/transport.test.ts @@ -33,9 +33,13 @@ function createEndpoint(): LocalControlEndpoint { return { kind: 'unix', path: socketPath } } -async function listen(listener: RequestListener): Promise { +async function listen( + listener: RequestListener, + checkContinueListener?: RequestListener +): Promise { const endpoint = createEndpoint() const server = createServer(listener) + if (checkContinueListener) server.on('checkContinue', checkContinueListener) servers.push(server) await new Promise((resolve, reject) => { server.once('error', reject) @@ -219,7 +223,10 @@ describe('CLI response transport', () => { it('uploads a stable regular-file snapshot with a typed envelope header', async () => { let receivedBody = Buffer.alloc(0) let receivedEnvelope: unknown - const descriptor = await listen((request, response) => { + let receivedExpectHeader: string | undefined + let bytesBeforeContinue = 0 + let continueSent = false + const handleUpload: RequestListener = (request, response) => { const rawEnvelope = request.headers[LOCAL_CONTROL_UPLOAD_REQUEST_HEADER] receivedEnvelope = LocalControlUploadRequestSchema.parse( JSON.parse(Buffer.from(String(rawEnvelope), 'base64url').toString('utf8')) @@ -231,6 +238,17 @@ describe('CLI response transport', () => { response.setHeader('content-type', 'application/json; charset=utf-8') response.end(JSON.stringify(createLocalControlSuccess('request-1', { accepted: true }))) }) + } + const descriptor = await listen(handleUpload, (request, response) => { + receivedExpectHeader = request.headers.expect + request.on('data', (chunk: Buffer) => { + if (!continueSent) bytesBeforeContinue += chunk.length + }) + setImmediate(() => { + continueSent = true + response.writeContinue() + handleUpload(request, response) + }) }) const directory = await mkdtemp(path.join(os.tmpdir(), 'deepchat-cli-upload-')) temporaryDirectories.push(directory) @@ -249,6 +267,8 @@ describe('CLI response transport', () => { }) expect(result).toMatchObject({ ok: true, result: { accepted: true } }) + expect(receivedExpectHeader).toBe('100-continue') + expect(bytesBeforeContinue).toBe(0) expect(receivedEnvelope).toMatchObject({ id: 'request-1', method: 'audio.transcribeUpload', From 86084cf068c15d9250081dfd8ff732b40a60e170 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 21:31:47 +0800 Subject: [PATCH 38/51] fix(cli): report safe model diagnostics --- docs/architecture/local-control-plane/plan.md | 5 +- docs/architecture/local-control-plane/spec.md | 9 ++- .../architecture/local-control-plane/tasks.md | 3 +- src/main/cli/computeService.ts | 48 ++++++++++--- src/shared/contracts/routes/models.routes.ts | 19 ++++- test/main/cli/client.test.ts | 6 +- test/main/cli/computeService.test.ts | 70 ++++++++++++++----- test/main/cli/packagedSmoke.test.ts | 3 +- test/main/cli/server.test.ts | 3 +- test/main/cli/transport.test.ts | 3 +- 10 files changed, 122 insertions(+), 47 deletions(-) diff --git a/docs/architecture/local-control-plane/plan.md b/docs/architecture/local-control-plane/plan.md index 65fa1627b..420f28fbb 100644 --- a/docs/architecture/local-control-plane/plan.md +++ b/docs/architecture/local-control-plane/plan.md @@ -27,8 +27,9 @@ ## Stage B: Raw Model, Media, Speech, OCR, and Artifacts -1. Add `models.invoke` on the existing `coreStream` foundation with tools/session/memory disabled and - one canonical stream for all CLI output modes. +1. Add `models.invoke` on the existing `coreStream` foundation with tools/session/memory disabled, + one canonical stream for all CLI output modes, explicit benchmark timings, and secret-safe + provider failure metadata. 2. Expose standalone image and video generation through typed contracts and output artifacts. 3. Add a provider-runtime `generateSpeechStandalone` capability and typed audio artifact; keep the current VoiceAI event quirk behind its adapter. diff --git a/docs/architecture/local-control-plane/spec.md b/docs/architecture/local-control-plane/spec.md index c2b5cdce8..5bbc73601 100644 --- a/docs/architecture/local-control-plane/spec.md +++ b/docs/architecture/local-control-plane/spec.md @@ -405,8 +405,11 @@ foundation with: The server always produces the canonical stream. Human-readable and non-stream JSON CLI modes buffer that stream in the CLI; main does not maintain a second non-stream execution path. Events include -text/reasoning deltas as permitted, usage, finish reason, resolved provider/model identity, TTFT, -latency, and redacted resolved settings. +text/reasoning deltas as permitted, usage, finish reason, and resolved provider/model identity. The +terminal result reports rate-limit queue duration plus request-relative first-provider-event, +first-text, and total latency. Provider failures preserve a normalized upstream HTTP status and +retryability when available, but raw upstream messages, codes, headers, and response bodies never +cross the local-control boundary because they may contain credentials or request content. ### Media and speech @@ -600,7 +603,7 @@ Benchmarks are external harnesses over stable CLI output. Terminal records inclu - requested and resolved provider/model; - redacted generation settings and capability identity; - input/output token or byte counts where available; -- usage, TTFT, end-to-end latency, finish reason, retries, and cancellation outcome; +- usage, queue/first-event/first-text/total latency, finish reason, retries, and cancellation outcome; - artifact MIME, size, hash, and ID without local paths; - OCR cache/runtime classification; - app, protocol, surface, CLI, and provider adapter versions. diff --git a/docs/architecture/local-control-plane/tasks.md b/docs/architecture/local-control-plane/tasks.md index 359364829..5b8058be2 100644 --- a/docs/architecture/local-control-plane/tasks.md +++ b/docs/architecture/local-control-plane/tasks.md @@ -29,7 +29,8 @@ ## Compute and Artifacts -- [x] Add raw `models.invoke` over `coreStream` with no Agent/session/tool side effects. +- [x] Add raw `models.invoke` over `coreStream` with no Agent/session/tool side effects, explicit + benchmark timings, and secret-safe provider failure metadata. - [x] Add image and video standalone generation surfaces. - [x] Add formal standalone speech generation and typed audio output. - [x] Add upload and owned-artifact transcription inputs. diff --git a/src/main/cli/computeService.ts b/src/main/cli/computeService.ts index 5655aaa35..b22dee19c 100644 --- a/src/main/cli/computeService.ts +++ b/src/main/cli/computeService.ts @@ -2,6 +2,7 @@ import { MODEL_INVOKE_MAX_OUTPUT_CHARACTERS, MediaGenerationEventSchema, ModelInvokeEventSchema, + ModelInvokeProviderFailureSchema, PublicProviderSchema, imagesGenerateRoute, modelsInvokeRoute, @@ -29,6 +30,7 @@ import { isVideoGenerationModelConfig } from '@shared/videoGenerationSettings' import { isTtsModelConfig, isTtsModelId } from '@shared/ttsSettings' import type { ProviderSettingsPort } from '@/provider/settings' import type { ProviderRuntime } from '@/provider' +import { extractProviderFailureMetadata } from '@/provider/providerFailure' import { createRouteMap, type CliRouteCaller, @@ -118,6 +120,15 @@ function toUsage(event: Extract): ModelIn }) } +function providerFailureIsRetryable( + statusCode: number | undefined, + retryable: boolean | undefined +) { + if (retryable !== undefined) return retryable + if (statusCode === undefined) return true + return statusCode === 408 || statusCode === 409 || statusCode === 429 || statusCode >= 500 +} + export class CliComputeService { private readonly now: () => number private readonly log: Pick @@ -241,7 +252,7 @@ export class CliComputeService { emit: ComputeEmitter ): Promise { const startedAt = this.now() - let providerError: unknown + let providerError: Extract | undefined let emittedEvents = 0 const emitEvent = async (event: ModelInvokeEvent): Promise => { if (emittedEvents >= MAX_MODEL_STREAM_EVENTS) { @@ -261,6 +272,7 @@ export class CliComputeService { }) } + const queueStartedAt = this.now() let queuedEmission = Promise.resolve() let queuedEmissionError: unknown await this.options.providerRuntime.executeWithRateLimit(input.providerId, { @@ -279,6 +291,7 @@ export class CliComputeService { }) } }) + const queueFinishedAt = this.now() await queuedEmission if (queuedEmissionError) throw queuedEmissionError @@ -297,10 +310,16 @@ export class CliComputeService { let outputCharacters = 0 let usage: ModelInvokeOutput['usage'] let finishReason: ProviderRoundStopReason | undefined - let firstTokenAt: number | undefined + let firstEventAt: number | undefined + let firstTextAt: number | undefined for await (const event of stream) { signal.throwIfAborted() + let eventAt: number | undefined + if (firstEventAt === undefined) { + eventAt = this.now() + firstEventAt = eventAt + } switch (event.type) { case 'text': case 'reasoning': { @@ -311,7 +330,9 @@ export class CliComputeService { httpStatus: 413 }) } - if (firstTokenAt === undefined && delta.length > 0) firstTokenAt = this.now() + if (event.type === 'text' && firstTextAt === undefined && delta.length > 0) { + firstTextAt = eventAt ?? this.now() + } if (event.type === 'text') textChunks.push(delta) else reasoningChunks.push(delta) for (const chunk of splitStreamDelta(delta)) { @@ -382,8 +403,12 @@ export class CliComputeService { ...(reasoning ? { reasoning } : {}), ...(usage ? { usage } : {}), finishReason, - durationMs: Math.max(0, this.now() - startedAt), - ttftMs: firstTokenAt === undefined ? null : Math.max(0, firstTokenAt - startedAt) + latency: { + queueMs: Math.max(0, queueFinishedAt - queueStartedAt), + firstEventMs: firstEventAt === undefined ? null : Math.max(0, firstEventAt - startedAt), + firstTextMs: firstTextAt === undefined ? null : Math.max(0, firstTextAt - startedAt), + totalMs: Math.max(0, this.now() - startedAt) + } }) } catch (error) { if (error instanceof CliRequestError) throw error @@ -392,17 +417,20 @@ export class CliComputeService { retriable: true }) } + const metadata = extractProviderFailureMetadata(providerError ?? error) + const failure = ModelInvokeProviderFailureSchema.parse({ + ...(metadata?.statusCode !== undefined ? { statusCode: metadata.statusCode } : {}), + retryable: providerFailureIsRetryable(metadata?.statusCode, metadata?.retryable) + }) this.log.warn('[CLI] Model invocation failed', { providerId: input.providerId, modelId: input.modelId, - failure: - providerError && typeof providerError === 'object' && 'failure' in providerError - ? providerError.failure - : { name: error instanceof Error ? error.name : typeof error } + failure }) throw new CliRequestError('unavailable', 'Model provider request failed', { httpStatus: 503, - retriable: true + retriable: failure.retryable, + details: { providerFailure: failure } }) } } diff --git a/src/shared/contracts/routes/models.routes.ts b/src/shared/contracts/routes/models.routes.ts index 94379fdef..ff9b359c8 100644 --- a/src/shared/contracts/routes/models.routes.ts +++ b/src/shared/contracts/routes/models.routes.ts @@ -22,6 +22,22 @@ export const ModelInvokeUsageSchema = z }) .strict() +export const ModelInvokeLatencySchema = z + .object({ + queueMs: z.number().int().nonnegative(), + firstEventMs: z.number().int().nonnegative().nullable(), + firstTextMs: z.number().int().nonnegative().nullable(), + totalMs: z.number().int().nonnegative() + }) + .strict() + +export const ModelInvokeProviderFailureSchema = z + .object({ + statusCode: z.number().int().min(100).max(599).optional(), + retryable: z.boolean() + }) + .strict() + export const ModelInvokeEventSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('text_delta'), text: z.string().max(1024 * 1024) }).strict(), z.object({ type: z.literal('reasoning_delta'), text: z.string().max(1024 * 1024) }).strict(), @@ -86,8 +102,7 @@ export const modelsInvokeRoute = defineRouteContract({ reasoning: z.string().max(MODEL_INVOKE_MAX_OUTPUT_CHARACTERS).optional(), usage: ModelInvokeUsageSchema.optional(), finishReason: z.enum(['tool_use', 'max_tokens', 'max_turn_requests', 'error', 'complete']), - durationMs: z.number().int().nonnegative(), - ttftMs: z.number().int().nonnegative().nullable() + latency: ModelInvokeLatencySchema }) .strict() .superRefine((output, context) => { diff --git a/test/main/cli/client.test.ts b/test/main/cli/client.test.ts index e309498cf..0b2392067 100644 --- a/test/main/cli/client.test.ts +++ b/test/main/cli/client.test.ts @@ -263,8 +263,7 @@ describe('bundled CLI client', () => { modelId: 'model-1', text: 'Hello', finishReason: 'complete', - durationMs: 10, - ttftMs: 1 + latency: { queueMs: 0, firstEventMs: 1, firstTextMs: 1, totalMs: 10 } } } as const const { userDataPath } = await createClientServer({ stream }) @@ -515,8 +514,7 @@ describe('bundled CLI client', () => { modelId: 'model-1', text: 'safe', finishReason: 'complete', - durationMs: 10, - ttftMs: 1 + latency: { queueMs: 0, firstEventMs: 1, firstTextMs: 1, totalMs: 10 } } }) }) diff --git a/test/main/cli/computeService.test.ts b/test/main/cli/computeService.test.ts index 3ba581692..ba399e489 100644 --- a/test/main/cli/computeService.test.ts +++ b/test/main/cli/computeService.test.ts @@ -68,7 +68,13 @@ async function* streamEvents( for (const event of events) yield event } -function createService(events: readonly LLMCoreStreamEvent[]) { +function createService( + events: readonly LLMCoreStreamEvent[], + options: { + now?: () => number + providerRuntime?: Partial + } = {} +) { const providerSettings: CliComputeServiceOptions['providerSettings'] = { getProviders: vi.fn(() => [provider]), getProviderById: vi.fn(() => provider), @@ -81,11 +87,17 @@ function createService(events: readonly LLMCoreStreamEvent[]) { } const providerRuntime: CliComputeServiceOptions['providerRuntime'] = { executeWithRateLimit: vi.fn(async () => undefined), - streamChat: vi.fn(() => streamEvents(events)) + streamChat: vi.fn(() => streamEvents(events)), + ...options.providerRuntime } const log = { warn: vi.fn() } return { - service: new CliComputeService({ providerSettings, providerRuntime, log, now: () => 100 }), + service: new CliComputeService({ + providerSettings, + providerRuntime, + log, + now: options.now ?? (() => 100) + }), providerSettings, providerRuntime, log @@ -194,17 +206,21 @@ describe('CLI compute service', () => { }) it('streams only typed raw-model events and never enables tools', async () => { - const { service, providerRuntime } = createService([ - { type: 'text', content: 'Hel' }, - { type: 'text', content: '' }, - { type: 'reasoning', reasoning_content: 'Think' }, - { type: 'text', content: 'lo' }, - { - type: 'usage', - usage: { prompt_tokens: 2, completion_tokens: 3, total_tokens: 5 } - }, - { type: 'stop', stop_reason: 'complete' } - ]) + const timestamps = [0, 5, 25, 40, 80] + const { service, providerRuntime } = createService( + [ + { type: 'text', content: 'Hel' }, + { type: 'text', content: '' }, + { type: 'reasoning', reasoning_content: 'Think' }, + { type: 'text', content: 'lo' }, + { + type: 'usage', + usage: { prompt_tokens: 2, completion_tokens: 3, total_tokens: 5 } + }, + { type: 'stop', stop_reason: 'complete' } + ], + { now: () => timestamps.shift() ?? 80 } + ) const emitted: ModelInvokeEvent[] = [] const signal = new AbortController().signal @@ -230,7 +246,8 @@ describe('CLI compute service', () => { text: 'Hello', reasoning: 'Think', usage: { totalTokens: 5 }, - finishReason: 'complete' + finishReason: 'complete', + latency: { queueMs: 20, firstEventMs: 40, firstTextMs: 40, totalMs: 80 } }) expect(emitted.map((event) => event.type)).toEqual([ 'text_delta', @@ -244,9 +261,18 @@ describe('CLI compute service', () => { expect(streamCall?.[7]).toEqual({ signal }) }) - it('does not expose provider error details through the local protocol', async () => { + it('exposes normalized provider failure metadata without upstream text or headers', async () => { const { service, log } = createService([ - { type: 'error', error_message: 'secret upstream response' } + { + type: 'error', + error_message: 'secret upstream response', + failure: { + statusCode: 401, + code: 'SECRET_PROVIDER_CODE', + retryable: false, + retryHeaders: { 'retry-after': 'secret-header-value' } + } + } ]) await expect( @@ -265,10 +291,16 @@ describe('CLI compute service', () => { ).rejects.toMatchObject({ code: 'unavailable', message: 'Model provider request failed', - retriable: true + retriable: false, + options: { + details: { providerFailure: { statusCode: 401, retryable: false } } + } }) expect(log.warn).toHaveBeenCalledOnce() - expect(JSON.stringify(log.warn.mock.calls)).not.toContain('secret upstream response') + const serializedLog = JSON.stringify(log.warn.mock.calls) + expect(serializedLog).not.toContain('secret upstream response') + expect(serializedLog).not.toContain('SECRET_PROVIDER_CODE') + expect(serializedLog).not.toContain('secret-header-value') }) it('rejects tool events instead of turning raw invocation into an Agent run', async () => { diff --git a/test/main/cli/packagedSmoke.test.ts b/test/main/cli/packagedSmoke.test.ts index 527289e0c..226a96280 100644 --- a/test/main/cli/packagedSmoke.test.ts +++ b/test/main/cli/packagedSmoke.test.ts @@ -98,8 +98,7 @@ describe('packaged CLI smoke', () => { modelId: parsedInput.modelId, text: 'fixture reply', finishReason: 'complete', - durationMs: 5, - ttftMs: 1 + latency: { queueMs: 0, firstEventMs: 1, firstTextMs: 1, totalMs: 5 } } } server = new CliServer({ diff --git a/test/main/cli/server.test.ts b/test/main/cli/server.test.ts index 614ae614c..03bbec94a 100644 --- a/test/main/cli/server.test.ts +++ b/test/main/cli/server.test.ts @@ -916,8 +916,7 @@ describe('CLI local transport', () => { modelId: 'model-1', text: 'hello', finishReason: 'complete', - durationMs: 10, - ttftMs: 1 + latency: { queueMs: 0, firstEventMs: 1, firstTextMs: 1, totalMs: 10 } } } }) diff --git a/test/main/cli/transport.test.ts b/test/main/cli/transport.test.ts index 3eecf7b9f..0fceddbf5 100644 --- a/test/main/cli/transport.test.ts +++ b/test/main/cli/transport.test.ts @@ -181,8 +181,7 @@ describe('CLI response transport', () => { modelId: 'model-1', text: 'hello', finishReason: 'complete', - durationMs: 10, - ttftMs: 1 + latency: { queueMs: 0, firstEventMs: 1, firstTextMs: 1, totalMs: 10 } }) ] .map((record) => JSON.stringify(record)) From d5e9d9293a6a42dd93eb2c18d165c555037041bb Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 21:58:57 +0800 Subject: [PATCH 39/51] fix(cli): bind agent commands to surface --- docs/architecture/local-control-plane/plan.md | 15 +- docs/architecture/local-control-plane/spec.md | 30 +- .../architecture/local-control-plane/tasks.md | 3 + docs/guides/cli.md | 6 +- resources/skills/deepchat-cli/SKILL.md | 6 +- src/cli/args.ts | 234 ++------- src/cli/run.ts | 2 +- src/main/cli/agentCommandAccess.ts | 84 ++- src/main/cli/agentTokenAuthority.ts | 25 +- src/main/cli/surface.ts | 2 +- src/main/tool/agentTools/agentBashHandler.ts | 22 +- src/main/tool/agentTools/agentToolManager.ts | 6 +- src/shared/contracts/cliCommands.ts | 490 ++++++++++++++++++ test/main/cli/agentCommandAccess.test.ts | 92 +++- test/main/cli/agentTokenAuthority.test.ts | 71 ++- test/main/cli/args.test.ts | 7 +- test/main/cli/client.test.ts | 8 + test/main/cli/surface.test.ts | 2 +- test/main/scripts/buildCli.test.ts | 6 +- .../tool/agentTools/agentBashHandler.test.ts | 35 +- 20 files changed, 852 insertions(+), 294 deletions(-) create mode 100644 src/shared/contracts/cliCommands.ts diff --git a/docs/architecture/local-control-plane/plan.md b/docs/architecture/local-control-plane/plan.md index 420f28fbb..0ac8c41cf 100644 --- a/docs/architecture/local-control-plane/plan.md +++ b/docs/architecture/local-control-plane/plan.md @@ -75,15 +75,20 @@ 2. Automatically reconcile platform launchers after server startup, with no settings toggle and with explicit, reversible ownership that never overwrites foreign commands or shell content. 3. Add the internal scoped-token issuer, conversation binding, expiry/revocation, call/byte quotas, - and main-enforced Agent restrictions. + and main-enforced Agent restrictions. Derive each token's exact scopes from the shared command + catalog and `CLI_SURFACE`; the issuer has no broad default capability set. 4. Harden `CommandPermissionService` so redirection and compound shell syntax cannot inherit a safe base-command decision. 5. Integrate `deepchat ` without adding `deepchat` to `SAFE_COMMANDS`; reject - prefix-global-flag grammar and deny Agent artifact-byte/output-path access. -6. Add the bundled DeepChat CLI Skill and ensure instructions never expose the human descriptor. -7. Add packaged smoke coverage for diagnostics, raw text, artifact download, OCR, and scoped Agent + prefix-global-flag grammar, deny Agent artifact-byte/output-path access, and make human-only + commands and wrapper processes fail closed without descriptor fallback. Keep `run watch` + human-only to avoid an Agent waiting on its own active run. +6. Prepend the packaged CLI directory to the controlled Agent shell `PATH` while retaining and + de-duplicating existing entries. +7. Add the bundled DeepChat CLI Skill and ensure instructions never expose the human descriptor. +8. Add packaged smoke coverage for diagnostics, raw text, artifact download, OCR, and scoped Agent denial paths without requiring external credentials where fixtures can substitute providers. -8. Complete cross-platform packaging validation and user-facing documentation. +9. Complete cross-platform packaging validation and user-facing documentation. ## Validation Order diff --git a/docs/architecture/local-control-plane/spec.md b/docs/architecture/local-control-plane/spec.md index 5bbc73601..2a31546c1 100644 --- a/docs/architecture/local-control-plane/spec.md +++ b/docs/architecture/local-control-plane/spec.md @@ -166,7 +166,14 @@ is diagnostic information, not authority to launch or kill a process. The human descriptor token authenticates same-user local automation; it is not a defense against arbitrary malware running as the same OS user. Agent invocation receives a short-lived scoped token through the Agent runtime and must not rely on the descriptor token. The CLI must never fall back to -the human descriptor when an Agent-token environment is present but invalid or expired. +the human descriptor when an Agent-token environment is present but invalid, denied, exhausted, or +expired. A recognized direct `deepchat` command that is not Agent-accessible receives an explicitly +invalid Agent-token environment so it fails closed instead of changing principal. + +Every conversation-bound Agent shell process receives that fail-closed token environment by default, +including ordinary commands and wrapper processes. A catalog-approved direct CLI command replaces it +with a one-command capability token. Environment identity and command-rewrite control are separate: +ordinary commands may still use RTK rewriting, while an approved CLI command is preserved verbatim. The bearer token proves possession, not whether a same-UID process is semantically a human or an Agent. A process that can read and deliberately replay the human descriptor can present as a human @@ -504,6 +511,10 @@ The spool is output-only and intentionally smaller than a general asset store: a separate request; - bounded streaming download with backpressure. +For a human descriptor, ownership means the same local application user, not one transient HTTP +connection. `connectionId` remains quota and audit provenance because publication and detached-run +recovery intentionally span separate CLI processes. Agent ownership remains conversation-bound. + Input uploads use a separate private temporary-body utility and never become spool artifacts unless a domain operation deliberately produces a new output artifact. @@ -527,6 +538,10 @@ existing lifecycle, then starts the initial turn. It returns a durable run/sessi streaming. Disconnect does not destroy a detached run; status/messages can be recovered from session state and event cursors. `runs.cancel` is idempotent and ownership checked. +`events.subscribe` is human-only in V1. An Agent can own only its currently executing conversation, +so waiting on that run from its bash tool would deadlock the run on itself. Agent callers may use the +nonblocking owned `runs.get` snapshot and idempotent `runs.cancel`; they cannot invoke `run watch`. + ## CLI Product Contract The command grammar starts with exactly two capability tokens: @@ -535,6 +550,10 @@ The command grammar starts with exactly two capability tokens: deepchat [options] ``` +The local-only `deepchat help` command is the sole one-token exception. It prints static client help +without discovery, authentication, or a main-process request. Per-command help remains +`deepchat --help`; `deepchat help commands` is rejected. + Global output/timeout flags follow the domain and verb, or use environment variables. Forms such as `deepchat --json image generate` are rejected. This is a security contract: the existing shell permission signature takes the base command and next token, but takes a third token when the second @@ -549,6 +568,11 @@ are: 4. effect policy and renderer approval; 5. rate, quota, ownership, and audit enforcement. +The shared command catalog is the sole mapping from ` ` to route identity. Agent +issuance resolves that catalog entry against `CLI_SURFACE`, then mints exactly the route scopes; the +token authority has no broad default-scope fallback. Commands marked human-only or unknown fail +closed without a token that could fall back to human authority. + The first control is a hard dependency, not a decorative outer layer. Its parser must recognize output/input redirection, file-descriptor redirection, command substitution, process substitution, pipelines, separators, and newlines before Agent CLI is enabled. A safe base command must not override @@ -588,6 +612,10 @@ conversation binding, allowed surface scopes, expiry, call/byte quotas, and a ra The token is passed to the CLI invocation environment and is never written to the descriptor or transcript. Main revokes it when the session ends, permission caches clear, or the app stops. +The bundled CLI directory is prepended to the effective controlled shell `PATH`; existing path +entries and other controlled environment values are retained and de-duplicated. Agent integration +must not replace `PATH` with the CLI directory alone. + Agent defaults allow bounded raw compute/media and owned-artifact operations. They deny `sessions.runDetached`, credentials, destructive operations, arbitrary input paths, and output paths. Management mutations are either denied or wait for renderer approval according to the matrix. diff --git a/docs/architecture/local-control-plane/tasks.md b/docs/architecture/local-control-plane/tasks.md index 5b8058be2..2aa6bc617 100644 --- a/docs/architecture/local-control-plane/tasks.md +++ b/docs/architecture/local-control-plane/tasks.md @@ -79,9 +79,12 @@ - [x] Add automatic, idempotent, reversible platform launcher/PATH integration with no settings toggle. - [x] Add in-memory scoped Agent token issuance, expiry, revocation, and quotas. +- [x] Derive Agent token scopes from the shared command catalog and fail closed for human-only + commands, including self-blocking `run watch`. - [x] Harden shell permission checks for redirection and compound syntax before Agent enablement. - [x] Keep `deepchat` out of `SAFE_COMMANDS`, enforce domain/verb-first grammar, and deny Agent artifact-byte/output-path access. +- [x] Prepend the bundled CLI directory while retaining the controlled Agent command `PATH`. - [x] Add the bundled CLI Skill without exposing the human descriptor. - [x] Add bundled diagnostics/compute/artifact/OCR/Agent-policy and desktop-shutdown smoke coverage. diff --git a/docs/guides/cli.md b/docs/guides/cli.md index 4d392a0f9..d4bb86ac1 100644 --- a/docs/guides/cli.md +++ b/docs/guides/cli.md @@ -44,7 +44,7 @@ deepchat --json image generate 查看全部命令或单个命令参数: ```bash -deepchat help commands +deepchat help deepchat image generate --help ``` @@ -177,7 +177,9 @@ deepchat run cancel --run --json ``` `agent run` 先创建 durable detached Session,再启动首轮。CLI 断开不会删除 run;可以通过 -`run get` 恢复消息,通过 cursor 续接 `run watch`。Agent caller 自身不能递归执行 `agent run`。 +`run get` 恢复消息,human caller 可通过 cursor 续接 `run watch`。Agent caller 自身不能递归执行 +`agent run`,也不能等待当前正在执行的自身 run;Agent 仅可使用非阻塞的 `run get` 与幂等的 +`run cancel`。 ## 设置、Skill 与 MCP diff --git a/resources/skills/deepchat-cli/SKILL.md b/resources/skills/deepchat-cli/SKILL.md index c00ded94f..75135ddc1 100644 --- a/resources/skills/deepchat-cli/SKILL.md +++ b/resources/skills/deepchat-cli/SKILL.md @@ -26,7 +26,7 @@ artifacts, Agent runs, and approvals. is injected only after the command has passed the normal shell permission check. - A shell approval authorizes command execution. Sensitive mutations can additionally pause for a renderer approval; wait for that decision and never attempt to manufacture confirmation data. -- Use `deepchat help commands` or `deepchat --help` only when the options below are +- Use `deepchat help` or `deepchat --help` only when the options below are insufficient. Do not probe undocumented routes. ## Agent file and recursion boundaries @@ -35,7 +35,9 @@ artifacts, Agent runs, and approvals. with `artifact describe`. - Do not use `--file`, `--out`, `--overwrite`, `artifact get`, or `artifact delete`. Agent callers cannot upload arbitrary local bytes, download artifact bytes, or choose output paths. -- Do not call `agent run`; an Agent cannot recursively create a detached Agent run. +- Do not call `agent run` or `run watch`. An Agent cannot recursively create a detached Agent run, + and waiting on its own currently executing run would deadlock it. Use `run get` for a nonblocking + snapshot or `run cancel` to request cancellation. - Generated media remains in DeepChat's artifact spool. Return the artifact metadata or ID so the application can render or reuse it. diff --git a/src/cli/args.ts b/src/cli/args.ts index 6a86be4f3..d7fdf5001 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -1,78 +1,29 @@ -import { - cliCapabilitiesRoute, - cliDoctorRoute, - cliStatusRoute, - cliVersionRoute -} from '@shared/contracts/routes/cli.routes' -import { - ArtifactIdSchema, - artifactsDeleteRoute, - artifactsDescribeRoute, - artifactsReadRoute -} from '@shared/contracts/routes/artifacts.routes' +import { ArtifactIdSchema } from '@shared/contracts/routes/artifacts.routes' import { AUDIO_TRANSCRIPTION_MAX_INPUT_BYTES, audioTranscribeArtifactRoute, audioTranscribeUploadRoute } from '@shared/contracts/routes/audio.routes' -import { - modelsGetPublicConfigRoute, - modelsInvokeRoute, - modelsListRuntimeRoute, - modelsResetConfigRoute, - modelsSetPublicConfigRoute, - modelsSetStatusRoute -} from '@shared/contracts/routes/models.routes' -import { - imagesGenerateRoute, - speechGenerateRoute, - videosGenerateRoute -} from '@shared/contracts/routes/media.routes' -import { - mcpAddPublicRoute, - mcpListPublicRoute, - mcpRemovePublicRoute, - mcpSetPublicStatusRoute, - mcpStartPublicRoute, - mcpStopPublicRoute, - mcpUpdatePublicRoute -} from '@shared/contracts/routes/mcp.routes' -import { - providersAddPublicRoute, - providersListPublicRoute, - providersRemoveRoute, - providersSetCredentialRoute, - providersTestPublicConnectionRoute, - providersUpdatePublicRoute -} from '@shared/contracts/routes/providers.routes' import { OCR_EXTRACTION_MAX_INPUT_BYTES, - ocrClearCacheRoute, ocrExtractArtifactRoute, - ocrExtractUploadRoute, - ocrGetRuntimeStatusRoute + ocrExtractUploadRoute } from '@shared/contracts/routes/ocr.routes' -import { - settingsGetPublicRoute, - settingsUpdatePublicRoute -} from '@shared/contracts/routes/settings.routes' import { skillsInstallPublicUrlRoute, - skillsInstallUploadRoute, - skillsListPublicRoute, - skillsSetPublicStatusRoute, - skillsUninstallPublicRoute + skillsInstallUploadRoute } from '@shared/contracts/routes/skills.routes' import { - eventsSubscribeRoute, RUN_MAX_MESSAGE_PAGE_SIZE, RunEventCursorSchema, - RunIdSchema, - runsCancelRoute, - runsGetRoute, - sessionsRunDetachedRoute + RunIdSchema } from '@shared/contracts/routes/runs.routes' import { MessagePageCursorSchema } from '@shared/contracts/common' +import { + cliCommandKey, + getCliCommandDefinition, + type CliRpcContract +} from '@shared/contracts/cliCommands' import { JsonValueSchema, type JsonValue } from '@shared/contracts/json' import { LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS } from '@shared/contracts/localControl' import { @@ -91,53 +42,7 @@ export const DEFAULT_COMPUTE_TIMEOUT_MS = MAX_CLI_TIMEOUT_MS export const DEFAULT_MUTATION_TIMEOUT_MS = 10 * 60_000 export type CliOutputMode = 'text' | 'json' | 'jsonl' -export type CliRpcContract = - | typeof cliStatusRoute - | typeof cliVersionRoute - | typeof cliCapabilitiesRoute - | typeof cliDoctorRoute - | typeof artifactsDescribeRoute - | typeof artifactsReadRoute - | typeof artifactsDeleteRoute - | typeof modelsInvokeRoute - | typeof imagesGenerateRoute - | typeof videosGenerateRoute - | typeof speechGenerateRoute - | typeof audioTranscribeUploadRoute - | typeof audioTranscribeArtifactRoute - | typeof ocrGetRuntimeStatusRoute - | typeof ocrExtractUploadRoute - | typeof ocrExtractArtifactRoute - | typeof ocrClearCacheRoute - | typeof providersListPublicRoute - | typeof providersTestPublicConnectionRoute - | typeof providersAddPublicRoute - | typeof providersUpdatePublicRoute - | typeof providersSetCredentialRoute - | typeof providersRemoveRoute - | typeof modelsListRuntimeRoute - | typeof modelsGetPublicConfigRoute - | typeof modelsSetStatusRoute - | typeof modelsSetPublicConfigRoute - | typeof modelsResetConfigRoute - | typeof settingsGetPublicRoute - | typeof settingsUpdatePublicRoute - | typeof skillsListPublicRoute - | typeof skillsInstallPublicUrlRoute - | typeof skillsInstallUploadRoute - | typeof skillsSetPublicStatusRoute - | typeof skillsUninstallPublicRoute - | typeof mcpListPublicRoute - | typeof mcpAddPublicRoute - | typeof mcpUpdatePublicRoute - | typeof mcpRemovePublicRoute - | typeof mcpSetPublicStatusRoute - | typeof mcpStartPublicRoute - | typeof mcpStopPublicRoute - | typeof sessionsRunDetachedRoute - | typeof runsGetRoute - | typeof runsCancelRoute - | typeof eventsSubscribeRoute +export type { CliRpcContract } from '@shared/contracts/cliCommands' export type CliCommandOperation = 'rpc' | 'stream' | 'upload' | 'download' @@ -157,93 +62,6 @@ export type ParsedCliArguments = Readonly<{ readStdin: boolean }> -const COMMANDS = new Map([ - ['system status', cliStatusRoute], - ['system version', cliVersionRoute], - ['system capabilities', cliCapabilitiesRoute], - ['system doctor', cliDoctorRoute], - ['artifact describe', artifactsDescribeRoute], - ['artifact get', artifactsReadRoute], - ['artifact delete', artifactsDeleteRoute], - ['model invoke', modelsInvokeRoute], - ['image generate', imagesGenerateRoute], - ['video generate', videosGenerateRoute], - ['audio speak', speechGenerateRoute], - ['audio transcribe', audioTranscribeUploadRoute], - ['ocr status', ocrGetRuntimeStatusRoute], - ['ocr extract', ocrExtractUploadRoute], - ['ocr clear-cache', ocrClearCacheRoute], - ['provider list', providersListPublicRoute], - ['provider test', providersTestPublicConnectionRoute], - ['provider add', providersAddPublicRoute], - ['provider update', providersUpdatePublicRoute], - ['provider set-credential', providersSetCredentialRoute], - ['provider clear-credential', providersSetCredentialRoute], - ['provider remove', providersRemoveRoute], - ['model list', modelsListRuntimeRoute], - ['model config-get', modelsGetPublicConfigRoute], - ['model enable', modelsSetStatusRoute], - ['model disable', modelsSetStatusRoute], - ['model config-set', modelsSetPublicConfigRoute], - ['model config-reset', modelsResetConfigRoute], - ['settings get', settingsGetPublicRoute], - ['settings set', settingsUpdatePublicRoute], - ['skill list', skillsListPublicRoute], - ['skill install', skillsInstallPublicUrlRoute], - ['skill enable', skillsSetPublicStatusRoute], - ['skill disable', skillsSetPublicStatusRoute], - ['skill remove', skillsUninstallPublicRoute], - ['mcp list', mcpListPublicRoute], - ['mcp add', mcpAddPublicRoute], - ['mcp update', mcpUpdatePublicRoute], - ['mcp enable', mcpSetPublicStatusRoute], - ['mcp disable', mcpSetPublicStatusRoute], - ['mcp start', mcpStartPublicRoute], - ['mcp stop', mcpStopPublicRoute], - ['mcp remove', mcpRemovePublicRoute], - ['agent run', sessionsRunDetachedRoute], - ['run get', runsGetRoute], - ['run watch', eventsSubscribeRoute], - ['run cancel', runsCancelRoute] -]) - -const LONG_RUNNING_COMMANDS = new Set([ - 'artifact get', - 'model invoke', - 'image generate', - 'video generate', - 'audio speak', - 'audio transcribe', - 'ocr extract', - 'ocr clear-cache', - 'skill install', - 'agent run', - 'run watch' -]) - -const APPROVED_MUTATION_COMMANDS = new Set([ - 'provider add', - 'provider update', - 'provider set-credential', - 'provider clear-credential', - 'provider remove', - 'model enable', - 'model disable', - 'model config-set', - 'model config-reset', - 'settings set', - 'skill enable', - 'skill disable', - 'skill remove', - 'mcp add', - 'mcp update', - 'mcp enable', - 'mcp disable', - 'mcp start', - 'mcp stop', - 'mcp remove' -]) - function parseBoolean(value: string, source: string): boolean { if (value === 'true') return true if (value === 'false') return false @@ -561,16 +379,32 @@ export function parseCliArguments( argv: readonly string[], env: NodeJS.ProcessEnv = process.env ): ParsedCliArguments { + if (argv[0] === 'help') { + if (argv.length !== 1) throw new CliUsageError('Expected: deepchat help') + return { + domain: 'help', + verb: '', + contract: null, + outputMode: parseOutputMode(env[CLI_OUTPUT_ENV]), + timeoutMs: DEFAULT_CLI_TIMEOUT_MS, + helpRequested: true, + operation: 'rpc', + params: {}, + overwrite: false, + readStdin: false + } + } + const domain = argv[0] const verb = argv[1] if (!domain || !verb || domain.startsWith('-') || verb.startsWith('-')) { throw new CliUsageError('Expected: deepchat [options]') } - const commandKey = `${domain} ${verb}` - const isHelpCommand = commandKey === 'help commands' - let contract = COMMANDS.get(commandKey) ?? null - if (!contract && !isHelpCommand) { + const commandKey = cliCommandKey(domain, verb) + const commandDefinition = getCliCommandDefinition(domain, verb) + let contract = commandDefinition?.contract ?? null + if (!contract) { throw new CliUsageError(`Unknown command: deepchat ${domain} ${verb}`) } @@ -578,9 +412,9 @@ export function parseCliArguments( let explicitOutputMode: CliOutputMode | undefined let timeoutMs = env[CLI_TIMEOUT_ENV] ? parseTimeout(env[CLI_TIMEOUT_ENV], CLI_TIMEOUT_ENV) - : LONG_RUNNING_COMMANDS.has(commandKey) + : commandDefinition?.timeoutClass === 'long-running' ? DEFAULT_COMPUTE_TIMEOUT_MS - : APPROVED_MUTATION_COMMANDS.has(commandKey) + : commandDefinition?.timeoutClass === 'approved-mutation' ? DEFAULT_MUTATION_TIMEOUT_MS : DEFAULT_CLI_TIMEOUT_MS let timeoutSeen = false @@ -1128,7 +962,7 @@ export function parseCliArguments( contract, outputMode, timeoutMs, - helpRequested: helpRequested || isHelpCommand, + helpRequested, operation: commandKey === 'artifact get' ? 'download' @@ -1322,7 +1156,7 @@ export function formatCliHelp(command?: Pick> + prependPath: readonly string[] + preserveCommand: boolean +}> + export type AgentCliCommandAccessOptions = Readonly<{ tokenAuthority: Pick commandPermission: Pick resolveCliDirectory(): string | null }> +type AgentCliCommandCapability = Readonly<{ + scopes: readonly LocalControlScope[] +}> + +function createAgentCliCommandRegistry(): ReadonlyMap { + const registry = new Map() + for (const definition of CLI_COMMAND_DEFINITIONS) { + const contract = getAgentCliCommandContract(definition) + if (!contract) continue + const surface = getCliSurfaceEntry(contract.name) + if (!surface || !surface.callers.includes('agent') || surface.scopes.length === 0) { + throw new Error( + `Agent CLI command is not backed by an Agent-accessible surface: ${definition.domain} ${definition.verb}` + ) + } + registry.set(cliCommandKey(definition.domain, definition.verb), { scopes: surface.scopes }) + } + return registry +} + +const AGENT_CLI_COMMANDS = createAgentCliCommandRegistry() + +function unprivilegedAgentEnvironment(preserveCommand = false): AgentCommandEnvironment { + return { + variables: { [LOCAL_CONTROL_AGENT_TOKEN_ENV]: '' }, + prependPath: [], + preserveCommand + } +} + +function localAgentEnvironment(cliDirectory: string | null): AgentCommandEnvironment { + return { + variables: { [LOCAL_CONTROL_AGENT_TOKEN_ENV]: '' }, + prependPath: cliDirectory ? [cliDirectory] : [], + preserveCommand: true + } +} + export function resolveBundledCliDirectory( input: Readonly<{ appPath: string @@ -38,29 +91,40 @@ export function resolveBundledCliDirectory( export class AgentCliCommandAccess { constructor(private readonly options: AgentCliCommandAccessOptions) {} - createEnvironment(conversationId: string, command: string): Record | undefined { + createEnvironment(conversationId: string, command: string): AgentCommandEnvironment | undefined { const normalizedConversationId = conversationId.trim() const normalizedCommand = command.trim() + if (!normalizedConversationId) return undefined + if (this.options.commandPermission.extractBaseCommand(normalizedCommand) !== 'deepchat') { + return unprivilegedAgentEnvironment() + } + if (normalizedCommand === 'deepchat help') { + return localAgentEnvironment(this.options.resolveCliDirectory()) + } + + const commandMatch = AGENT_CLI_COMMAND_PATTERN.exec(normalizedCommand) if ( - !normalizedConversationId || - this.options.commandPermission.extractBaseCommand(normalizedCommand) !== 'deepchat' || this.options.commandPermission.hasShellControlSyntax(normalizedCommand) || - !AGENT_CLI_COMMAND_PATTERN.test(normalizedCommand) || + !commandMatch || normalizedCommand.includes(LOCAL_CONTROL_AGENT_TOKEN_ENV) ) { - return undefined + return unprivilegedAgentEnvironment(true) } + const capability = AGENT_CLI_COMMANDS.get(cliCommandKey(commandMatch[1], commandMatch[2])) + if (!capability) return unprivilegedAgentEnvironment(true) const cliDirectory = this.options.resolveCliDirectory() - if (!cliDirectory) return undefined + if (!cliDirectory) return unprivilegedAgentEnvironment(true) const issued = this.options.tokenAuthority.issue({ conversationId: normalizedConversationId, + scopes: capability.scopes, ttlMs: AGENT_CLI_COMMAND_TOKEN_TTL_MS, maxCalls: 1 }) return { - [LOCAL_CONTROL_AGENT_TOKEN_ENV]: issued.token, - PATH: cliDirectory + variables: { [LOCAL_CONTROL_AGENT_TOKEN_ENV]: issued.token }, + prependPath: [cliDirectory], + preserveCommand: true } } } diff --git a/src/main/cli/agentTokenAuthority.ts b/src/main/cli/agentTokenAuthority.ts index 30bf09233..f5b93ab25 100644 --- a/src/main/cli/agentTokenAuthority.ts +++ b/src/main/cli/agentTokenAuthority.ts @@ -15,26 +15,6 @@ export const MAX_AGENT_CLI_TOKEN_BYTES = 1024 * 1024 * 1024 const DEFAULT_MAX_TOKENS = 256 const DEFAULT_MAX_TOKENS_PER_CONVERSATION = 8 -export const DEFAULT_AGENT_CLI_SCOPES = [ - 'system:read', - 'models:read', - 'models:invoke', - 'media:generate', - 'audio:transcribe', - 'ocr:read', - 'ocr:extract', - 'runs:read', - 'runs:cancel', - 'artifacts:read', - 'settings:read', - 'settings:write', - 'providers:read', - 'skills:read', - 'skills:write', - 'mcp:read', - 'mcp:write' -] as const satisfies readonly LocalControlScope[] - export type AgentCliTokenClaims = Readonly<{ tokenId: string conversationId: string @@ -137,14 +117,15 @@ export class AgentCliTokenAuthority { issue( input: Readonly<{ conversationId: string - scopes?: readonly LocalControlScope[] + scopes: readonly LocalControlScope[] ttlMs?: number maxCalls?: number maxBytes?: number }> ): IssuedAgentCliToken { const conversationId = normalizeConversationId(input.conversationId) - const scopes = LocalControlScopesSchema.parse([...(input.scopes ?? DEFAULT_AGENT_CLI_SCOPES)]) + const scopes = LocalControlScopesSchema.parse([...input.scopes]) + if (scopes.length === 0) throw new Error('scopes must contain at least one capability') const ttlMs = boundedPositiveSafeInteger( input.ttlMs ?? DEFAULT_AGENT_CLI_TOKEN_TTL_MS, MAX_AGENT_CLI_TOKEN_TTL_MS, diff --git a/src/main/cli/surface.ts b/src/main/cli/surface.ts index 0fd5c7cf7..f1b9a455b 100644 --- a/src/main/cli/surface.ts +++ b/src/main/cli/surface.ts @@ -576,7 +576,7 @@ const CLI_SURFACE_V1_ENTRIES = [ { contract: eventsSubscribeRoute, effect: 'read', - callers: ['human', 'agent'], + callers: ['human'], scopes: ['runs:read'], transport: 'stream', approval: 'never', diff --git a/src/main/tool/agentTools/agentBashHandler.ts b/src/main/tool/agentTools/agentBashHandler.ts index bc977c22d..2b8f4622a 100644 --- a/src/main/tool/agentTools/agentBashHandler.ts +++ b/src/main/tool/agentTools/agentBashHandler.ts @@ -14,7 +14,7 @@ import { RTK_ENABLED_SETTING_KEY, rtkRuntimeService } from '@/agent/shared/process/rtkRuntimeService' -import { getUserShell } from '@/agent/shared/process/shellEnvHelper' +import { getUserShell, mergeCommandEnvironment } from '@/agent/shared/process/shellEnvHelper' import { createUtf8OutputDecoderPair, prepareShellCommandForUtf8Output @@ -51,7 +51,16 @@ export interface ExecuteCommandOptions { } export interface AgentCommandEnvironmentPort { - createEnvironment(conversationId: string, command: string): Record | undefined + createEnvironment( + conversationId: string, + command: string + ): + | Readonly<{ + variables: Readonly> + prependPath: readonly string[] + preserveCommand: boolean + }> + | undefined } interface PreparedCommand { @@ -654,8 +663,13 @@ export class AgentBashHandler { : undefined if (!scopedEnvironment) return { env: options.env, preserveCommand: false } return { - env: { ...options.env, ...scopedEnvironment }, - preserveCommand: true + env: mergeCommandEnvironment({ + processEnv: process.env, + overrides: { ...options.env, ...scopedEnvironment.variables }, + prependPathSources: [...scopedEnvironment.prependPath], + includeDefaultPaths: false + }), + preserveCommand: scopedEnvironment.preserveCommand } } diff --git a/src/main/tool/agentTools/agentToolManager.ts b/src/main/tool/agentTools/agentToolManager.ts index bb673a868..e2ed01cff 100644 --- a/src/main/tool/agentTools/agentToolManager.ts +++ b/src/main/tool/agentTools/agentToolManager.ts @@ -366,7 +366,8 @@ export class AgentToolManager { this.bashHandler = new AgentBashHandler( [this.agentWorkspacePath], this.settings, - this.commandPermissionHandler + this.commandPermissionHandler, + this.commandEnvironment ) } } @@ -389,7 +390,8 @@ export class AgentToolManager { this.bashHandler = new AgentBashHandler( [effectiveWorkspacePath], this.settings, - this.commandPermissionHandler + this.commandPermissionHandler, + this.commandEnvironment ) } else { this.fileSystemHandler = null diff --git a/src/shared/contracts/cliCommands.ts b/src/shared/contracts/cliCommands.ts new file mode 100644 index 000000000..917615d29 --- /dev/null +++ b/src/shared/contracts/cliCommands.ts @@ -0,0 +1,490 @@ +import { + cliCapabilitiesRoute, + cliDoctorRoute, + cliStatusRoute, + cliVersionRoute +} from './routes/cli.routes' +import { + artifactsDeleteRoute, + artifactsDescribeRoute, + artifactsReadRoute +} from './routes/artifacts.routes' +import { audioTranscribeArtifactRoute, audioTranscribeUploadRoute } from './routes/audio.routes' +import { + modelsGetPublicConfigRoute, + modelsInvokeRoute, + modelsListRuntimeRoute, + modelsResetConfigRoute, + modelsSetPublicConfigRoute, + modelsSetStatusRoute +} from './routes/models.routes' +import { + imagesGenerateRoute, + speechGenerateRoute, + videosGenerateRoute +} from './routes/media.routes' +import { + mcpAddPublicRoute, + mcpListPublicRoute, + mcpRemovePublicRoute, + mcpSetPublicStatusRoute, + mcpStartPublicRoute, + mcpStopPublicRoute, + mcpUpdatePublicRoute +} from './routes/mcp.routes' +import { + ocrClearCacheRoute, + ocrExtractArtifactRoute, + ocrExtractUploadRoute, + ocrGetRuntimeStatusRoute +} from './routes/ocr.routes' +import { + providersAddPublicRoute, + providersListPublicRoute, + providersRemoveRoute, + providersSetCredentialRoute, + providersTestPublicConnectionRoute, + providersUpdatePublicRoute +} from './routes/providers.routes' +import { + eventsSubscribeRoute, + runsCancelRoute, + runsGetRoute, + sessionsRunDetachedRoute +} from './routes/runs.routes' +import { settingsGetPublicRoute, settingsUpdatePublicRoute } from './routes/settings.routes' +import { + skillsInstallPublicUrlRoute, + skillsInstallUploadRoute, + skillsListPublicRoute, + skillsSetPublicStatusRoute, + skillsUninstallPublicRoute +} from './routes/skills.routes' + +export type CliRpcContract = + | typeof cliStatusRoute + | typeof cliVersionRoute + | typeof cliCapabilitiesRoute + | typeof cliDoctorRoute + | typeof artifactsDescribeRoute + | typeof artifactsReadRoute + | typeof artifactsDeleteRoute + | typeof modelsInvokeRoute + | typeof imagesGenerateRoute + | typeof videosGenerateRoute + | typeof speechGenerateRoute + | typeof audioTranscribeUploadRoute + | typeof audioTranscribeArtifactRoute + | typeof ocrGetRuntimeStatusRoute + | typeof ocrExtractUploadRoute + | typeof ocrExtractArtifactRoute + | typeof ocrClearCacheRoute + | typeof providersListPublicRoute + | typeof providersTestPublicConnectionRoute + | typeof providersAddPublicRoute + | typeof providersUpdatePublicRoute + | typeof providersSetCredentialRoute + | typeof providersRemoveRoute + | typeof modelsListRuntimeRoute + | typeof modelsGetPublicConfigRoute + | typeof modelsSetStatusRoute + | typeof modelsSetPublicConfigRoute + | typeof modelsResetConfigRoute + | typeof settingsGetPublicRoute + | typeof settingsUpdatePublicRoute + | typeof skillsListPublicRoute + | typeof skillsInstallPublicUrlRoute + | typeof skillsInstallUploadRoute + | typeof skillsSetPublicStatusRoute + | typeof skillsUninstallPublicRoute + | typeof mcpListPublicRoute + | typeof mcpAddPublicRoute + | typeof mcpUpdatePublicRoute + | typeof mcpRemovePublicRoute + | typeof mcpSetPublicStatusRoute + | typeof mcpStartPublicRoute + | typeof mcpStopPublicRoute + | typeof sessionsRunDetachedRoute + | typeof runsGetRoute + | typeof runsCancelRoute + | typeof eventsSubscribeRoute + +export type CliCommandTimeoutClass = 'standard' | 'long-running' | 'approved-mutation' +export type CliAgentInvocation = 'deny' | 'allow' | Readonly<{ contract: CliRpcContract }> + +export type CliCommandDefinition = Readonly<{ + domain: string + verb: string + contract: CliRpcContract + timeoutClass: CliCommandTimeoutClass + agentInvocation: CliAgentInvocation +}> + +const standard = 'standard' as const +const longRunning = 'long-running' as const +const approvedMutation = 'approved-mutation' as const + +export const CLI_COMMAND_DEFINITIONS = [ + { + domain: 'system', + verb: 'status', + contract: cliStatusRoute, + timeoutClass: standard, + agentInvocation: 'allow' + }, + { + domain: 'system', + verb: 'version', + contract: cliVersionRoute, + timeoutClass: standard, + agentInvocation: 'allow' + }, + { + domain: 'system', + verb: 'capabilities', + contract: cliCapabilitiesRoute, + timeoutClass: standard, + agentInvocation: 'allow' + }, + { + domain: 'system', + verb: 'doctor', + contract: cliDoctorRoute, + timeoutClass: standard, + agentInvocation: 'allow' + }, + { + domain: 'artifact', + verb: 'describe', + contract: artifactsDescribeRoute, + timeoutClass: standard, + agentInvocation: 'allow' + }, + { + domain: 'artifact', + verb: 'get', + contract: artifactsReadRoute, + timeoutClass: longRunning, + agentInvocation: 'deny' + }, + { + domain: 'artifact', + verb: 'delete', + contract: artifactsDeleteRoute, + timeoutClass: standard, + agentInvocation: 'deny' + }, + { + domain: 'model', + verb: 'invoke', + contract: modelsInvokeRoute, + timeoutClass: longRunning, + agentInvocation: 'allow' + }, + { + domain: 'image', + verb: 'generate', + contract: imagesGenerateRoute, + timeoutClass: longRunning, + agentInvocation: 'allow' + }, + { + domain: 'video', + verb: 'generate', + contract: videosGenerateRoute, + timeoutClass: longRunning, + agentInvocation: 'allow' + }, + { + domain: 'audio', + verb: 'speak', + contract: speechGenerateRoute, + timeoutClass: longRunning, + agentInvocation: 'allow' + }, + { + domain: 'audio', + verb: 'transcribe', + contract: audioTranscribeUploadRoute, + timeoutClass: longRunning, + agentInvocation: { contract: audioTranscribeArtifactRoute } + }, + { + domain: 'ocr', + verb: 'status', + contract: ocrGetRuntimeStatusRoute, + timeoutClass: standard, + agentInvocation: 'allow' + }, + { + domain: 'ocr', + verb: 'extract', + contract: ocrExtractUploadRoute, + timeoutClass: longRunning, + agentInvocation: { contract: ocrExtractArtifactRoute } + }, + { + domain: 'ocr', + verb: 'clear-cache', + contract: ocrClearCacheRoute, + timeoutClass: longRunning, + agentInvocation: 'deny' + }, + { + domain: 'provider', + verb: 'list', + contract: providersListPublicRoute, + timeoutClass: standard, + agentInvocation: 'allow' + }, + { + domain: 'provider', + verb: 'test', + contract: providersTestPublicConnectionRoute, + timeoutClass: standard, + agentInvocation: 'deny' + }, + { + domain: 'provider', + verb: 'add', + contract: providersAddPublicRoute, + timeoutClass: approvedMutation, + agentInvocation: 'deny' + }, + { + domain: 'provider', + verb: 'update', + contract: providersUpdatePublicRoute, + timeoutClass: approvedMutation, + agentInvocation: 'deny' + }, + { + domain: 'provider', + verb: 'set-credential', + contract: providersSetCredentialRoute, + timeoutClass: approvedMutation, + agentInvocation: 'deny' + }, + { + domain: 'provider', + verb: 'clear-credential', + contract: providersSetCredentialRoute, + timeoutClass: approvedMutation, + agentInvocation: 'deny' + }, + { + domain: 'provider', + verb: 'remove', + contract: providersRemoveRoute, + timeoutClass: approvedMutation, + agentInvocation: 'deny' + }, + { + domain: 'model', + verb: 'list', + contract: modelsListRuntimeRoute, + timeoutClass: standard, + agentInvocation: 'allow' + }, + { + domain: 'model', + verb: 'config-get', + contract: modelsGetPublicConfigRoute, + timeoutClass: standard, + agentInvocation: 'allow' + }, + { + domain: 'model', + verb: 'enable', + contract: modelsSetStatusRoute, + timeoutClass: approvedMutation, + agentInvocation: 'deny' + }, + { + domain: 'model', + verb: 'disable', + contract: modelsSetStatusRoute, + timeoutClass: approvedMutation, + agentInvocation: 'deny' + }, + { + domain: 'model', + verb: 'config-set', + contract: modelsSetPublicConfigRoute, + timeoutClass: approvedMutation, + agentInvocation: 'deny' + }, + { + domain: 'model', + verb: 'config-reset', + contract: modelsResetConfigRoute, + timeoutClass: approvedMutation, + agentInvocation: 'deny' + }, + { + domain: 'settings', + verb: 'get', + contract: settingsGetPublicRoute, + timeoutClass: standard, + agentInvocation: 'allow' + }, + { + domain: 'settings', + verb: 'set', + contract: settingsUpdatePublicRoute, + timeoutClass: approvedMutation, + agentInvocation: 'allow' + }, + { + domain: 'skill', + verb: 'list', + contract: skillsListPublicRoute, + timeoutClass: standard, + agentInvocation: 'allow' + }, + { + domain: 'skill', + verb: 'install', + contract: skillsInstallPublicUrlRoute, + timeoutClass: longRunning, + agentInvocation: 'allow' + }, + { + domain: 'skill', + verb: 'enable', + contract: skillsSetPublicStatusRoute, + timeoutClass: approvedMutation, + agentInvocation: 'deny' + }, + { + domain: 'skill', + verb: 'disable', + contract: skillsSetPublicStatusRoute, + timeoutClass: approvedMutation, + agentInvocation: 'deny' + }, + { + domain: 'skill', + verb: 'remove', + contract: skillsUninstallPublicRoute, + timeoutClass: approvedMutation, + agentInvocation: 'deny' + }, + { + domain: 'mcp', + verb: 'list', + contract: mcpListPublicRoute, + timeoutClass: standard, + agentInvocation: 'allow' + }, + { + domain: 'mcp', + verb: 'add', + contract: mcpAddPublicRoute, + timeoutClass: approvedMutation, + agentInvocation: 'allow' + }, + { + domain: 'mcp', + verb: 'update', + contract: mcpUpdatePublicRoute, + timeoutClass: approvedMutation, + agentInvocation: 'deny' + }, + { + domain: 'mcp', + verb: 'enable', + contract: mcpSetPublicStatusRoute, + timeoutClass: approvedMutation, + agentInvocation: 'deny' + }, + { + domain: 'mcp', + verb: 'disable', + contract: mcpSetPublicStatusRoute, + timeoutClass: approvedMutation, + agentInvocation: 'deny' + }, + { + domain: 'mcp', + verb: 'start', + contract: mcpStartPublicRoute, + timeoutClass: approvedMutation, + agentInvocation: 'deny' + }, + { + domain: 'mcp', + verb: 'stop', + contract: mcpStopPublicRoute, + timeoutClass: approvedMutation, + agentInvocation: 'deny' + }, + { + domain: 'mcp', + verb: 'remove', + contract: mcpRemovePublicRoute, + timeoutClass: approvedMutation, + agentInvocation: 'deny' + }, + { + domain: 'agent', + verb: 'run', + contract: sessionsRunDetachedRoute, + timeoutClass: longRunning, + agentInvocation: 'deny' + }, + { + domain: 'run', + verb: 'get', + contract: runsGetRoute, + timeoutClass: standard, + agentInvocation: 'allow' + }, + { + domain: 'run', + verb: 'watch', + contract: eventsSubscribeRoute, + timeoutClass: longRunning, + agentInvocation: 'deny' + }, + { + domain: 'run', + verb: 'cancel', + contract: runsCancelRoute, + timeoutClass: standard, + agentInvocation: 'allow' + } +] as const satisfies readonly CliCommandDefinition[] + +export function cliCommandKey(domain: string, verb: string): string { + return `${domain} ${verb}` +} + +function createCliCommandRegistry( + definitions: readonly CliCommandDefinition[] +): ReadonlyMap { + const registry = new Map() + for (const definition of definitions) { + const key = cliCommandKey(definition.domain, definition.verb) + if (registry.has(key)) throw new Error(`Duplicate CLI command definition: ${key}`) + registry.set(key, definition) + } + return registry +} + +export const CLI_COMMAND_REGISTRY = createCliCommandRegistry(CLI_COMMAND_DEFINITIONS) + +export function getCliCommandDefinition( + domain: string, + verb: string +): CliCommandDefinition | undefined { + return CLI_COMMAND_REGISTRY.get(cliCommandKey(domain, verb)) +} + +export function getAgentCliCommandContract( + definition: CliCommandDefinition +): CliRpcContract | undefined { + if (definition.agentInvocation === 'deny') return undefined + if (definition.agentInvocation === 'allow') return definition.contract + return definition.agentInvocation.contract +} diff --git a/test/main/cli/agentCommandAccess.test.ts b/test/main/cli/agentCommandAccess.test.ts index 0171265f4..feb08b0bf 100644 --- a/test/main/cli/agentCommandAccess.test.ts +++ b/test/main/cli/agentCommandAccess.test.ts @@ -45,8 +45,9 @@ describe('AgentCliCommandAccess', () => { ) expect(environment).toEqual({ - [LOCAL_CONTROL_AGENT_TOKEN_ENV]: agentToken, - PATH: directory + variables: { [LOCAL_CONTROL_AGENT_TOKEN_ENV]: agentToken }, + prependPath: [directory], + preserveCommand: true }) const first = authority.beginRequest(agentToken) expect(first.status).toBe('granted') @@ -54,7 +55,7 @@ describe('AgentCliCommandAccess', () => { expect(first.grant.claims).toMatchObject({ conversationId: 'conversation-1', expiresAt: 301_000, - scopes: expect.arrayContaining(['models:invoke']) + scopes: ['models:invoke'] }) first.grant.release() expect(authority.beginRequest(agentToken)).toEqual({ status: 'quota-exhausted' }) @@ -63,12 +64,14 @@ describe('AgentCliCommandAccess', () => { it.each([ 'deepchat --json model invoke', 'deepchat model', + 'deepchat run watch --run conversation-1', + 'deepchat provider remove --provider provider-1', + 'deepchat unknown command', 'deepchat model invoke > output.txt', 'deepchat model invoke | tee output.txt', 'FOO=bar deepchat model invoke', - `deepchat model invoke --prompt $${LOCAL_CONTROL_AGENT_TOKEN_ENV}`, - 'ls -la' - ])('does not issue authority for %j', async (command) => { + `deepchat model invoke --prompt $${LOCAL_CONTROL_AGENT_TOKEN_ENV}` + ])('blocks human-token fallback without issuing authority for %j', async (command) => { const { directory } = await createCliDirectory() const authority = new AgentCliTokenAuthority() const access = new AgentCliCommandAccess({ @@ -77,10 +80,79 @@ describe('AgentCliCommandAccess', () => { resolveCliDirectory: () => directory }) - expect(access.createEnvironment('conversation-1', command)).toBeUndefined() + expect(access.createEnvironment('conversation-1', command)).toEqual({ + variables: { [LOCAL_CONTROL_AGENT_TOKEN_ENV]: '' }, + prependPath: [], + preserveCommand: true + }) expect(authority.snapshot()).toEqual({ tokens: 0, conversations: 0 }) }) + it('marks non-CLI commands as unprivileged without suppressing command rewriting', async () => { + const { directory } = await createCliDirectory() + const authority = new AgentCliTokenAuthority() + const access = new AgentCliCommandAccess({ + tokenAuthority: authority, + commandPermission: new CommandPermissionService(), + resolveCliDirectory: () => directory + }) + + expect(access.createEnvironment('conversation-1', 'ls -la')).toEqual({ + variables: { [LOCAL_CONTROL_AGENT_TOKEN_ENV]: '' }, + prependPath: [], + preserveCommand: false + }) + expect(access.createEnvironment('conversation-1', '"deepchat" model invoke')).toEqual({ + variables: { [LOCAL_CONTROL_AGENT_TOKEN_ENV]: '' }, + prependPath: [], + preserveCommand: false + }) + expect(authority.snapshot()).toEqual({ tokens: 0, conversations: 0 }) + }) + + it('resolves local help through the bundled launcher without granting authority', async () => { + const { directory } = await createCliDirectory() + const authority = new AgentCliTokenAuthority() + const access = new AgentCliCommandAccess({ + tokenAuthority: authority, + commandPermission: new CommandPermissionService(), + resolveCliDirectory: () => directory + }) + + expect(access.createEnvironment('conversation-1', 'deepchat help')).toEqual({ + variables: { [LOCAL_CONTROL_AGENT_TOKEN_ENV]: '' }, + prependPath: [directory], + preserveCommand: true + }) + expect(authority.snapshot()).toEqual({ tokens: 0, conversations: 0 }) + }) + + it('derives dynamic artifact command scopes from the Agent surface', async () => { + const { directory } = await createCliDirectory() + const agentToken = 'b'.repeat(43) + const authority = new AgentCliTokenAuthority({ + createToken: () => agentToken, + createTokenId: () => 'token-id-conversation-1' + }) + const access = new AgentCliCommandAccess({ + tokenAuthority: authority, + commandPermission: new CommandPermissionService(), + resolveCliDirectory: () => directory + }) + + expect( + access.createEnvironment( + 'conversation-1', + 'deepchat audio transcribe --artifact artifact-1 --provider p --model m' + ) + ).toMatchObject({ variables: { [LOCAL_CONTROL_AGENT_TOKEN_ENV]: agentToken } }) + const request = authority.beginRequest(agentToken) + expect(request.status).toBe('granted') + if (request.status !== 'granted') throw new Error('Expected Agent CLI grant') + expect(request.grant.claims.scopes).toEqual(['audio:transcribe', 'artifacts:read']) + request.grant.release() + }) + it('fails closed without a built launcher', () => { const authority = new AgentCliTokenAuthority() const access = new AgentCliCommandAccess({ @@ -89,7 +161,11 @@ describe('AgentCliCommandAccess', () => { resolveCliDirectory: () => null }) - expect(access.createEnvironment('conversation-1', 'deepchat cli status')).toBeUndefined() + expect(access.createEnvironment('conversation-1', 'deepchat system status')).toEqual({ + variables: { [LOCAL_CONTROL_AGENT_TOKEN_ENV]: '' }, + prependPath: [], + preserveCommand: true + }) expect(authority.snapshot()).toEqual({ tokens: 0, conversations: 0 }) }) }) diff --git a/test/main/cli/agentTokenAuthority.test.ts b/test/main/cli/agentTokenAuthority.test.ts index b42baa63f..931c1602a 100644 --- a/test/main/cli/agentTokenAuthority.test.ts +++ b/test/main/cli/agentTokenAuthority.test.ts @@ -5,6 +5,8 @@ function token(character: string): string { return character.repeat(43) } +const TEST_SCOPES = ['system:read'] as const + describe('AgentCliTokenAuthority', () => { it('issues bounded in-memory claims and consumes call and byte quotas', () => { let now = 1_000 @@ -53,9 +55,9 @@ describe('AgentCliTokenAuthority', () => { createToken: () => generatedTokens.shift()!, createTokenId: () => `token-id-${generatedTokens.length}`.padEnd(16, '0') }) - const first = authority.issue({ conversationId: 'conversation-1' }) - const second = authority.issue({ conversationId: 'conversation-1' }) - const other = authority.issue({ conversationId: 'conversation-2' }) + const first = authority.issue({ conversationId: 'conversation-1', scopes: TEST_SCOPES }) + const second = authority.issue({ conversationId: 'conversation-1', scopes: TEST_SCOPES }) + const other = authority.issue({ conversationId: 'conversation-2', scopes: TEST_SCOPES }) const active = authority.beginRequest(first.token) if (active.status !== 'granted') throw new Error('Expected grant') const abort = vi.fn() @@ -78,15 +80,15 @@ describe('AgentCliTokenAuthority', () => { maxTokens: 2, maxTokensPerConversation: 1 }) - const first = authority.issue({ conversationId: 'conversation-1' }) - const replacement = authority.issue({ conversationId: 'conversation-1' }) + const first = authority.issue({ conversationId: 'conversation-1', scopes: TEST_SCOPES }) + const replacement = authority.issue({ conversationId: 'conversation-1', scopes: TEST_SCOPES }) expect(authority.beginRequest(first.token)).toEqual({ status: 'invalid' }) expect(authority.beginRequest(replacement.token).status).toBe('granted') - authority.issue({ conversationId: 'conversation-2' }) - expect(() => authority.issue({ conversationId: 'conversation-3' })).toThrow( - AgentCliTokenCapacityError - ) + authority.issue({ conversationId: 'conversation-2', scopes: TEST_SCOPES }) + expect(() => + authority.issue({ conversationId: 'conversation-3', scopes: TEST_SCOPES }) + ).toThrow(AgentCliTokenCapacityError) }) it('reclaims completed exhausted grants before enforcing global capacity', () => { @@ -97,15 +99,19 @@ describe('AgentCliTokenAuthority', () => { createTokenId: () => `token-id-${String((tokenId += 1)).padStart(8, '0')}`, maxTokens: 1 }) - const first = authority.issue({ conversationId: 'conversation-1', maxCalls: 1 }) + const first = authority.issue({ + conversationId: 'conversation-1', + scopes: TEST_SCOPES, + maxCalls: 1 + }) const active = authority.beginRequest(first.token) if (active.status !== 'granted') throw new Error('Expected grant') - expect(() => authority.issue({ conversationId: 'conversation-2' })).toThrow( - AgentCliTokenCapacityError - ) + expect(() => + authority.issue({ conversationId: 'conversation-2', scopes: TEST_SCOPES }) + ).toThrow(AgentCliTokenCapacityError) active.grant.release() - const second = authority.issue({ conversationId: 'conversation-2' }) + const second = authority.issue({ conversationId: 'conversation-2', scopes: TEST_SCOPES }) expect(authority.beginRequest(first.token)).toEqual({ status: 'invalid' }) expect(authority.beginRequest(second.token).status).toBe('granted') @@ -117,7 +123,18 @@ describe('AgentCliTokenAuthority', () => { createTokenId: () => 'token-id-1234567890' }) - expect(() => authority.issue({ conversationId: 'conversation-1' })).toThrow() + expect(() => + authority.issue({ conversationId: 'conversation-1', scopes: TEST_SCOPES }) + ).toThrow() + expect(authority.snapshot()).toEqual({ tokens: 0, conversations: 0 }) + }) + + it('requires every issued token to carry an explicit nonempty scope set', () => { + const authority = new AgentCliTokenAuthority() + + expect(() => authority.issue({ conversationId: 'conversation-1', scopes: [] })).toThrow( + 'scopes must contain at least one capability' + ) expect(authority.snapshot()).toEqual({ tokens: 0, conversations: 0 }) }) @@ -129,9 +146,11 @@ describe('AgentCliTokenAuthority', () => { createTokenId: () => `token-id-${String((tokenId += 1)).padStart(8, '0')}`, maxTokensPerConversation: 1 }) - const existing = authority.issue({ conversationId: 'conversation-1' }) + const existing = authority.issue({ conversationId: 'conversation-1', scopes: TEST_SCOPES }) - expect(() => authority.issue({ conversationId: 'conversation-1' })).toThrow() + expect(() => + authority.issue({ conversationId: 'conversation-1', scopes: TEST_SCOPES }) + ).toThrow() expect(authority.beginRequest(existing.token).status).toBe('granted') }) @@ -139,13 +158,21 @@ describe('AgentCliTokenAuthority', () => { const authority = new AgentCliTokenAuthority() expect(() => - authority.issue({ conversationId: 'conversation-1', ttlMs: 60 * 60_000 + 1 }) + authority.issue({ + conversationId: 'conversation-1', + scopes: TEST_SCOPES, + ttlMs: 60 * 60_000 + 1 + }) ).toThrow('ttlMs exceeds') - expect(() => authority.issue({ conversationId: 'conversation-1', maxCalls: 1025 })).toThrow( - 'maxCalls exceeds' - ) expect(() => - authority.issue({ conversationId: 'conversation-1', maxBytes: 1024 * 1024 * 1024 + 1 }) + authority.issue({ conversationId: 'conversation-1', scopes: TEST_SCOPES, maxCalls: 1025 }) + ).toThrow('maxCalls exceeds') + expect(() => + authority.issue({ + conversationId: 'conversation-1', + scopes: TEST_SCOPES, + maxBytes: 1024 * 1024 * 1024 + 1 + }) ).toThrow('maxBytes exceeds') expect(authority.snapshot()).toEqual({ tokens: 0, conversations: 0 }) }) diff --git a/test/main/cli/args.test.ts b/test/main/cli/args.test.ts index 0a92afb39..2e9628e72 100644 --- a/test/main/cli/args.test.ts +++ b/test/main/cli/args.test.ts @@ -59,11 +59,14 @@ describe('CLI argument grammar', () => { expect(() => parseCliArguments(['system', 'status', 'extra'], {})).toThrow('Unknown option') }) - it('keeps help inside the two-token grammar', () => { - expect(parseCliArguments(['help', 'commands'], {})).toMatchObject({ + it('keeps top-level help local while command help follows the two-token grammar', () => { + expect(parseCliArguments(['help'], {})).toMatchObject({ + domain: 'help', + verb: '', contract: null, helpRequested: true }) + expect(() => parseCliArguments(['help', 'commands'], {})).toThrow('deepchat help') expect(() => parseCliArguments(['--help'], {})).toThrow('deepchat ') expect(formatCliHelp({ domain: 'agent', verb: 'run' })).toContain('--max-turns ') expect(formatCliHelp({ domain: 'run', verb: 'watch' })).toContain('--cursor ') diff --git a/test/main/cli/client.test.ts b/test/main/cli/client.test.ts index 0b2392067..e4e8c13cd 100644 --- a/test/main/cli/client.test.ts +++ b/test/main/cli/client.test.ts @@ -138,6 +138,14 @@ afterEach(async () => { }) describe('bundled CLI client', () => { + it('renders top-level help without discovering a running app', async () => { + const invocation = runWithCapturedOutput(['help'], {}) + + await expect(invocation.result).resolves.toBe(0) + expect(invocation.stdout.read()).toContain('Usage: deepchat [options]') + expect(invocation.stderr.read()).toBe('') + }) + it('keeps usage failures machine-readable when a post-command mode is valid', async () => { const invocation = runWithCapturedOutput(['system', 'status', '--json', '--unknown'], {}) diff --git a/test/main/cli/surface.test.ts b/test/main/cli/surface.test.ts index b4ae4cf7c..51aff3c06 100644 --- a/test/main/cli/surface.test.ts +++ b/test/main/cli/surface.test.ts @@ -384,7 +384,7 @@ describe('CLI surface V1', () => { method: 'events.subscribe', possibleEffects: ['read'], transport: 'stream', - callers: ['human', 'agent'], + callers: ['human'], scopes: ['runs:read'] }), expect.objectContaining({ diff --git a/test/main/scripts/buildCli.test.ts b/test/main/scripts/buildCli.test.ts index bd00f6bda..2854a792f 100644 --- a/test/main/scripts/buildCli.test.ts +++ b/test/main/scripts/buildCli.test.ts @@ -22,11 +22,11 @@ async function runGeneratedLauncher(outputDirectory: string) { const launcherPath = path.join(outputDirectory, 'deepchat.cmd') return await execFileAsync( process.env.ComSpec ?? 'cmd.exe', - ['/d', '/s', '/c', `"${launcherPath}" help commands`], + ['/d', '/s', '/c', `"${launcherPath}" help`], { env: environment } ) } - return await execFileAsync(path.join(outputDirectory, 'deepchat'), ['help', 'commands'], { + return await execFileAsync(path.join(outputDirectory, 'deepchat'), ['help'], { env: environment }) } @@ -38,7 +38,7 @@ describe('CLI bundle', () => { await buildCli({ outDir: outputDirectory, logLevel: 'silent' }) const entryPath = path.join(outputDirectory, 'deepchat.mjs') const source = await readFile(entryPath, 'utf8') - const result = await execFileAsync(process.execPath, [entryPath, 'help', 'commands']) + const result = await execFileAsync(process.execPath, [entryPath, 'help']) const launcherResult = await runGeneratedLauncher(outputDirectory) expect(source.startsWith('#!/usr/bin/env node')).toBe(true) diff --git a/test/main/tool/agentTools/agentBashHandler.test.ts b/test/main/tool/agentTools/agentBashHandler.test.ts index d807f7200..4a49e4156 100644 --- a/test/main/tool/agentTools/agentBashHandler.test.ts +++ b/test/main/tool/agentTools/agentBashHandler.test.ts @@ -130,8 +130,9 @@ describe('AgentBashHandler', () => { permissionService.approve('conv-1', 'deepchat model', false) const commandEnvironment = { createEnvironment: vi.fn(() => ({ - DEEPCHAT_CLI_AGENT_TOKEN: 'scoped-token', - PATH: '/bundled/cli' + variables: { DEEPCHAT_CLI_AGENT_TOKEN: 'scoped-token' }, + prependPath: ['/bundled/cli'], + preserveCommand: true })) } const handler = new AgentBashHandler( @@ -143,7 +144,7 @@ describe('AgentBashHandler', () => { const prepareCommand = vi.spyOn(handler as never, 'prepareCommand' as never).mockResolvedValue({ originalCommand: 'deepchat model invoke --prompt hello', command: 'deepchat model invoke --prompt hello', - env: { DEEPCHAT_CLI_AGENT_TOKEN: 'scoped-token', PATH: '/bundled/cli' }, + env: { DEEPCHAT_CLI_AGENT_TOKEN: 'scoped-token' }, rewritten: false, rtkApplied: false, rtkMode: 'bypass' @@ -161,7 +162,13 @@ describe('AgentBashHandler', () => { command: 'deepchat model invoke --prompt hello', description: 'Invoke model' }, - { conversationId: 'conv-1' } + { + conversationId: 'conv-1', + env: { + PATH: ['/controlled/bin', '/shared/bin'].join(path.delimiter), + CONTROLLED_VALUE: 'preserved' + } + } ) expect(commandEnvironment.createEnvironment).toHaveBeenCalledWith( @@ -170,16 +177,28 @@ describe('AgentBashHandler', () => { ) expect(prepareCommand).toHaveBeenCalledWith( 'deepchat model invoke --prompt hello', - { + expect.objectContaining({ DEEPCHAT_CLI_AGENT_TOKEN: 'scoped-token', - PATH: '/bundled/cli' - }, + CONTROLLED_VALUE: 'preserved' + }), true ) + const preparedEnvironment = prepareCommand.mock.calls[0]?.[1] as Record + expect(preparedEnvironment.PATH?.split(path.delimiter).slice(0, 3)).toEqual([ + '/bundled/cli', + '/controlled/bin', + '/shared/bin' + ]) }) it('does not issue a scoped environment while command approval is pending', async () => { - const commandEnvironment = { createEnvironment: vi.fn(() => ({})) } + const commandEnvironment = { + createEnvironment: vi.fn(() => ({ + variables: {}, + prependPath: [], + preserveCommand: false + })) + } const handler = new AgentBashHandler( ['/workspace'], { get: () => undefined }, From d4e481a32748a7a54424ba328286eaf05e4810f2 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 22:14:16 +0800 Subject: [PATCH 40/51] fix(cli): pin installed launcher runtime --- docs/architecture/local-control-plane/plan.md | 4 +- docs/architecture/local-control-plane/spec.md | 9 +- .../architecture/local-control-plane/tasks.md | 2 +- docs/guides/cli.md | 3 + scripts/build-cli.mjs | 35 +-- src/main/cli/launcherService.ts | 259 +++++++++++++----- test/main/cli/launcherService.test.ts | 90 +++++- test/main/scripts/buildCli.test.ts | 35 ++- 8 files changed, 315 insertions(+), 122 deletions(-) diff --git a/docs/architecture/local-control-plane/plan.md b/docs/architecture/local-control-plane/plan.md index 0ac8c41cf..5cdc39739 100644 --- a/docs/architecture/local-control-plane/plan.md +++ b/docs/architecture/local-control-plane/plan.md @@ -73,7 +73,9 @@ 1. Build the CLI as a packaged application resource that runs on the bundled Node runtime. 2. Automatically reconcile platform launchers after server startup, with no settings toggle and with - explicit, reversible ownership that never overwrites foreign commands or shell content. + explicit, reversible ownership that never overwrites foreign commands or shell content. Install a + stable regular-file shim, atomically refresh its pinned app-resource paths, migrate the owned + legacy POSIX symlink, and never fall back to a runtime from `PATH`. 3. Add the internal scoped-token issuer, conversation binding, expiry/revocation, call/byte quotas, and main-enforced Agent restrictions. Derive each token's exact scopes from the shared command catalog and `CLI_SURFACE`; the issuer has no broad default capability set. diff --git a/docs/architecture/local-control-plane/spec.md b/docs/architecture/local-control-plane/spec.md index 2a31546c1..f477e5dc8 100644 --- a/docs/architecture/local-control-plane/spec.md +++ b/docs/architecture/local-control-plane/spec.md @@ -594,10 +594,13 @@ and `8` internal/protocol failure. The packaged CLI source lives in `src/cli`; main-side transport adapters live in `src/main/cli`. The built standalone entry and launchers use the bundled Node runtime and ship outside `app.asar` as application resources. After the local control server is listening, startup automatically and -idempotently places a small launcher in the platform's user command location; there is no settings -toggle. It never overwrites an unowned command or modified shell block, does not install an npm +idempotently places a small regular-file shim in the platform's user command location; there is no +settings toggle. The shim pins the validated CLI module and bundled Node paths from the current app +installation and never falls back to a runtime discovered through `PATH`. Startup atomically +reconciles its content hash after an app move or upgrade and migrates a still-owned legacy POSIX +symlink. It never overwrites an unowned command or modified shell block, does not install an npm package or copy credentials, and records enough ownership state for exact rollback during full data -reset. Upgrades replace app-owned resources while keeping the launcher stable. +reset. Main owns the server lifetime. Desktop shutdown first stops accepting new work, aborts every pending request and stream with a typed `unavailable` result when the connection remains writable, then diff --git a/docs/architecture/local-control-plane/tasks.md b/docs/architecture/local-control-plane/tasks.md index 2aa6bc617..8e22df631 100644 --- a/docs/architecture/local-control-plane/tasks.md +++ b/docs/architecture/local-control-plane/tasks.md @@ -77,7 +77,7 @@ - [x] Package the CLI with the bundled Node runtime on all supported targets. - [x] Add automatic, idempotent, reversible platform launcher/PATH integration with no settings - toggle. + toggle, a hash-reconciled regular-file shim, legacy symlink migration, and no system-Node fallback. - [x] Add in-memory scoped Agent token issuance, expiry, revocation, and quotas. - [x] Derive Agent token scopes from the shared command catalog and fail closed for human-only commands, including self-blocking `run watch`. diff --git a/docs/guides/cli.md b/docs/guides/cli.md index d4bb86ac1..21825644f 100644 --- a/docs/guides/cli.md +++ b/docs/guides/cli.md @@ -7,6 +7,9 @@ MCP、OCR、Artifact 和 Agent 状态仍由正在运行的 DeepChat main 进程 - 不提供 CLI 开关。DeepChat 启动时自动启动本机 control plane。 - server 监听成功后,DeepChat 自动、幂等地安装或修复自己拥有的 `deepchat` launcher。 +- 用户命令位置中的 launcher 是普通文件;它固定引用当前应用中校验过的 CLI 与 bundled Node, + 应用移动或升级后由下次启动原子刷新。旧版 owned symlink 会自动迁移。 +- launcher 不会回退到 `PATH` 中的系统 Node;bundled runtime 缺失时以 `127` 失败关闭。 - launcher 冲突时保持失败关闭:不会覆盖同名的外部命令、被修改的 managed block、符号链接 profile 或不属于 DeepChat 的文件。 - launcher 不是 daemon。DeepChat 未运行时,命令返回 `unavailable`,退出码为 `3`。 diff --git a/scripts/build-cli.mjs b/scripts/build-cli.mjs index a4560c49c..a0606f45a 100644 --- a/scripts/build-cli.mjs +++ b/scripts/build-cli.mjs @@ -12,43 +12,32 @@ export const cliOutputDirectory = path.join(repositoryRoot, 'out', 'cli') export const POSIX_LAUNCHER = `#!/bin/sh set -eu -script_path=$0 -while [ -L "$script_path" ]; do - script_dir=$(CDPATH= cd -P -- "$(dirname -- "$script_path")" && pwd) - link_target=$(readlink "$script_path") - case "$link_target" in - /*) script_path=$link_target ;; - *) script_path=$script_dir/$link_target ;; - esac -done -script_dir=$(CDPATH= cd -P -- "$(dirname -- "$script_path")" && pwd) +case "$0" in + */*) script_dir=\${0%/*} ;; + *) script_dir=. ;; +esac +script_dir=$(CDPATH= cd -P -- "$script_dir" && pwd) runtime_node="$script_dir/../runtime/node/bin/node" if [ ! -x "$runtime_node" ]; then runtime_node="$script_dir/../../runtime/node/bin/node" fi -if [ -x "$runtime_node" ]; then - exec "$runtime_node" "$script_dir/deepchat.mjs" "$@" +cli_module="$script_dir/deepchat.mjs" +if [ -x "$runtime_node" ] && [ -f "$cli_module" ]; then + exec "$runtime_node" "$cli_module" "$@" fi -if command -v node >/dev/null 2>&1; then - exec node "$script_dir/deepchat.mjs" "$@" -fi -echo "DeepChat CLI requires the bundled Node.js runtime or node on PATH." >&2 +echo "DeepChat CLI bundled resources are unavailable." >&2 exit 127 ` export const WINDOWS_LAUNCHER = `@echo off\r set "runtime_node=%~dp0..\\runtime\\node\\node.exe"\r if not exist "%runtime_node%" set "runtime_node=%~dp0..\\..\\runtime\\node\\node.exe"\r -if exist "%runtime_node%" goto bundled_runtime\r -where node >nul 2>&1\r -if errorlevel 1 goto missing_runtime\r -node "%~dp0deepchat.mjs" %*\r -exit /b %errorlevel%\r -:bundled_runtime\r +if not exist "%runtime_node%" goto missing_runtime\r +if not exist "%~dp0deepchat.mjs" goto missing_runtime\r "%runtime_node%" "%~dp0deepchat.mjs" %*\r exit /b %errorlevel%\r :missing_runtime\r -echo DeepChat CLI requires the bundled Node.js runtime or node on PATH. 1>&2\r +echo DeepChat CLI bundled resources are unavailable. 1>&2\r exit /b 127\r ` diff --git a/src/main/cli/launcherService.ts b/src/main/cli/launcherService.ts index 726daa9d6..5499cf3cc 100644 --- a/src/main/cli/launcherService.ts +++ b/src/main/cli/launcherService.ts @@ -54,6 +54,7 @@ type PosixLauncherMarker = Readonly<{ platform: 'posix' commandPath: string launcherTarget: string + commandHash?: string profileKind: PosixProfileKind | null profilePrefixLength: 0 | 1 | 2 profileCreated: boolean @@ -97,11 +98,15 @@ type AppendedManagedBlock = Readonly<{ }> type CliSource = Readonly<{ - directory: string posixLauncher: string modulePath: string + runtimeNode: string }> +type OwnedCommand = + | Readonly<{ kind: 'link'; value: string }> + | Readonly<{ kind: 'text'; value: string; executable: boolean }> + function sha256(value: string): string { return createHash('sha256').update(value).digest('hex') } @@ -134,6 +139,8 @@ function parseLauncherMarker(value: unknown): LauncherMarker | null { if ( marker.platform === 'posix' && typeof marker.launcherTarget === 'string' && + (marker.commandHash === undefined || + (typeof marker.commandHash === 'string' && /^[0-9a-f]{64}$/.test(marker.commandHash))) && (marker.profileKind === null || isPosixProfileKind(marker.profileKind)) && (marker.profilePrefixLength === 0 || marker.profilePrefixLength === 1 || @@ -202,33 +209,52 @@ function escapeBatchLiteral(value: string): string { return value.replaceAll('%', '%%') } +function quotePosixLiteral(value: string): string { + return "'" + value.replaceAll("'", "'\\''") + "'" +} + +function createPosixCommand(source: CliSource): string { + return [ + '#!/bin/sh', + 'set -eu', + 'runtime_node=' + quotePosixLiteral(source.runtimeNode), + 'cli_module=' + quotePosixLiteral(source.modulePath), + 'if [ ! -x "$runtime_node" ] || [ ! -f "$cli_module" ]; then', + ' echo "DeepChat CLI bundled resources are unavailable." >&2', + ' exit 127', + 'fi', + 'exec "$runtime_node" "$cli_module" "$@"', + '' + ].join('\n') +} + function createWindowsCommand(source: CliSource): string { const cliModule = escapeBatchLiteral(source.modulePath) - const runtimeCandidates = [ - path.join(source.directory, '..', 'runtime', 'node', 'node.exe'), - path.join(source.directory, '..', '..', 'runtime', 'node', 'node.exe') - ].map((candidate) => escapeBatchLiteral(path.resolve(candidate))) + const runtimeNode = escapeBatchLiteral(source.runtimeNode) return [ '@echo off', 'setlocal', `set "cli_module=${cliModule}"`, - `set "runtime_node=${runtimeCandidates[0]}"`, - `if not exist "%runtime_node%" set "runtime_node=${runtimeCandidates[1]}"`, - 'if exist "%runtime_node%" goto bundled_runtime', - 'where node >nul 2>&1', - 'if errorlevel 1 goto missing_runtime', - 'node "%cli_module%" %*', - 'exit /b %errorlevel%', - ':bundled_runtime', + `set "runtime_node=${runtimeNode}"`, + 'if not exist "%runtime_node%" goto missing_runtime', + 'if not exist "%cli_module%" goto missing_runtime', '"%runtime_node%" "%cli_module%" %*', 'exit /b %errorlevel%', ':missing_runtime', - 'echo DeepChat CLI requires the bundled Node.js runtime or node on PATH. 1>&2', + 'echo DeepChat CLI bundled resources are unavailable. 1>&2', 'exit /b 127', '' ].join('\r\n') } +function ownedCommandsEqual(left: OwnedCommand | null, right: OwnedCommand | null): boolean { + return ( + left?.kind === right?.kind && + left?.value === right?.value && + (left?.kind !== 'text' || (right?.kind === 'text' && left.executable === right.executable)) + ) +} + export class CliLauncherService { private readonly platform: NodeJS.Platform private operationQueue: Promise = Promise.resolve() @@ -289,10 +315,33 @@ export class CliLauncherService { const directory = this.options.resolveCliDirectory() if (!directory) return null const resolvedDirectory = path.resolve(directory) - const source = { - directory: resolvedDirectory, + const runtimeExecutable = + this.platform === 'win32' ? path.join('node', 'node.exe') : path.join('node', 'bin', 'node') + const runtimeCandidates = [ + path.resolve(resolvedDirectory, '..', 'runtime', runtimeExecutable), + path.resolve(resolvedDirectory, '..', '..', 'runtime', runtimeExecutable) + ] + let runtimeNode: string | null = null + for (const candidate of runtimeCandidates) { + try { + const stats = await lstat(candidate) + if ( + stats.isFile() && + !stats.isSymbolicLink() && + (this.platform === 'win32' || (stats.mode & 0o111) !== 0) + ) { + runtimeNode = candidate + break + } + } catch (error) { + if (!isMissingFileError(error)) throw error + } + } + if (!runtimeNode) return null + const source: CliSource = { posixLauncher: path.join(resolvedDirectory, 'deepchat'), - modulePath: path.join(resolvedDirectory, 'deepchat.mjs') + modulePath: path.join(resolvedDirectory, 'deepchat.mjs'), + runtimeNode } const requiredPaths = this.platform === 'win32' ? [source.modulePath] : [source.posixLauncher, source.modulePath] @@ -444,7 +493,8 @@ export class CliLauncherService { ) const stale = marker.platform === 'posix' - ? marker.launcherTarget !== (current as PosixLauncherMarker).launcherTarget + ? marker.commandHash === undefined || + marker.commandHash !== (current as PosixLauncherMarker).commandHash : marker.commandHash !== (current as WindowsLauncherMarker).commandHash return { state: stale ? 'stale' : 'installed', @@ -533,7 +583,7 @@ export class CliLauncherService { let commandChanged = false let profileChanged = false try { - if (previousCommand !== nextCommand) { + if (!ownedCommandsEqual(previousCommand, nextCommand)) { await this.writeOwnedCommand(source, previousCommand) commandChanged = true } @@ -768,6 +818,7 @@ export class CliLauncherService { platform: 'posix', commandPath, launcherTarget: source.posixLauncher, + commandHash: sha256(createPosixCommand(source)), profileKind, profilePrefixLength, profileCreated @@ -778,96 +829,139 @@ export class CliLauncherService { marker: LauncherMarker ): Promise<'current' | 'missing' | 'modified'> { try { - if (marker.platform === 'posix') { - const target = await this.readPosixCommandTarget() - if (target === null) return 'missing' - return target === path.resolve(marker.launcherTarget) ? 'current' : 'modified' - } - const content = await this.readWindowsCommand() - if (content === null) return 'missing' - return sha256(content) === marker.commandHash ? 'current' : 'modified' + const command = await this.readOwnedCommand() + if (command === null) return 'missing' + return this.commandMatchesMarker(command, marker) ? 'current' : 'modified' } catch { return 'modified' } } - private async captureOwnedCommand(marker: LauncherMarker | null): Promise { - if (this.platform === 'win32') { - const content = await this.readWindowsCommand() - if (content === null) return null - if (!marker || marker.platform !== 'windows' || sha256(content) !== marker.commandHash) { - throw new Error('Refusing to replace an unowned DeepChat CLI command') - } - return content + private commandMatchesMarker(command: OwnedCommand, marker: LauncherMarker): boolean { + if (marker.platform === 'windows') { + return command.kind === 'text' && sha256(command.value) === marker.commandHash } - const target = await this.readPosixCommandTarget() - if (target === null) return null - if (!marker || marker.platform !== 'posix' || target !== path.resolve(marker.launcherTarget)) { + if (marker.commandHash !== undefined) { + return ( + command.kind === 'text' && + command.executable && + sha256(command.value) === marker.commandHash + ) + } + return command.kind === 'link' && command.value === path.resolve(marker.launcherTarget) + } + + private async captureOwnedCommand(marker: LauncherMarker | null): Promise { + const command = await this.readOwnedCommand() + if (command === null) return null + if (!marker || !this.commandMatchesMarker(command, marker)) { throw new Error('Refusing to replace an unowned DeepChat CLI command') } - return target + return command } private async writeOwnedCommand( source: CliSource, - previousCommand: string | null + previousCommand: OwnedCommand | null ): Promise { const commandPath = this.commandPath if (!commandPath) throw new Error('CLI launcher command path is unavailable') await this.prepareCommandDirectory(path.dirname(commandPath)) if (this.platform === 'win32') { - await this.atomicWriteText(commandPath, createWindowsCommand(source), previousCommand, 0o755) + if (previousCommand?.kind === 'link') { + throw new Error('Windows CLI launcher cannot replace a symbolic link') + } + await this.atomicWriteText( + commandPath, + createWindowsCommand(source), + previousCommand?.value ?? null, + 0o755 + ) return } - await this.atomicWriteLink(commandPath, source.posixLauncher, previousCommand) + await this.atomicWritePosixCommand( + { kind: 'text', value: createPosixCommand(source), executable: true }, + previousCommand + ) } - private commandForSource(source: CliSource): string { - return this.platform === 'win32' - ? createWindowsCommand(source) - : path.resolve(source.posixLauncher) + private commandForSource(source: CliSource): OwnedCommand { + return { + kind: 'text', + value: this.platform === 'win32' ? createWindowsCommand(source) : createPosixCommand(source), + executable: true + } } private async restoreOwnedCommand( - previousCommand: string | null, - expectedCurrent: string | null + previousCommand: OwnedCommand | null, + expectedCurrent: OwnedCommand | null ): Promise { const commandPath = this.commandPath if (!commandPath) return - if (previousCommand === null) { - if (expectedCurrent === null) return - if (this.platform === 'win32') { - await this.unlinkTextIfMatches(commandPath, expectedCurrent) + if (this.platform !== 'win32') { + if (previousCommand === null) { + if (expectedCurrent !== null) await this.unlinkPosixCommandIfMatches(expectedCurrent) } else { - await this.unlinkLinkIfMatches(commandPath, expectedCurrent) + await this.atomicWritePosixCommand(previousCommand, expectedCurrent) } return } - if (this.platform === 'win32') { - await this.atomicWriteText(commandPath, previousCommand, expectedCurrent, 0o755) - } else { - await this.atomicWriteLink(commandPath, previousCommand, expectedCurrent) + if (previousCommand?.kind === 'link' || expectedCurrent?.kind === 'link') { + throw new Error('Windows CLI launcher cannot restore a symbolic link') } + if (previousCommand === null) { + if (expectedCurrent !== null) { + await this.unlinkTextIfMatches(commandPath, expectedCurrent.value) + } + return + } + await this.atomicWriteText( + commandPath, + previousCommand.value, + expectedCurrent?.value ?? null, + 0o755 + ) } - private async removeOwnedCommand(previousCommand: string): Promise { + private async removeOwnedCommand(previousCommand: OwnedCommand): Promise { const commandPath = this.commandPath if (!commandPath) return if (this.platform === 'win32') { - await this.unlinkTextIfMatches(commandPath, previousCommand) + if (previousCommand.kind !== 'text') { + throw new Error('Windows CLI launcher cannot remove a symbolic link') + } + await this.unlinkTextIfMatches(commandPath, previousCommand.value) } else { - await this.unlinkLinkIfMatches(commandPath, previousCommand) + await this.unlinkPosixCommandIfMatches(previousCommand) + } + } + + private async readOwnedCommand(): Promise { + if (this.platform === 'win32') { + const content = await this.readWindowsCommand() + return content === null ? null : { kind: 'text', value: content, executable: true } } + return await this.readPosixCommand() } - private async readPosixCommandTarget(): Promise { + private async readPosixCommand(): Promise { const commandPath = this.commandPath if (!commandPath) return null try { const stats = await lstat(commandPath) - if (!stats.isSymbolicLink()) throw new Error('DeepChat CLI command is not a symbolic link') - const target = await readlink(commandPath) - return path.resolve(path.dirname(commandPath), target) + if (stats.isSymbolicLink()) { + const target = await readlink(commandPath) + return { kind: 'link', value: path.resolve(path.dirname(commandPath), target) } + } + if (!stats.isFile() || stats.size > 64 * 1024) { + throw new Error('DeepChat CLI command is not an owned launcher') + } + return { + kind: 'text', + value: await readFile(commandPath, 'utf8'), + executable: (stats.mode & 0o111) !== 0 + } } catch (error) { if (isMissingFileError(error)) return null throw error @@ -916,21 +1010,32 @@ export class CliLauncherService { } } - private async atomicWriteLink( - commandPath: string, - target: string, - expectedTarget: string | null + private async atomicWritePosixCommand( + command: OwnedCommand, + expectedCommand: OwnedCommand | null ): Promise { - const currentTarget = await this.readPosixCommandTarget() - if (currentTarget !== (expectedTarget && path.resolve(expectedTarget))) { + const commandPath = this.commandPath + if (!commandPath) throw new Error('CLI launcher command path is unavailable') + const currentCommand = await this.readPosixCommand() + if (!ownedCommandsEqual(currentCommand, expectedCommand)) { throw new Error('CLI launcher changed during installation') } const tempPath = path.join(path.dirname(commandPath), `.deepchat-${randomUUID()}.tmp`) try { - await symlink(path.resolve(target), tempPath) - const verifiedTarget = await this.readPosixCommandTarget() - if (verifiedTarget !== currentTarget) + if (command.kind === 'link') { + await symlink(command.value, tempPath) + } else { + await writeFile(tempPath, command.value, { + encoding: 'utf8', + flag: 'wx', + mode: 0o755 + }) + await chmod(tempPath, 0o755) + } + const verifiedCommand = await this.readPosixCommand() + if (!ownedCommandsEqual(verifiedCommand, currentCommand)) { throw new Error('CLI launcher changed during installation') + } await rename(tempPath, commandPath) } catch (error) { await unlink(tempPath).catch(() => undefined) @@ -993,9 +1098,11 @@ export class CliLauncherService { } } - private async unlinkLinkIfMatches(commandPath: string, expectedTarget: string): Promise { - const currentTarget = await this.readPosixCommandTarget() - if (currentTarget !== path.resolve(expectedTarget)) { + private async unlinkPosixCommandIfMatches(expectedCommand: OwnedCommand): Promise { + const commandPath = this.commandPath + if (!commandPath) return + const currentCommand = await this.readPosixCommand() + if (!ownedCommandsEqual(currentCommand, expectedCommand)) { throw new Error('Refusing to remove a changed CLI launcher') } await unlink(commandPath) diff --git a/test/main/cli/launcherService.test.ts b/test/main/cli/launcherService.test.ts index c941a26d7..a63f0f616 100644 --- a/test/main/cli/launcherService.test.ts +++ b/test/main/cli/launcherService.test.ts @@ -1,4 +1,14 @@ -import { lstat, mkdir, mkdtemp, readFile, readlink, rm, symlink, writeFile } from 'node:fs/promises' +import { + chmod, + lstat, + mkdir, + mkdtemp, + readFile, + readlink, + rm, + symlink, + writeFile +} from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -19,6 +29,14 @@ async function createFixture(platform: NodeJS.Platform = 'darwin') { await writeFile(path.join(cliDirectory, 'deepchat'), '#!/bin/sh\n', { mode: 0o755 }) await writeFile(path.join(cliDirectory, 'deepchat.cmd'), '@echo off\r\n') await writeFile(path.join(cliDirectory, 'deepchat.mjs'), 'console.log("deepchat")\n') + const runtimeNode = path.join( + root, + 'runtime', + 'node', + platform === 'win32' ? 'node.exe' : path.join('bin', 'node') + ) + await mkdir(path.dirname(runtimeNode), { recursive: true }) + await writeFile(runtimeNode, 'fixture runtime\n', { mode: 0o755 }) let currentCliDirectory: string | null = cliDirectory const service = new CliLauncherService({ platform, @@ -38,6 +56,7 @@ async function createFixture(platform: NodeJS.Platform = 'darwin') { userDataDirectory, localAppDataDirectory, cliDirectory, + runtimeNode, service, setCliDirectory: (directory: string | null) => { currentCliDirectory = directory @@ -68,9 +87,14 @@ describe('CliLauncherService', () => { commandPath, shellConfigPath: profilePath }) - expect(path.resolve(path.dirname(commandPath), await readlink(commandPath))).toBe( - path.join(fixture.cliDirectory, 'deepchat') - ) + const commandStats = await lstat(commandPath) + const command = await readFile(commandPath, 'utf8') + expect(commandStats.isFile()).toBe(true) + expect(commandStats.isSymbolicLink()).toBe(false) + expect(commandStats.mode & 0o111).not.toBe(0) + expect(command).toContain(`runtime_node='${fixture.runtimeNode}'`) + expect(command).toContain(`cli_module='${path.join(fixture.cliDirectory, 'deepchat.mjs')}'`) + expect(command).not.toContain('command -v node') expect(await readFile(profilePath, 'utf8')).toBe( [ 'export EDITOR=vim', @@ -203,6 +227,7 @@ describe('CliLauncherService', () => { const commandPath = path.join(fixture.homeDirectory, '.local', 'bin', 'deepchat') const profilePath = path.join(fixture.homeDirectory, '.zprofile') await fixture.service.ensureInstalled() + const installedCommand = await readFile(commandPath, 'utf8') await rm(commandPath) await symlink('/tmp/not-deepchat', commandPath) @@ -214,7 +239,7 @@ describe('CliLauncherService', () => { expect(await readlink(commandPath)).toBe('/tmp/not-deepchat') await rm(commandPath) - await symlink(path.join(fixture.cliDirectory, 'deepchat'), commandPath) + await writeFile(commandPath, installedCommand, { mode: 0o755 }) await writeFile( profilePath, (await readFile(profilePath, 'utf8')) @@ -239,10 +264,13 @@ describe('CliLauncherService', () => { reason: 'command-missing' }) await expect(fixture.service.ensureInstalled()).resolves.toMatchObject({ state: 'installed' }) - expect((await lstat(commandPath)).isSymbolicLink()).toBe(true) + const repairedCommand = await lstat(commandPath) + expect(repairedCommand.isFile()).toBe(true) + expect(repairedCommand.isSymbolicLink()).toBe(false) + expect(repairedCommand.mode & 0o111).not.toBe(0) }) - it('refreshes only a stale launcher whose previous target is still owned', async () => { + it('refreshes only a stale launcher whose previous content is still owned', async () => { const fixture = await createFixture() const commandPath = path.join(fixture.homeDirectory, '.local', 'bin', 'deepchat') await fixture.service.ensureInstalled() @@ -257,12 +285,52 @@ describe('CliLauncherService', () => { reason: 'upgrade-required' }) await fixture.service.ensureInstalled() - expect(path.resolve(path.dirname(commandPath), await readlink(commandPath))).toBe( - path.join(nextCliDirectory, 'deepchat') + const refreshedCommand = await readFile(commandPath, 'utf8') + expect(refreshedCommand).toContain( + `cli_module='${path.join(nextCliDirectory, 'deepchat.mjs')}'` ) + expect(refreshedCommand).not.toContain(fixture.cliDirectory) await expect(fixture.service.getStatus()).resolves.toMatchObject({ state: 'installed' }) }) + it('migrates an owned legacy POSIX symlink to the stable command shim', async () => { + const fixture = await createFixture() + const commandPath = path.join(fixture.homeDirectory, '.local', 'bin', 'deepchat') + const markerPath = path.join(fixture.userDataDirectory, 'local-control', 'launcher.json') + await fixture.service.ensureInstalled() + + const marker = JSON.parse(await readFile(markerPath, 'utf8')) as Record + delete marker.commandHash + await writeFile(markerPath, `${JSON.stringify(marker)}\n`) + await rm(commandPath) + await symlink(path.join(fixture.cliDirectory, 'deepchat'), commandPath) + + await expect(fixture.service.getStatus()).resolves.toMatchObject({ + state: 'stale', + reason: 'upgrade-required' + }) + await expect(fixture.service.ensureInstalled()).resolves.toMatchObject({ state: 'installed' }) + + const migratedStats = await lstat(commandPath) + const migratedMarker = JSON.parse(await readFile(markerPath, 'utf8')) as Record + expect(migratedStats.isFile()).toBe(true) + expect(migratedStats.isSymbolicLink()).toBe(false) + expect(migratedMarker.commandHash).toMatch(/^[0-9a-f]{64}$/) + }) + + it('fails closed when an owned POSIX shim loses its executable mode', async () => { + const fixture = await createFixture() + const commandPath = path.join(fixture.homeDirectory, '.local', 'bin', 'deepchat') + await fixture.service.ensureInstalled() + await chmod(commandPath, 0o644) + + await expect(fixture.service.getStatus()).resolves.toMatchObject({ + state: 'conflict', + reason: 'command-modified' + }) + await expect(fixture.service.ensureInstalled()).rejects.toThrow('unowned') + }) + it('uses an owned Windows command shim and refreshes it across app paths', async () => { const fixture = await createFixture('win32') const commandPath = path.join( @@ -280,6 +348,10 @@ describe('CliLauncherService', () => { expect(await readFile(commandPath, 'utf8')).toContain( `set "cli_module=${path.join(fixture.cliDirectory, 'deepchat.mjs')}"` ) + expect(await readFile(commandPath, 'utf8')).toContain( + `set "runtime_node=${fixture.runtimeNode}"` + ) + expect(await readFile(commandPath, 'utf8')).not.toContain('where node') const nextCliDirectory = path.join(fixture.root, 'cli-win-v2') await mkdir(nextCliDirectory) diff --git a/test/main/scripts/buildCli.test.ts b/test/main/scripts/buildCli.test.ts index 2854a792f..b084415fb 100644 --- a/test/main/scripts/buildCli.test.ts +++ b/test/main/scripts/buildCli.test.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process' -import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' +import { copyFile, mkdir, mkdtemp, readFile, rm, stat, symlink } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { promisify } from 'node:util' @@ -14,28 +14,42 @@ import { const execFileAsync = promisify(execFile) async function runGeneratedLauncher(outputDirectory: string) { - const environment = { - ...process.env, - PATH: [path.dirname(process.execPath), process.env.PATH].filter(Boolean).join(path.delimiter) - } if (process.platform === 'win32') { const launcherPath = path.join(outputDirectory, 'deepchat.cmd') return await execFileAsync( process.env.ComSpec ?? 'cmd.exe', ['/d', '/s', '/c', `"${launcherPath}" help`], - { env: environment } + { env: { ...process.env, PATH: '' } } ) } return await execFileAsync(path.join(outputDirectory, 'deepchat'), ['help'], { - env: environment + env: { ...process.env, PATH: '' } }) } +async function provisionBundledRuntime(outputDirectory: string): Promise { + const runtimeNode = path.resolve( + outputDirectory, + '..', + 'runtime', + 'node', + process.platform === 'win32' ? 'node.exe' : path.join('bin', 'node') + ) + await mkdir(path.dirname(runtimeNode), { recursive: true }) + if (process.platform === 'win32') { + await copyFile(process.execPath, runtimeNode) + } else { + await symlink(process.execPath, runtimeNode) + } +} + describe('CLI bundle', () => { it('builds a standalone Node entry and explicit bundled-runtime launchers', async () => { - const outputDirectory = await mkdtemp(path.join(os.tmpdir(), 'deepchat-cli-build-')) + const temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), 'deepchat-cli-build-')) + const outputDirectory = path.join(temporaryDirectory, 'cli') try { await buildCli({ outDir: outputDirectory, logLevel: 'silent' }) + await provisionBundledRuntime(outputDirectory) const entryPath = path.join(outputDirectory, 'deepchat.mjs') const source = await readFile(entryPath, 'utf8') const result = await execFileAsync(process.execPath, [entryPath, 'help']) @@ -52,10 +66,13 @@ describe('CLI bundle', () => { ) expect(POSIX_LAUNCHER).toContain('../runtime/node/bin/node') expect(POSIX_LAUNCHER).toContain('../../runtime/node/bin/node') + expect(POSIX_LAUNCHER).not.toContain('command -v node') expect(WINDOWS_LAUNCHER).toContain('..\\runtime\\node\\node.exe') expect(WINDOWS_LAUNCHER).toContain('..\\..\\runtime\\node\\node.exe') + expect(WINDOWS_LAUNCHER).not.toContain('where node') + expect(WINDOWS_LAUNCHER).not.toContain('node "%~dp0deepchat.mjs"') } finally { - await rm(outputDirectory, { recursive: true }) + await rm(temporaryDirectory, { recursive: true }) } }) From de8df36a882ccd814e80c90994a473f971f8ec06 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 22:20:05 +0800 Subject: [PATCH 41/51] fix(cli): return persisted MCP state --- src/main/cli/mcpAdminRoutes.ts | 42 ++++++++++++------- test/main/cli/mcpAdminRoutes.test.ts | 62 ++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 14 deletions(-) diff --git a/src/main/cli/mcpAdminRoutes.ts b/src/main/cli/mcpAdminRoutes.ts index 9b0a0c163..c59322b00 100644 --- a/src/main/cli/mcpAdminRoutes.ts +++ b/src/main/cli/mcpAdminRoutes.ts @@ -334,6 +334,27 @@ export function createCliMcpAdminRoutes(dependencies: CliMcpAdminDependencies): } return server } + const summarizeAppliedServer = async (serverName: string): Promise => { + try { + const config = (await dependencies.mcp.getMcpServers())[serverName] + if (!config) throw new Error('Persisted MCP server is missing after mutation') + return await summarizeServer(serverName, config) + } catch (error) { + log.warn('[CLI] Could not confirm MCP state after a successful mutation', { + serverName, + failure: { name: error instanceof Error ? error.name : typeof error } + }) + throw new CliRequestError( + 'unavailable', + 'MCP mutation completed, but persisted state could not be confirmed', + { + httpStatus: 503, + retriable: false, + details: { serverName, applied: true } + } + ) + } + } const recordActivity = ( action: SettingsActivityInput['action'], serverName: string, @@ -385,12 +406,10 @@ export function createCliMcpAdminRoutes(dependencies: CliMcpAdminDependencies): async (rawInput, context) => { requireCliCaller(context.caller) const input = mcpAddPublicRoute.input.parse(rawInput) + const storedConfig = toStoredConfig(input.config) let result: Awaited> try { - result = await dependencies.mcp.addMcpServer( - input.serverName, - toStoredConfig(input.config) - ) + result = await dependencies.mcp.addMcpServer(input.serverName, storedConfig) } catch (error) { throw unavailable('add the MCP server', error) } @@ -405,7 +424,7 @@ export function createCliMcpAdminRoutes(dependencies: CliMcpAdminDependencies): 'settings.controlCenter.activity.mcpServerCreated' ) return mcpAddPublicRoute.output.parse({ - server: await summarizeServer(input.serverName, toStoredConfig(input.config)) + server: await summarizeAppliedServer(input.serverName) }) } ], @@ -421,16 +440,14 @@ export function createCliMcpAdminRoutes(dependencies: CliMcpAdminDependencies): } catch (error) { throw unavailable('update the MCP server', error) } - const server = await summarizeServer(input.serverName, { - ...current.config, - ...storedUpdate - }) recordActivity( 'updated', input.serverName, 'settings.controlCenter.activity.mcpServerUpdated' ) - return mcpUpdatePublicRoute.output.parse({ server }) + return mcpUpdatePublicRoute.output.parse({ + server: await summarizeAppliedServer(input.serverName) + }) } ], [ @@ -482,10 +499,7 @@ export function createCliMcpAdminRoutes(dependencies: CliMcpAdminDependencies): 'settings.controlCenter.activity.mcpServerStatusChanged' ) return mcpSetPublicStatusRoute.output.parse({ - server: await summarizeServer(input.serverName, { - ...current.config, - enabled: input.enabled - }) + server: await summarizeAppliedServer(input.serverName) }) } ], diff --git a/test/main/cli/mcpAdminRoutes.test.ts b/test/main/cli/mcpAdminRoutes.test.ts index 9a5fc1c01..dbf999401 100644 --- a/test/main/cli/mcpAdminRoutes.test.ts +++ b/test/main/cli/mcpAdminRoutes.test.ts @@ -307,6 +307,68 @@ describe('CLI MCP administration routes', () => { expect(harness.recordSettingsActivity).toHaveBeenCalledOnce() }) + it('returns canonical MCP state read back after successful mutations', async () => { + const harness = createHarness({ server: stdioConfig() }) + harness.addMcpServer.mockImplementationOnce(async (serverName, config) => { + harness.servers.set(serverName, { + ...config, + command: '/managed/bin/pnpm', + enabled: true + }) + return { status: 'added' as const } + }) + + await expect( + harness.invoke(mcpAddPublicRoute.name, { + serverName: 'normalized', + config: { type: 'stdio', command: 'npx' } + }) + ).resolves.toMatchObject({ + server: { name: 'normalized', commandName: 'pnpm', enabled: true } + }) + + harness.updateMcpServer.mockImplementationOnce(async (serverName, updates) => { + harness.servers.set(serverName, { + ...harness.servers.get(serverName)!, + ...updates, + descriptions: 'Persisted description' + }) + }) + await expect( + harness.invoke(mcpUpdatePublicRoute.name, { + serverName: 'server', + updates: { description: 'Requested description' } + }) + ).resolves.toMatchObject({ server: { description: 'Persisted description' } }) + + harness.setMcpServerEnabled.mockImplementationOnce(async (serverName) => { + harness.servers.set(serverName, { ...harness.servers.get(serverName)!, enabled: false }) + }) + await expect( + harness.invoke(mcpSetPublicStatusRoute.name, { serverName: 'server', enabled: true }) + ).resolves.toMatchObject({ server: { enabled: false } }) + }) + + it('marks a successful mutation as applied when canonical read-back fails', async () => { + const harness = createHarness() + harness.getMcpServers.mockRejectedValueOnce(new Error('store unavailable')) + + const failure = await harness + .invoke(mcpAddPublicRoute.name, { + serverName: 'applied-server', + config: { type: 'stdio', command: 'npx' } + }) + .catch((error: unknown) => error) + + expect(harness.servers.has('applied-server')).toBe(true) + expect(failure).toMatchObject({ + code: 'unavailable', + retriable: false, + options: { details: { serverName: 'applied-server', applied: true } } + }) + expect(harness.recordSettingsActivity).toHaveBeenCalledOnce() + }) + it('preserves unmentioned secrets and clears incompatible fields on transport changes', async () => { const harness = createHarness({ server: stdioConfig({ customNpmRegistry: 'https://registry.example/npm' }) From e545738a04a64a5efa0a0ef6b35df244cc57c79e Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 22:31:16 +0800 Subject: [PATCH 42/51] fix(cli): support filesystems without hardlinks --- docs/architecture/local-control-plane/spec.md | 6 +- .../architecture/local-control-plane/tasks.md | 3 +- docs/guides/cli.md | 2 +- src/cli/artifacts.ts | 59 +++++++++++++++---- src/main/cli/skillService.ts | 20 ++++++- src/shared/utils/filesystem.ts | 15 +++++ test/main/cli/artifacts.test.ts | 25 ++++++++ test/main/cli/skillService.test.ts | 18 +++++- 8 files changed, 128 insertions(+), 20 deletions(-) create mode 100644 src/shared/utils/filesystem.ts diff --git a/docs/architecture/local-control-plane/spec.md b/docs/architecture/local-control-plane/spec.md index f477e5dc8..e24e4873a 100644 --- a/docs/architecture/local-control-plane/spec.md +++ b/docs/architecture/local-control-plane/spec.md @@ -478,8 +478,10 @@ Human and Agent file flows are deliberately different: never an arbitrary source path. - Agent input: only DeepChat-owned attachment/artifact IDs or a main-resolved file-grant ID are accepted. The main process canonicalizes and validates a grant; the CLI cannot mint one. -- Human output: the CLI downloads an owned artifact and writes `--out` with no-overwrite semantics by - default. Replacement requires explicit `--overwrite`. +- Human output: the CLI downloads an owned artifact into a verified temporary file beside `--out`. + No-overwrite publication uses an atomic hardlink when supported and an exclusive copy plus `fsync` + on filesystems without hardlinks; neither path replaces an existing destination. Replacement + requires explicit `--overwrite`. - Agent output: main returns artifact IDs and metadata only. Artifact byte download, stdout byte export, deletion, and `--out` are rejected for Agent callers; IDs may be passed to another scoped operation. diff --git a/docs/architecture/local-control-plane/tasks.md b/docs/architecture/local-control-plane/tasks.md index 8e22df631..abc98264f 100644 --- a/docs/architecture/local-control-plane/tasks.md +++ b/docs/architecture/local-control-plane/tasks.md @@ -34,7 +34,8 @@ - [x] Add image and video standalone generation surfaces. - [x] Add formal standalone speech generation and typed audio output. - [x] Add upload and owned-artifact transcription inputs. -- [x] Implement output-only `ArtifactSpool` ownership, quotas, expiry, and cleanup. +- [x] Implement output-only `ArtifactSpool` ownership, quotas, expiry, cleanup, and portable + no-overwrite downloads on filesystems without hardlinks. - [x] Add stream, media, speech, transcription, artifact, and quota tests. ## OCR diff --git a/docs/guides/cli.md b/docs/guides/cli.md index 21825644f..29b84b899 100644 --- a/docs/guides/cli.md +++ b/docs/guides/cli.md @@ -87,7 +87,7 @@ deepchat image generate --help | 9 | Model 管理 | `model list/enable/disable/config-get/config-set/config-reset` | 运行时列表与严格公共配置分离 | | 10 | Skill 管理 | `skill list/install/enable/disable/remove` | ZIP/HTTPS 安装有边界与供应链批准 | | 11 | MCP 管理 | `mcp list/add/update/enable/disable/start/stop/remove` | 仅公开管理面,不暴露 raw MCP tool tunnel | -| 12 | Artifact 管理 | `artifact describe/get/delete` | ownership、TTL、hash、配额与 no-overwrite | +| 12 | Artifact 管理 | `artifact describe/get/delete` | ownership、TTL、hash、配额与跨文件系统 no-overwrite | | 13 | 诊断和 benchmark 输出 | `system ...`, JSON/JSONL、stdin、timeout | 外部 harness 负责数据集、重复、打分和冷启动 | | 14 | Agent scoped CLI | bundled `deepchat-cli` Skill | main 签发短期、按调用和字节限额的 token,不暴露 human descriptor | diff --git a/src/cli/artifacts.ts b/src/cli/artifacts.ts index 5981a28c9..d66f5c2dd 100644 --- a/src/cli/artifacts.ts +++ b/src/cli/artifacts.ts @@ -8,10 +8,12 @@ import { type LocalControlDescriptor } from '@shared/contracts/localControl' import type { ArtifactMetadata } from '@shared/contracts/routes/artifacts.routes' +import { isHardlinkUnavailableError } from '@shared/utils/filesystem' import { CLI_EXIT_CODES, CliClientError, exitCodeForRemoteError } from './errors' import { CLI_VERSION } from './transport' const MAX_ERROR_RESPONSE_BYTES = 64 * 1024 +const PORTABLE_COPY_BUFFER_BYTES = 64 * 1024 export type ArtifactDownloadInput = Readonly<{ descriptor: LocalControlDescriptor @@ -187,25 +189,58 @@ async function receiveArtifact(input: ArtifactDownloadInput, handle: FileHandle) }) } -async function publishDownload( +function outputAlreadyExists(outputPath: string): CliClientError { + return new CliClientError( + 'conflict', + `Output already exists: ${outputPath}`, + CLI_EXIT_CODES.domain + ) +} + +async function copyDownloadExclusive(tempPath: string, outputPath: string): Promise { + let output: FileHandle | undefined + let source: FileHandle | undefined + try { + output = await open(outputPath, 'wx', 0o600) + source = await open(tempPath, 'r') + const buffer = Buffer.allocUnsafe(PORTABLE_COPY_BUFFER_BYTES) + let position = 0 + while (true) { + const { bytesRead } = await source.read(buffer, 0, buffer.length, position) + if (bytesRead === 0) break + position += await writeAll(output, buffer.subarray(0, bytesRead), position) + } + await output.sync() + await source.close() + source = undefined + await output.close() + output = undefined + } catch (error) { + await source?.close().catch(() => undefined) + await output?.close().catch(() => undefined) + if (output) await unlink(outputPath).catch(() => undefined) + if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw outputAlreadyExists(outputPath) + throw error + } +} + +export async function publishArtifactDownload( tempPath: string, outputPath: string, - overwrite: boolean + overwrite: boolean, + linkFile: typeof link = link ): Promise { if (!overwrite) { try { - await link(tempPath, outputPath) + await linkFile(tempPath, outputPath) return } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'EEXIST') { - throw new CliClientError( - 'conflict', - `Output already exists: ${outputPath}`, - CLI_EXIT_CODES.domain - ) - } - throw error + const code = (error as NodeJS.ErrnoException).code + if (code === 'EEXIST') throw outputAlreadyExists(outputPath) + if (!isHardlinkUnavailableError(error)) throw error } + await copyDownloadExclusive(tempPath, outputPath) + return } await rename(tempPath, outputPath) @@ -236,7 +271,7 @@ export async function downloadArtifact(input: ArtifactDownloadInput): Promise { + await publishArtifactDownload(tempPath, outputPath, input.overwrite).catch((error) => { if (error instanceof CliClientError) throw error throw new CliClientError( 'conflict', diff --git a/src/main/cli/skillService.ts b/src/main/cli/skillService.ts index 9ce193e9c..b654ef286 100644 --- a/src/main/cli/skillService.ts +++ b/src/main/cli/skillService.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto' -import { link, unlink } from 'node:fs/promises' +import { constants, copyFile, link, unlink } from 'node:fs/promises' import path from 'node:path' import { PUBLIC_SKILL_LIST_MAX_ITEMS, @@ -14,6 +14,7 @@ import { } from '@shared/contracts/routes' import type { SkillInstallResult, SkillServicePort } from '@shared/types/skill' import type { UnifiedSkillItem } from '@shared/types/skillManagement' +import { isHardlinkUnavailableError } from '@shared/utils/filesystem' import { BUILTIN_SKILL_AGENT_ID } from '@/skill/agentSkillRoots' import { createRouteMap, @@ -75,9 +76,22 @@ async function removeFileIfPresent(filePath: string): Promise { } } -async function retainUploadFile(uploadPath: string): Promise> { +export async function retainUploadFile( + uploadPath: string, + linkFile: typeof link = link +): Promise> { const retainedPath = path.join(path.dirname(uploadPath), `body-${randomUUID()}.tmp`) - await link(uploadPath, retainedPath) + try { + await linkFile(uploadPath, retainedPath) + } catch (error) { + if (!isHardlinkUnavailableError(error)) throw error + try { + await copyFile(uploadPath, retainedPath, constants.COPYFILE_EXCL) + } catch (copyError) { + await removeFileIfPresent(retainedPath).catch(() => undefined) + throw copyError + } + } return { path: retainedPath } } diff --git a/src/shared/utils/filesystem.ts b/src/shared/utils/filesystem.ts new file mode 100644 index 000000000..4928070c0 --- /dev/null +++ b/src/shared/utils/filesystem.ts @@ -0,0 +1,15 @@ +const HARDLINK_UNAVAILABLE_CODES = new Set([ + 'EACCES', + 'EINVAL', + 'EMLINK', + 'ENOSYS', + 'ENOTSUP', + 'EOPNOTSUPP', + 'EPERM', + 'EXDEV' +]) + +export function isHardlinkUnavailableError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException).code + return typeof code === 'string' && HARDLINK_UNAVAILABLE_CODES.has(code) +} diff --git a/test/main/cli/artifacts.test.ts b/test/main/cli/artifacts.test.ts index 13692576a..9a322c438 100644 --- a/test/main/cli/artifacts.test.ts +++ b/test/main/cli/artifacts.test.ts @@ -12,6 +12,7 @@ import { ArtifactSpool } from '@/cli/artifactSpool' import { createArtifactRoutes } from '@/cli/artifactRoutes' import { CliServer } from '@/cli/server' import type { CliRouteCaller, HumanCliRouteCaller } from '@/routes/routeRegistry' +import { publishArtifactDownload } from '../../../src/cli/artifacts' import { runCli } from '../../../src/cli/run' const servers: CliServer[] = [] @@ -92,6 +93,30 @@ afterEach(async () => { }) describe('artifact CLI', () => { + it('publishes without overwrite when the destination filesystem rejects hardlinks', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'deepchat-cli-artifact-output-')) + temporaryDirectories.push(directory) + const sourcePath = path.join(directory, '.source.tmp') + const outputPath = path.join(directory, 'output.bin') + const portableOutput = Buffer.from( + Array.from({ length: 128 * 1024 + 17 }, (_, index) => index % 251) + ) + await writeFile(sourcePath, portableOutput) + const unsupportedLink = vi.fn(async () => { + throw Object.assign(new Error('hardlinks are unavailable'), { code: 'EPERM' }) + }) + + await publishArtifactDownload(sourcePath, outputPath, false, unsupportedLink) + expect(await readFile(outputPath)).toEqual(portableOutput) + + const replacementPath = path.join(directory, '.replacement.tmp') + await writeFile(replacementPath, 'replacement') + await expect( + publishArtifactDownload(replacementPath, outputPath, false, unsupportedLink) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(await readFile(outputPath)).toEqual(portableOutput) + }) + it('downloads verified bytes and emits the canonical machine envelope', async () => { const { userDataPath, spool } = await createHarness() const artifact = await spool.write({ diff --git a/test/main/cli/skillService.test.ts b/test/main/cli/skillService.test.ts index 280a0833d..7971a72ef 100644 --- a/test/main/cli/skillService.test.ts +++ b/test/main/cli/skillService.test.ts @@ -11,7 +11,7 @@ import { } from '@shared/contracts/routes' import type { SkillServicePort } from '@shared/types/skill' import type { UnifiedSkillItem } from '@shared/types/skillManagement' -import { CliSkillService } from '@/cli/skillService' +import { CliSkillService, retainUploadFile } from '@/cli/skillService' import type { CliRouteCaller, RouteContext } from '@/routes/routeRegistry' const caller: CliRouteCaller = { @@ -105,6 +105,22 @@ function createHarness(catalog: UnifiedSkillItem[] = [skill()]) { } describe('CLI Skill service', () => { + it('retains uploads on filesystems without hardlink support', async () => { + const tempDirectory = await mkdtemp(path.join(tmpdir(), 'deepchat-cli-skill-retain-')) + const uploadPath = path.join(tempDirectory, 'body-upload.tmp') + await writeFile(uploadPath, 'archive-bytes') + + try { + const retained = await retainUploadFile(uploadPath, async () => { + throw Object.assign(new Error('hardlinks are unavailable'), { code: 'EPERM' }) + }) + expect(retained.path).not.toBe(uploadPath) + await expect(readFile(retained.path, 'utf8')).resolves.toBe('archive-bytes') + } finally { + await rm(tempDirectory, { recursive: true, force: true }) + } + }) + it('accepts signed HTTPS URLs but rejects credentials, fragments, and unsafe filenames', () => { expect( skillsInstallPublicUrlRoute.input.safeParse({ From cead53d6b8ab450400412440c49c6ed04c2fe244 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 22:33:16 +0800 Subject: [PATCH 43/51] fix(cli): redact detached run failures --- docs/architecture/local-control-plane/spec.md | 4 ++- .../architecture/local-control-plane/tasks.md | 3 +- src/main/cli/runService.ts | 18 +++++------ test/main/cli/runService.test.ts | 31 ++++++++++++++----- 4 files changed, 36 insertions(+), 20 deletions(-) diff --git a/docs/architecture/local-control-plane/spec.md b/docs/architecture/local-control-plane/spec.md index e24e4873a..71e63aea8 100644 --- a/docs/architecture/local-control-plane/spec.md +++ b/docs/architecture/local-control-plane/spec.md @@ -538,7 +538,9 @@ Raw and media requests are cancelled when their connection/request aborts unless explicitly supports detachment. `sessions.runDetached` first creates a detached session through the existing lifecycle, then starts the initial turn. It returns a durable run/session identity before streaming. Disconnect does not destroy a detached run; status/messages can be recovered from session -state and event cursors. `runs.cancel` is idempotent and ownership checked. +state and event cursors. If initial-turn startup fails, the response and run event retain that durable +identity but expose only a stable failure message; upstream error text is excluded from public output +and logs. `runs.cancel` is idempotent and ownership checked. `events.subscribe` is human-only in V1. An Agent can own only its currently executing conversation, so waiting on that run from its bash tool would deadlock the run on itself. Agent callers may use the diff --git a/docs/architecture/local-control-plane/tasks.md b/docs/architecture/local-control-plane/tasks.md index abc98264f..a4e0427da 100644 --- a/docs/architecture/local-control-plane/tasks.md +++ b/docs/architecture/local-control-plane/tasks.md @@ -72,7 +72,8 @@ disconnect, and incarnation-safe recovery semantics. - [x] Compose detached session creation with initial-turn execution. - [x] Add owned status, event streaming, result recovery, and idempotent cancellation. -- [x] Add event-isolation, backpressure, detached-recovery, recursion-denial, and cancellation tests. +- [x] Add event-isolation, backpressure, detached-recovery, startup-error redaction, + recursion-denial, and cancellation tests. ## Packaging and Agent Use diff --git a/src/main/cli/runService.ts b/src/main/cli/runService.ts index d2fc76b1c..f51d22947 100644 --- a/src/main/cli/runService.ts +++ b/src/main/cli/runService.ts @@ -41,8 +41,8 @@ import { CliRequestError } from './errors' import type { CliStreamEmitter } from './server' const DEFAULT_MESSAGE_LIMIT = 50 -const MAX_PUBLIC_ERROR_CHARACTERS = 4_096 const RUN_SNAPSHOT_MESSAGE_BUDGET_BYTES = 8 * 1024 * 1024 +const RUN_START_FAILURE_MESSAGE = 'Detached Agent run could not start' type RunLifecyclePort = Readonly<{ createDetachedSession(input: CreateDetachedSessionInput): Promise @@ -153,12 +153,6 @@ function projectMessagePage( return { messages, nextCursor: hasMore ? nextCursor : null, hasMore } } -function publicErrorMessage(error: unknown): string { - const message = - error instanceof Error && error.message.trim() ? error.message.trim() : 'Unknown error' - return message.slice(0, MAX_PUBLIC_ERROR_CHARACTERS) -} - export class CliRunService { private readonly now: () => number private readonly log: Pick @@ -262,19 +256,21 @@ export class CliRunService { input.maxTurns ? { maxProviderRounds: input.maxTurns } : undefined ) } catch (error) { - const message = publicErrorMessage(error) - this.log.warn('[CLI] Failed to start detached Agent run', { runId, error }) + this.log.warn('[CLI] Failed to start detached Agent run', { + runId, + failure: { name: error instanceof Error ? error.name : typeof error } + }) this.options.eventHub.publish( runsTurnFailedEvent.name, { runId, sessionId: session.id, failedAt: this.now(), - error: message + error: RUN_START_FAILURE_MESSAGE }, { kind: 'run', runId } ) - throw new CliRequestError('conflict', `Detached Agent run could not start: ${message}`, { + throw new CliRequestError('conflict', RUN_START_FAILURE_MESSAGE, { httpStatus: 409, details: { runId, sessionId: session.id } }) diff --git a/test/main/cli/runService.test.ts b/test/main/cli/runService.test.ts index ba3c17be5..fb4a27255 100644 --- a/test/main/cli/runService.test.ts +++ b/test/main/cli/runService.test.ts @@ -84,6 +84,7 @@ function createHarness( turn: CliRunServiceOptions['turn'] projection: CliRunServiceOptions['projection'] sessions: CliRunServiceOptions['sessions'] + log: { warn: ReturnType } } { const session = overrides.session ?? baseSession const lifecycle = { @@ -106,6 +107,7 @@ function createHarness( overrides.storedSession === undefined ? (session as SessionRecord) : overrides.storedSession ) } + const log = { warn: vi.fn() } const hub = new TypedEventHub({ renderer: { broadcast: vi.fn(), send: vi.fn() }, epoch: 'test-epoch', @@ -119,13 +121,14 @@ function createHarness( sessions, eventHub: hub, now: () => 200, - log: { warn: vi.fn() } + log }), hub, lifecycle, turn, projection, - sessions + sessions, + log } } @@ -204,15 +207,29 @@ describe('CliRunService', () => { }) it('returns the durable run identity when initial turn startup fails', async () => { - const { service, turn } = createHarness() - vi.mocked(turn.sendMessage).mockRejectedValueOnce(new Error('provider unavailable')) + const { service, turn, hub, log } = createHarness() + const events = hub.subscribe({ kind: 'run', runId: 'run-1' }) + const privateFailure = 'EACCES /Users/private/provider.json?token=secret' + vi.mocked(turn.sendMessage).mockRejectedValueOnce(new Error(privateFailure)) - await expect( - invokeRoute(service, sessionsRunDetachedRoute.name, { prompt: 'hello' }) - ).rejects.toMatchObject({ + const failure = await invokeRoute(service, sessionsRunDetachedRoute.name, { + prompt: 'hello' + }).catch((error: unknown) => error) + expect(failure).toMatchObject({ code: 'conflict', + message: 'Detached Agent run could not start', options: { details: { runId: 'run-1', sessionId: 'run-1' } } }) + await expect(nextEvent(events.events)).resolves.toMatchObject({ event: 'runs.created' }) + await expect(nextEvent(events.events)).resolves.toMatchObject({ + event: 'runs.turn.failed', + data: { error: 'Detached Agent run could not start' } + }) + expect(JSON.stringify(failure)).not.toContain(privateFailure) + expect(log.warn).toHaveBeenCalledWith('[CLI] Failed to start detached Agent run', { + runId: 'run-1', + failure: { name: 'Error' } + }) }) it('returns bounded public message text without internal message metadata', async () => { From 45c2af570bdb64a8ddf03bdd2800e3588fccf6b2 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 22:43:13 +0800 Subject: [PATCH 44/51] fix(cli): keep data reset available --- docs/architecture/local-control-plane/plan.md | 4 +- docs/architecture/local-control-plane/spec.md | 5 +- .../architecture/local-control-plane/tasks.md | 2 + docs/guides/cli.md | 3 +- src/main/app/applicationDataReset.ts | 82 ++++++++++ src/main/app/composition.ts | 20 +-- test/main/app/applicationDataReset.test.ts | 141 ++++++++++++++++++ 7 files changed, 241 insertions(+), 16 deletions(-) create mode 100644 src/main/app/applicationDataReset.ts create mode 100644 test/main/app/applicationDataReset.test.ts diff --git a/docs/architecture/local-control-plane/plan.md b/docs/architecture/local-control-plane/plan.md index 5cdc39739..c65044d56 100644 --- a/docs/architecture/local-control-plane/plan.md +++ b/docs/architecture/local-control-plane/plan.md @@ -75,7 +75,9 @@ 2. Automatically reconcile platform launchers after server startup, with no settings toggle and with explicit, reversible ownership that never overwrites foreign commands or shell content. Install a stable regular-file shim, atomically refresh its pinned app-resource paths, migrate the owned - legacy POSIX symlink, and never fall back to a runtime from `PATH`. + legacy POSIX symlink, and never fall back to a runtime from `PATH`. During full data reset, stop + the server first and treat owned-launcher removal as best-effort: conflicts or cleanup failures + preserve external files and cannot block application-data deletion. 3. Add the internal scoped-token issuer, conversation binding, expiry/revocation, call/byte quotas, and main-enforced Agent restrictions. Derive each token's exact scopes from the shared command catalog and `CLI_SURFACE`; the issuer has no broad default capability set. diff --git a/docs/architecture/local-control-plane/spec.md b/docs/architecture/local-control-plane/spec.md index 71e63aea8..66e3877be 100644 --- a/docs/architecture/local-control-plane/spec.md +++ b/docs/architecture/local-control-plane/spec.md @@ -604,7 +604,10 @@ installation and never falls back to a runtime discovered through `PATH`. Startu reconciles its content hash after an app move or upgrade and migrates a still-owned legacy POSIX symlink. It never overwrites an unowned command or modified shell block, does not install an npm package or copy credentials, and records enough ownership state for exact rollback during full data -reset. +reset. Full reset first stops the local control server, then removes only launcher files whose +ownership and content still match. An ownership conflict, inspection failure, or removal failure +preserves the external command/profile, emits a secret-safe diagnostic, and never blocks deletion of +DeepChat application data. Main owns the server lifetime. Desktop shutdown first stops accepting new work, aborts every pending request and stream with a typed `unavailable` result when the connection remains writable, then diff --git a/docs/architecture/local-control-plane/tasks.md b/docs/architecture/local-control-plane/tasks.md index a4e0427da..c1d111c87 100644 --- a/docs/architecture/local-control-plane/tasks.md +++ b/docs/architecture/local-control-plane/tasks.md @@ -80,6 +80,8 @@ - [x] Package the CLI with the bundled Node runtime on all supported targets. - [x] Add automatic, idempotent, reversible platform launcher/PATH integration with no settings toggle, a hash-reconciled regular-file shim, legacy symlink migration, and no system-Node fallback. +- [x] Keep full data reset available when launcher ownership conflicts or cleanup fails, while + preserving external files and logging only safe diagnostics. - [x] Add in-memory scoped Agent token issuance, expiry, revocation, and quotas. - [x] Derive Agent token scopes from the shared command catalog and fail closed for human-only commands, including self-blocking `run watch`. diff --git a/docs/guides/cli.md b/docs/guides/cli.md index 29b84b899..53d7fc6f1 100644 --- a/docs/guides/cli.md +++ b/docs/guides/cli.md @@ -16,7 +16,8 @@ MCP、OCR、Artifact 和 Agent 状态仍由正在运行的 DeepChat main 进程 - DeepChat 退出时先停止接收请求,再取消进行中的 RPC、上传、下载和 stream。连接会在有界宽限期 内关闭,所有已连接且正在等待 main 的 CLI 进程自行退出,退出码为 `3`;不会遗留 CLI 后台进程。 - 普通退出保留 launcher,便于下次启动后直接使用。完整数据重置只删除仍能证明由 DeepChat - 拥有的 launcher 集成。 + 拥有且未被修改的 launcher 集成;ownership 冲突或清理失败会保留外部命令/profile 并记录安全 + 诊断,但不会阻止应用数据重置。 先用诊断命令确认桌面端和协议可用: diff --git a/src/main/app/applicationDataReset.ts b/src/main/app/applicationDataReset.ts new file mode 100644 index 000000000..ce1e5428e --- /dev/null +++ b/src/main/app/applicationDataReset.ts @@ -0,0 +1,82 @@ +import type { CliLauncherReason, CliLauncherStatus } from '@/cli/launcherService' + +export type ApplicationDataResetType = 'chat' | 'knowledge' | 'config' | 'all' + +type CliLauncherResetPort = Readonly<{ + getStatus(): Promise + removeOwnedLauncher(): Promise +}> + +type ApplicationDataResetLogger = Readonly<{ + warn(message: string, context: Readonly>): void +}> + +export type ApplicationDataResetDependencies = Readonly<{ + cliLauncher: CliLauncherResetPort + logger: ApplicationDataResetLogger + stop(): Promise + resetDataByType(resetType: ApplicationDataResetType): Promise +}> + +type LauncherCleanupReason = + | CliLauncherReason + | 'launcher-conflict' + | 'status-inspection-failed' + | 'launcher-removal-failed' + +function safeErrorCode(error: unknown): string | undefined { + if (!error || typeof error !== 'object' || !('code' in error)) return undefined + const code = (error as { code?: unknown }).code + return typeof code === 'string' && /^[a-z0-9_-]{1,64}$/i.test(code) ? code : undefined +} + +function warnLauncherCleanupIncomplete( + logger: ApplicationDataResetLogger, + reason: LauncherCleanupReason, + error?: unknown +): void { + const errorCode = safeErrorCode(error) + try { + logger.warn('[CLI] Launcher cleanup did not complete during full data reset', { + reason, + ...(errorCode ? { errorCode } : {}) + }) + } catch { + // Diagnostics are best-effort and must never block the data reset they describe. + } +} + +async function removeOwnedLauncherForReset( + cliLauncher: CliLauncherResetPort, + logger: ApplicationDataResetLogger +): Promise { + let status: CliLauncherStatus + try { + status = await cliLauncher.getStatus() + } catch (error) { + warnLauncherCleanupIncomplete(logger, 'status-inspection-failed', error) + return + } + + if (status.state === 'conflict') { + warnLauncherCleanupIncomplete(logger, status.reason ?? 'launcher-conflict') + return + } + + try { + await cliLauncher.removeOwnedLauncher() + } catch (error) { + warnLauncherCleanupIncomplete(logger, 'launcher-removal-failed', error) + } +} + +export async function coordinateApplicationDataReset( + resetType: ApplicationDataResetType, + dependencies: ApplicationDataResetDependencies +): Promise { + await dependencies.stop() + if (resetType === 'all') { + await removeOwnedLauncherForReset(dependencies.cliLauncher, dependencies.logger) + } + await dependencies.resetDataByType(resetType) +} diff --git a/src/main/app/composition.ts b/src/main/app/composition.ts index f697cec23..2d6addc1f 100644 --- a/src/main/app/composition.ts +++ b/src/main/app/composition.ts @@ -213,6 +213,7 @@ import { type RouteDispatcher } from '@/routes' import { createNodeScheduler } from '@/routes/scheduler' +import { coordinateApplicationDataReset } from './applicationDataReset' import { AgentCliCommandAccess, AgentCliTokenAuthority, @@ -2840,19 +2841,12 @@ export async function createMainProcessControl(dependencies: { async function resetApplicationData( resetType: 'chat' | 'knowledge' | 'config' | 'all' ): Promise { - if (resetType === 'all') { - const launcherStatus = await cliLauncherService.getStatus() - if (launcherStatus.state === 'conflict' && launcherStatus.reason !== 'unowned-command') { - throw new Error( - 'Cannot reset application data while the owned DeepChat CLI launcher is inconsistent' - ) - } - if (launcherStatus.reason !== 'unowned-command') { - await cliLauncherService.removeOwnedLauncher() - } - } - await stop() - await deviceService.resetDataByType(resetType) + await coordinateApplicationDataReset(resetType, { + cliLauncher: cliLauncherService, + logger, + stop, + resetDataByType: (type) => deviceService.resetDataByType(type) + }) } async function restartApplication(): Promise { diff --git a/test/main/app/applicationDataReset.test.ts b/test/main/app/applicationDataReset.test.ts new file mode 100644 index 000000000..8b7cc2c88 --- /dev/null +++ b/test/main/app/applicationDataReset.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it, vi } from 'vitest' +import { + coordinateApplicationDataReset, + type ApplicationDataResetDependencies +} from '@/app/applicationDataReset' +import type { CliLauncherStatus } from '@/cli/launcherService' + +const INSTALLED_STATUS: CliLauncherStatus = { + state: 'installed', + reason: null, + commandPath: '/home/user/.local/bin/deepchat', + shellConfigPath: '/home/user/.profile' +} + +function createDependencies( + status: CliLauncherStatus = INSTALLED_STATUS +): ApplicationDataResetDependencies & { + cliLauncher: { + getStatus: ReturnType + removeOwnedLauncher: ReturnType + } + logger: { warn: ReturnType } + stop: ReturnType + resetDataByType: ReturnType +} { + return { + cliLauncher: { + getStatus: vi.fn().mockResolvedValue(status), + removeOwnedLauncher: vi.fn().mockResolvedValue({ + ...status, + state: 'not-installed', + reason: null + }) + }, + logger: { warn: vi.fn() }, + stop: vi.fn().mockResolvedValue(undefined), + resetDataByType: vi.fn().mockResolvedValue(undefined) + } +} + +describe('coordinateApplicationDataReset', () => { + it('stops runtimes before removing an owned launcher and resetting all data', async () => { + const dependencies = createDependencies() + + await coordinateApplicationDataReset('all', dependencies) + + expect(dependencies.stop).toHaveBeenCalledOnce() + expect(dependencies.cliLauncher.getStatus).toHaveBeenCalledOnce() + expect(dependencies.cliLauncher.removeOwnedLauncher).toHaveBeenCalledOnce() + expect(dependencies.resetDataByType).toHaveBeenCalledWith('all') + expect(dependencies.stop.mock.invocationCallOrder[0]).toBeLessThan( + dependencies.cliLauncher.getStatus.mock.invocationCallOrder[0] + ) + expect(dependencies.cliLauncher.removeOwnedLauncher.mock.invocationCallOrder[0]).toBeLessThan( + dependencies.resetDataByType.mock.invocationCallOrder[0] + ) + }) + + it('preserves a conflicting launcher without blocking a full data reset', async () => { + const dependencies = createDependencies({ + ...INSTALLED_STATUS, + state: 'conflict', + reason: 'command-modified' + }) + + await expect(coordinateApplicationDataReset('all', dependencies)).resolves.toBeUndefined() + + expect(dependencies.cliLauncher.removeOwnedLauncher).not.toHaveBeenCalled() + expect(dependencies.logger.warn).toHaveBeenCalledWith( + '[CLI] Launcher cleanup did not complete during full data reset', + { reason: 'command-modified' } + ) + expect(dependencies.resetDataByType).toHaveBeenCalledWith('all') + }) + + it('continues resetting when launcher inspection or removal fails', async () => { + const inspectionFailure = createDependencies() + inspectionFailure.cliLauncher.getStatus.mockRejectedValueOnce( + Object.assign(new Error('sensitive path'), { code: 'EACCES' }) + ) + + await coordinateApplicationDataReset('all', inspectionFailure) + + expect(inspectionFailure.cliLauncher.removeOwnedLauncher).not.toHaveBeenCalled() + expect(inspectionFailure.logger.warn).toHaveBeenCalledWith( + '[CLI] Launcher cleanup did not complete during full data reset', + { reason: 'status-inspection-failed', errorCode: 'EACCES' } + ) + expect(JSON.stringify(inspectionFailure.logger.warn.mock.calls[0])).not.toContain( + 'sensitive path' + ) + expect(inspectionFailure.resetDataByType).toHaveBeenCalledWith('all') + + const removalFailure = createDependencies() + removalFailure.cliLauncher.removeOwnedLauncher.mockRejectedValueOnce( + Object.assign(new Error('sensitive path'), { code: 'EPERM' }) + ) + + await coordinateApplicationDataReset('all', removalFailure) + + expect(removalFailure.logger.warn).toHaveBeenCalledWith( + '[CLI] Launcher cleanup did not complete during full data reset', + { reason: 'launcher-removal-failed', errorCode: 'EPERM' } + ) + expect(removalFailure.resetDataByType).toHaveBeenCalledWith('all') + }) + + it('does not let diagnostic failures block reset and stops before touching reset state', async () => { + const diagnosticFailure = createDependencies({ + ...INSTALLED_STATUS, + state: 'conflict', + reason: 'ownership-marker-invalid' + }) + diagnosticFailure.logger.warn.mockImplementationOnce(() => { + throw new Error('logger unavailable') + }) + + await expect(coordinateApplicationDataReset('all', diagnosticFailure)).resolves.toBeUndefined() + expect(diagnosticFailure.resetDataByType).toHaveBeenCalledWith('all') + + const stopFailure = createDependencies() + stopFailure.stop.mockRejectedValueOnce(new Error('shutdown failed')) + + await expect(coordinateApplicationDataReset('all', stopFailure)).rejects.toThrow( + 'shutdown failed' + ) + expect(stopFailure.cliLauncher.getStatus).not.toHaveBeenCalled() + expect(stopFailure.cliLauncher.removeOwnedLauncher).not.toHaveBeenCalled() + expect(stopFailure.resetDataByType).not.toHaveBeenCalled() + }) + + it('does not inspect or remove the launcher for partial resets', async () => { + const dependencies = createDependencies() + + await coordinateApplicationDataReset('chat', dependencies) + + expect(dependencies.cliLauncher.getStatus).not.toHaveBeenCalled() + expect(dependencies.cliLauncher.removeOwnedLauncher).not.toHaveBeenCalled() + expect(dependencies.resetDataByType).toHaveBeenCalledWith('chat') + }) +}) From 42626dc442d27cf10ed20c29bb629369c18af84e Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 23:18:10 +0800 Subject: [PATCH 45/51] test(renderer): release mounted wrappers --- test/setup.renderer.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/setup.renderer.ts b/test/setup.renderer.ts index 394e265d7..f6efa1557 100644 --- a/test/setup.renderer.ts +++ b/test/setup.renderer.ts @@ -1,5 +1,5 @@ import { vi, beforeEach, afterEach } from 'vitest' -import { config } from '@vue/test-utils' +import { config, enableAutoUnmount } from '@vue/test-utils' const createDefaultModelConfig = () => ({ maxTokens: 4096, @@ -569,3 +569,7 @@ afterEach(() => { vi.useRealTimers() vi.restoreAllMocks() }) + +// Vitest runs cleanup hooks in reverse registration order. Register auto-unmount +// last so components dispose while their mocked dependencies are still intact. +enableAutoUnmount(afterEach) From 44bf77fb236470dfc82a721f9f0cdb215aab93e4 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 5 Aug 2026 23:50:04 +0800 Subject: [PATCH 46/51] fix(cli): serialize streams and enforce quotas --- src/cli/args.ts | 4 +- src/main/cli/artifactSpool.ts | 126 ++++++++++++--------- src/main/cli/computeService.ts | 47 +++++--- src/main/cli/mutationGuard.ts | 2 +- src/shared/contracts/routes/runs.routes.ts | 7 +- src/shared/utils/filesystem.ts | 1 + test/main/cli/args.test.ts | 3 + test/main/cli/artifactSpool.test.ts | 33 ++++++ test/main/cli/computeService.test.ts | 117 +++++++++++++++++++ test/main/cli/mutationGuard.test.ts | 22 +++- test/main/cli/packagedSmoke.test.ts | 11 +- test/main/cli/runService.test.ts | 25 ++++ test/main/scripts/buildCli.test.ts | 7 +- test/main/shared/filesystem.test.ts | 16 +++ 14 files changed, 341 insertions(+), 80 deletions(-) create mode 100644 test/main/shared/filesystem.test.ts diff --git a/src/cli/args.ts b/src/cli/args.ts index d7fdf5001..b12facf25 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -458,7 +458,9 @@ export function parseCliArguments( if (argument === '--timeout') { if (timeoutSeen) throw new CliUsageError('--timeout may be specified only once') const value = argv[index + 1] - if (!value) throw new CliUsageError('Missing value for --timeout') + if (!value || value.startsWith('--')) { + throw new CliUsageError('Missing value for --timeout') + } timeoutMs = parseTimeout(value, '--timeout') timeoutSeen = true index += 1 diff --git a/src/main/cli/artifactSpool.ts b/src/main/cli/artifactSpool.ts index adeb386da..55f72ec02 100644 --- a/src/main/cli/artifactSpool.ts +++ b/src/main/cli/artifactSpool.ts @@ -211,10 +211,14 @@ export class ArtifactSpool { private readonly cleanupIntervalMs: number private readonly log: Pick private readonly artifacts = new Map() + private readonly storedOwnerUsage = new Map() + private readonly storedRequestUsage = new Map() + private readonly storedConnectionUsage = new Map() private readonly ownerReservations = new Map() private readonly requestReservations = new Map() private readonly connectionReservations = new Map() private readonly allocatedIds = new Set() + private storedBytes = 0 private reservedBytes = 0 private reservedCount = 0 private activeWrites = 0 @@ -394,7 +398,7 @@ export class ArtifactSpool { await unlink(tempPath) tempPath = '' throwIfArtifactWriteCancelled(input.signal) - this.artifacts.set(id, { + this.recordStoredArtifact({ metadata, filePath: finalPath, ownerKey: ownerQuotaKey, @@ -506,10 +510,14 @@ export class ArtifactSpool { this.openReadStreams.clear() this.pendingRemovalIds.clear() this.removalPromises.clear() + this.storedOwnerUsage.clear() + this.storedRequestUsage.clear() + this.storedConnectionUsage.clear() this.ownerReservations.clear() this.requestReservations.clear() this.connectionReservations.clear() this.allocatedIds.clear() + this.storedBytes = 0 this.reservedBytes = 0 this.reservedCount = 0 } @@ -529,79 +537,66 @@ export class ArtifactSpool { } private reserveArtifact(owner: string, request: string, connection: string): void { - const stored = Array.from(this.artifacts.values()) if ( - this.quotaUsage(stored, 'ownerKey', owner, this.ownerReservations).count + 1 > + this.quotaUsage(this.storedOwnerUsage, this.ownerReservations, owner).count + 1 > this.limits.maxOwnerCount || - this.quotaUsage(stored, 'requestKey', request, this.requestReservations).count + 1 > + this.quotaUsage(this.storedRequestUsage, this.requestReservations, request).count + 1 > this.limits.maxRequestCount || - this.quotaUsage(stored, 'connectionId', connection, this.connectionReservations).count + 1 > + this.quotaUsage(this.storedConnectionUsage, this.connectionReservations, connection).count + + 1 > this.limits.maxConnectionCount || - stored.length + this.reservedCount + 1 > this.limits.maxTotalCount + this.artifacts.size + this.reservedCount + 1 > this.limits.maxTotalCount ) { throw this.quotaError() } - this.addReservation(this.ownerReservations, owner, 0, 1) - this.addReservation(this.requestReservations, request, 0, 1) - this.addReservation(this.connectionReservations, connection, 0, 1) + this.addQuotaUsage(this.ownerReservations, owner, 0, 1) + this.addQuotaUsage(this.requestReservations, request, 0, 1) + this.addQuotaUsage(this.connectionReservations, connection, 0, 1) this.reservedCount += 1 } private reserveBytes(owner: string, request: string, connection: string, bytes: number): void { - const stored = Array.from(this.artifacts.values()) if ( - this.quotaUsage(stored, 'ownerKey', owner, this.ownerReservations).bytes + bytes > + this.quotaUsage(this.storedOwnerUsage, this.ownerReservations, owner).bytes + bytes > this.limits.maxOwnerBytes || - this.quotaUsage(stored, 'requestKey', request, this.requestReservations).bytes + bytes > + this.quotaUsage(this.storedRequestUsage, this.requestReservations, request).bytes + bytes > this.limits.maxRequestBytes || - this.quotaUsage(stored, 'connectionId', connection, this.connectionReservations).bytes + + this.quotaUsage(this.storedConnectionUsage, this.connectionReservations, connection).bytes + bytes > this.limits.maxConnectionBytes || - stored.reduce((total, artifact) => total + artifact.metadata.size, 0) + - this.reservedBytes + - bytes > - this.limits.maxTotalBytes + this.storedBytes + this.reservedBytes + bytes > this.limits.maxTotalBytes ) { throw this.quotaError() } - this.addReservation(this.ownerReservations, owner, bytes, 0) - this.addReservation(this.requestReservations, request, bytes, 0) - this.addReservation(this.connectionReservations, connection, bytes, 0) + this.addQuotaUsage(this.ownerReservations, owner, bytes, 0) + this.addQuotaUsage(this.requestReservations, request, bytes, 0) + this.addQuotaUsage(this.connectionReservations, connection, bytes, 0) this.reservedBytes += bytes } private quotaUsage( - artifacts: readonly StoredArtifact[], - key: 'ownerKey' | 'requestKey' | 'connectionId', - value: string, - reservations: ReadonlyMap + storedUsage: ReadonlyMap, + reservations: ReadonlyMap, + key: string ): QuotaReservation { - const stored = artifacts - .filter((artifact) => artifact[key] === value) - .reduce( - (usage, artifact) => ({ - bytes: usage.bytes + artifact.metadata.size, - count: usage.count + 1 - }), - { bytes: 0, count: 0 } - ) - const reserved = reservations.get(value) + const stored = storedUsage.get(key) + const reserved = reservations.get(key) return { - bytes: stored.bytes + (reserved?.bytes ?? 0), - count: stored.count + (reserved?.count ?? 0) + bytes: (stored?.bytes ?? 0) + (reserved?.bytes ?? 0), + count: (stored?.count ?? 0) + (reserved?.count ?? 0) } } - private addReservation( - reservations: Map, + private addQuotaUsage( + usageByKey: Map, key: string, bytes: number, count: number ): void { - const reservation = reservations.get(key) ?? { bytes: 0, count: 0 } - reservation.bytes += bytes - reservation.count += count - reservations.set(key, reservation) + const usage = usageByKey.get(key) ?? { bytes: 0, count: 0 } + usage.bytes += bytes + usage.count += count + usageByKey.set(key, usage) } private releaseReservation( @@ -610,24 +605,49 @@ export class ArtifactSpool { connection: string, bytes: number ): void { - this.subtractReservation(this.ownerReservations, owner, bytes, 1) - this.subtractReservation(this.requestReservations, request, bytes, 1) - this.subtractReservation(this.connectionReservations, connection, bytes, 1) + this.subtractQuotaUsage(this.ownerReservations, owner, bytes, 1) + this.subtractQuotaUsage(this.requestReservations, request, bytes, 1) + this.subtractQuotaUsage(this.connectionReservations, connection, bytes, 1) this.reservedBytes = Math.max(0, this.reservedBytes - bytes) this.reservedCount = Math.max(0, this.reservedCount - 1) } - private subtractReservation( - reservations: Map, + private subtractQuotaUsage( + usageByKey: Map, key: string, bytes: number, count: number ): void { - const reservation = reservations.get(key) - if (!reservation) return - reservation.bytes = Math.max(0, reservation.bytes - bytes) - reservation.count = Math.max(0, reservation.count - count) - if (reservation.bytes === 0 && reservation.count === 0) reservations.delete(key) + const usage = usageByKey.get(key) + if (!usage) return + usage.bytes = Math.max(0, usage.bytes - bytes) + usage.count = Math.max(0, usage.count - count) + if (usage.bytes === 0 && usage.count === 0) usageByKey.delete(key) + } + + private recordStoredArtifact(artifact: StoredArtifact): void { + const id = artifact.metadata.id + if (this.artifacts.has(id)) throw new Error('Artifact ID is already stored') + this.artifacts.set(id, artifact) + this.addQuotaUsage(this.storedOwnerUsage, artifact.ownerKey, artifact.metadata.size, 1) + this.addQuotaUsage(this.storedRequestUsage, artifact.requestKey, artifact.metadata.size, 1) + this.addQuotaUsage(this.storedConnectionUsage, artifact.connectionId, artifact.metadata.size, 1) + this.storedBytes += artifact.metadata.size + } + + private forgetStoredArtifact(artifact: StoredArtifact): void { + const id = artifact.metadata.id + if (this.artifacts.get(id) !== artifact) return + this.artifacts.delete(id) + this.subtractQuotaUsage(this.storedOwnerUsage, artifact.ownerKey, artifact.metadata.size, 1) + this.subtractQuotaUsage(this.storedRequestUsage, artifact.requestKey, artifact.metadata.size, 1) + this.subtractQuotaUsage( + this.storedConnectionUsage, + artifact.connectionId, + artifact.metadata.size, + 1 + ) + this.storedBytes = Math.max(0, this.storedBytes - artifact.metadata.size) } private quotaError(): CliRequestError { @@ -767,7 +787,7 @@ export class ArtifactSpool { await unlink(artifact.filePath).catch((error) => { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }) - if (this.artifacts.get(id) === artifact) this.artifacts.delete(id) + this.forgetStoredArtifact(artifact) this.pendingRemovalIds.delete(id) })() this.removalPromises.set(id, removal) diff --git a/src/main/cli/computeService.ts b/src/main/cli/computeService.ts index b22dee19c..8ea624e1c 100644 --- a/src/main/cli/computeService.ts +++ b/src/main/cli/computeService.ts @@ -274,26 +274,37 @@ export class CliComputeService { const queueStartedAt = this.now() let queuedEmission = Promise.resolve() - let queuedEmissionError: unknown - await this.options.providerRuntime.executeWithRateLimit(input.providerId, { - signal, - onQueued: (snapshot) => { - const event = ModelInvokeEventSchema.parse({ - type: 'rate_limit', - providerId: snapshot.providerId, - qpsLimit: snapshot.qpsLimit, - currentQps: snapshot.currentQps, - queueLength: snapshot.queueLength, - estimatedWaitTimeMs: snapshot.estimatedWaitTime - }) - queuedEmission = emitEvent(event).catch((error) => { - queuedEmissionError = error - }) - } - }) + let queuedEmissionFailure: { error: unknown } | undefined + let admissionFailure: { error: unknown } | undefined + try { + await this.options.providerRuntime.executeWithRateLimit(input.providerId, { + signal, + onQueued: (snapshot) => { + const event = ModelInvokeEventSchema.parse({ + type: 'rate_limit', + providerId: snapshot.providerId, + qpsLimit: snapshot.qpsLimit, + currentQps: snapshot.currentQps, + queueLength: snapshot.queueLength, + estimatedWaitTimeMs: snapshot.estimatedWaitTime + }) + queuedEmission = queuedEmission.then(async () => { + if (queuedEmissionFailure) return + try { + await emitEvent(event) + } catch (error) { + queuedEmissionFailure = { error } + } + }) + } + }) + } catch (error) { + admissionFailure = { error } + } const queueFinishedAt = this.now() await queuedEmission - if (queuedEmissionError) throw queuedEmissionError + if (admissionFailure) throw admissionFailure.error + if (queuedEmissionFailure) throw queuedEmissionFailure.error const stream = this.options.providerRuntime.streamChat( input.providerId, diff --git a/src/main/cli/mutationGuard.ts b/src/main/cli/mutationGuard.ts index 41bc9e6a1..591f5ce35 100644 --- a/src/main/cli/mutationGuard.ts +++ b/src/main/cli/mutationGuard.ts @@ -82,7 +82,7 @@ export class CliMutationGuard { } const executionId = randomUUID() - const scopeKey = `cli:${input.connectionId}:${executionId}` + const scopeKey = `cli:${input.connectionId}` let approvalRequestId: string | undefined let closeReason: DeepchatEventPayload<'approvals.closed'>['reason'] = 'cancelled' diff --git a/src/shared/contracts/routes/runs.routes.ts b/src/shared/contracts/routes/runs.routes.ts index 89ac7fc2a..142b11e1e 100644 --- a/src/shared/contracts/routes/runs.routes.ts +++ b/src/shared/contracts/routes/runs.routes.ts @@ -17,6 +17,11 @@ export const RunIdSchema = EntityIdSchema.max(128) export const RunEventCursorSchema = LocalControlEventCursorSchema const BoundedIdentifierSchema = z.string().trim().min(1).max(256) +const PublicRunMessageTextSchema = z + .string() + .refine((value) => new TextEncoder().encode(value).byteLength <= RUN_MESSAGE_MAX_TEXT_BYTES, { + message: 'Run message text exceeds its UTF-8 byte limit' + }) const UniqueIdentifierListSchema = z .array(BoundedIdentifierSchema) .max(128) @@ -35,7 +40,7 @@ export const PublicRunMessageSchema = z id: EntityIdSchema, role: z.enum(['user', 'assistant']), status: z.enum(['pending', 'sent', 'error']), - text: z.string(), + text: PublicRunMessageTextSchema, textTruncated: z.boolean(), createdAt: TimestampMsSchema, updatedAt: TimestampMsSchema diff --git a/src/shared/utils/filesystem.ts b/src/shared/utils/filesystem.ts index 4928070c0..c6408c1c8 100644 --- a/src/shared/utils/filesystem.ts +++ b/src/shared/utils/filesystem.ts @@ -10,6 +10,7 @@ const HARDLINK_UNAVAILABLE_CODES = new Set([ ]) export function isHardlinkUnavailableError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false const code = (error as NodeJS.ErrnoException).code return typeof code === 'string' && HARDLINK_UNAVAILABLE_CODES.has(code) } diff --git a/test/main/cli/args.test.ts b/test/main/cli/args.test.ts index 2e9628e72..da77bd68a 100644 --- a/test/main/cli/args.test.ts +++ b/test/main/cli/args.test.ts @@ -56,6 +56,9 @@ describe('CLI argument grammar', () => { expect(() => parseCliArguments(['system', 'status', '--timeout', '1800001'], {})).toThrow( 'must not exceed' ) + expect(() => parseCliArguments(['system', 'status', '--timeout', '--json'], {})).toThrow( + 'Missing value for --timeout' + ) expect(() => parseCliArguments(['system', 'status', 'extra'], {})).toThrow('Unknown option') }) diff --git a/test/main/cli/artifactSpool.test.ts b/test/main/cli/artifactSpool.test.ts index 28deccf93..85973013f 100644 --- a/test/main/cli/artifactSpool.test.ts +++ b/test/main/cli/artifactSpool.test.ts @@ -256,6 +256,39 @@ describe('ArtifactSpool', () => { ).rejects.toMatchObject({ code: 'rate_limited' }) }) + it('releases committed quota after removing an artifact', async () => { + const { spool } = await createSpool({ + limits: { + maxArtifactBytes: 6, + maxRequestBytes: 6, + maxConnectionBytes: 6, + maxOwnerBytes: 6, + maxTotalBytes: 6, + maxRequestCount: 1, + maxConnectionCount: 1, + maxOwnerCount: 1, + maxTotalCount: 1 + } + }) + const first = await spool.write({ + caller: humanCaller, + requestId: 'request-before-removal', + mimeType: 'application/octet-stream', + data: Buffer.alloc(6) + }) + + await spool.discard(first.id) + + await expect( + spool.write({ + caller: humanCaller, + requestId: 'request-after-removal', + mimeType: 'application/octet-stream', + data: Buffer.alloc(6) + }) + ).resolves.toMatchObject({ size: 6 }) + }) + it('expires artifacts and removes their files', async () => { let now = 1_000 const { spool, directory } = await createSpool({ now: () => now }) diff --git a/test/main/cli/computeService.test.ts b/test/main/cli/computeService.test.ts index ba399e489..9696598fa 100644 --- a/test/main/cli/computeService.test.ts +++ b/test/main/cli/computeService.test.ts @@ -261,6 +261,123 @@ describe('CLI compute service', () => { expect(streamCall?.[7]).toEqual({ signal }) }) + it('serializes repeated rate-limit queue events before model output', async () => { + const releases: Array<() => void> = [] + const startedQueueLengths: number[] = [] + let activeEmissions = 0 + let maxActiveEmissions = 0 + const { service } = createService([{ type: 'stop', stop_reason: 'complete' }], { + providerRuntime: { + executeWithRateLimit: vi.fn(async (_providerId, options) => { + options?.onQueued?.({ + providerId: provider.id, + qpsLimit: 1, + currentQps: 1, + queueLength: 2, + estimatedWaitTime: 20 + }) + options?.onQueued?.({ + providerId: provider.id, + qpsLimit: 1, + currentQps: 1, + queueLength: 1, + estimatedWaitTime: 10 + }) + }) + } + }) + + const invocation = service.dispatchStream( + modelsInvokeRoute.name, + { + providerId: provider.id, + modelId: model.id, + messages: [{ role: 'user', content: 'hello' }] + }, + caller, + 'request-queued', + new AbortController().signal, + async (_event, data) => { + const event = data as ModelInvokeEvent + if (event.type !== 'rate_limit') return + activeEmissions += 1 + maxActiveEmissions = Math.max(maxActiveEmissions, activeEmissions) + startedQueueLengths.push(event.queueLength) + await new Promise((resolve) => releases.push(resolve)) + activeEmissions -= 1 + } + ) + + await vi.waitFor(() => expect(releases).toHaveLength(1)) + expect(startedQueueLengths).toEqual([2]) + releases.shift()?.() + await vi.waitFor(() => expect(releases).toHaveLength(1)) + expect(startedQueueLengths).toEqual([2, 1]) + releases.shift()?.() + + await expect(invocation).resolves.toMatchObject({ finishReason: 'complete' }) + expect(maxActiveEmissions).toBe(1) + }) + + it('drains queued events before propagating an admission failure', async () => { + let releaseEmission!: () => void + const emissionGate = new Promise((resolve) => { + releaseEmission = resolve + }) + let emissionStarted = false + let emissionFinished = false + const { service, providerRuntime } = createService([], { + providerRuntime: { + executeWithRateLimit: vi.fn(async (_providerId, options) => { + options?.onQueued?.({ + providerId: provider.id, + qpsLimit: 1, + currentQps: 1, + queueLength: 1, + estimatedWaitTime: 10 + }) + throw new Error('Rate-limit admission failed') + }) + } + }) + + const invocation = service.dispatchStream( + modelsInvokeRoute.name, + { + providerId: provider.id, + modelId: model.id, + messages: [{ role: 'user', content: 'hello' }] + }, + caller, + 'request-admission-failure', + new AbortController().signal, + async (_event, data) => { + if ((data as ModelInvokeEvent).type !== 'rate_limit') return + emissionStarted = true + await emissionGate + emissionFinished = true + } + ) + let settled = false + void invocation.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + + await vi.waitFor(() => expect(emissionStarted).toBe(true)) + await Promise.resolve() + expect(settled).toBe(false) + releaseEmission() + + await expect(invocation).rejects.toMatchObject({ code: 'unavailable' }) + expect(emissionFinished).toBe(true) + expect(providerRuntime.streamChat).not.toHaveBeenCalled() + }) + it('exposes normalized provider failure metadata without upstream text or headers', async () => { const { service, log } = createService([ { diff --git a/test/main/cli/mutationGuard.test.ts b/test/main/cli/mutationGuard.test.ts index 91d712967..64749335b 100644 --- a/test/main/cli/mutationGuard.test.ts +++ b/test/main/cli/mutationGuard.test.ts @@ -9,7 +9,7 @@ const rendererCaller = (webContentsId: number) => ({ windowId: 7 }) -function createHarness() { +function createHarness(maxPendingPerScope?: number) { const requests: Array[1]> = [] const close = vi.fn(async () => undefined) const presentation: CliApprovalPresentationPort = { @@ -20,7 +20,9 @@ function createHarness() { }), close } - const approvals = new ApprovalBroker() + const approvals = new ApprovalBroker( + maxPendingPerScope === undefined ? undefined : { maxPendingPerScope } + ) const guard = new CliMutationGuard(approvals, presentation) const authorize = (signal = new AbortController().signal) => guard.authorize({ @@ -121,6 +123,22 @@ describe('CliMutationGuard', () => { await expect(second).rejects.toMatchObject({ code: 'approval_denied' }) }) + it('limits pending mutations across one CLI connection', async () => { + const harness = createHarness(1) + const first = harness.authorize() + await waitForRequests(harness.requests, 1) + + await expect(harness.authorize()).rejects.toMatchObject({ + code: 'rate_limited', + httpStatus: 429 + }) + expect(harness.requests).toHaveLength(1) + + const requestId = harness.requests[0].requestId + harness.guard.resolve({ requestId, decision: 'denied' }, rendererCaller(70)) + await expect(first).rejects.toMatchObject({ code: 'approval_denied' }) + }) + it('fails closed when no trusted renderer is available', async () => { const harness = createHarness() vi.mocked(harness.presentation.getTarget).mockResolvedValueOnce(null) diff --git a/test/main/cli/packagedSmoke.test.ts b/test/main/cli/packagedSmoke.test.ts index 226a96280..4df8c7563 100644 --- a/test/main/cli/packagedSmoke.test.ts +++ b/test/main/cli/packagedSmoke.test.ts @@ -24,6 +24,7 @@ import { buildCli } from '../../../scripts/build-cli.mjs' const execFileAsync = promisify(execFile) const CLI_PROCESS_TIMEOUT_MS = 5_000 +const PACKAGED_SMOKE_TIMEOUT_MS = 30_000 function cliEnvironment( userDataPath: string, @@ -41,7 +42,7 @@ function cliEnvironment( } describe('packaged CLI smoke', () => { - it('covers diagnostics, compute, artifacts, OCR, Agent policy, and desktop shutdown', async () => { + async function runPackagedCliSmoke(): Promise { const temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), 'deepchat-cli-smoke-')) const outputDirectory = path.join(temporaryDirectory, 'cli') const entryPath = path.join(outputDirectory, 'deepchat.mjs') @@ -257,5 +258,11 @@ describe('packaged CLI smoke', () => { await spool.close() await rm(temporaryDirectory, { recursive: true }) } - }) + } + + it( + 'covers diagnostics, compute, artifacts, OCR, Agent policy, and desktop shutdown', + runPackagedCliSmoke, + PACKAGED_SMOKE_TIMEOUT_MS + ) }) diff --git a/test/main/cli/runService.test.ts b/test/main/cli/runService.test.ts index fb4a27255..75d1d07e7 100644 --- a/test/main/cli/runService.test.ts +++ b/test/main/cli/runService.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { RUN_MESSAGE_MAX_TEXT_BYTES, RUN_PROMPT_MAX_CHARACTERS, + PublicRunMessageSchema, eventsSubscribeRoute, runsCancelRoute, runsGetRoute, @@ -258,6 +259,30 @@ describe('CliRunService', () => { expect(JSON.stringify(result)).not.toContain('private-provider-detail') }) + it('enforces the public message limit in UTF-8 bytes', () => { + const message = { + id: 'message-1', + role: 'assistant' as const, + status: 'sent' as const, + textTruncated: false, + createdAt: 100, + updatedAt: 101 + } + + expect( + PublicRunMessageSchema.safeParse({ + ...message, + text: 'x'.repeat(RUN_MESSAGE_MAX_TEXT_BYTES) + }).success + ).toBe(true) + expect( + PublicRunMessageSchema.safeParse({ + ...message, + text: '🙂'.repeat(RUN_MESSAGE_MAX_TEXT_BYTES / 4 + 1) + }).success + ).toBe(false) + }) + it('keeps escaped transcript pages within the local response byte limit', async () => { const messages = Array.from({ length: 16 }, (_, index) => createMessage({ diff --git a/test/main/scripts/buildCli.test.ts b/test/main/scripts/buildCli.test.ts index b084415fb..208771875 100644 --- a/test/main/scripts/buildCli.test.ts +++ b/test/main/scripts/buildCli.test.ts @@ -12,6 +12,7 @@ import { } from '../../../scripts/build-cli.mjs' const execFileAsync = promisify(execFile) +const CLI_BUILD_TEST_TIMEOUT_MS = 30_000 async function runGeneratedLauncher(outputDirectory: string) { if (process.platform === 'win32') { @@ -59,7 +60,9 @@ describe('CLI bundle', () => { expect(source).not.toMatch(/from\s+["']zod["']/) expect(result.stdout).toContain('deepchat ') expect(launcherResult.stdout).toContain('deepchat ') - expect((await stat(path.join(outputDirectory, 'deepchat'))).mode & 0o111).toBe(0o111) + if (process.platform !== 'win32') { + expect((await stat(path.join(outputDirectory, 'deepchat'))).mode & 0o111).toBe(0o111) + } expect(await readFile(path.join(outputDirectory, 'deepchat'), 'utf8')).toBe(POSIX_LAUNCHER) expect(await readFile(path.join(outputDirectory, 'deepchat.cmd'), 'utf8')).toBe( WINDOWS_LAUNCHER @@ -74,7 +77,7 @@ describe('CLI bundle', () => { } finally { await rm(temporaryDirectory, { recursive: true }) } - }) + }, CLI_BUILD_TEST_TIMEOUT_MS) it('packages only generated CLI resources outside app.asar', async () => { const config = parse(await readFile(path.resolve('electron-builder.yml'), 'utf8')) as { diff --git a/test/main/shared/filesystem.test.ts b/test/main/shared/filesystem.test.ts new file mode 100644 index 000000000..7199edf27 --- /dev/null +++ b/test/main/shared/filesystem.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { isHardlinkUnavailableError } from '@shared/utils/filesystem' + +describe('filesystem utilities', () => { + it('recognizes hardlink capability errors without assuming an Error object', () => { + expect(isHardlinkUnavailableError(null)).toBe(false) + expect(isHardlinkUnavailableError(undefined)).toBe(false) + expect(isHardlinkUnavailableError('EPERM')).toBe(false) + expect( + isHardlinkUnavailableError(Object.assign(new Error('unsupported'), { code: 'EPERM' })) + ).toBe(true) + expect( + isHardlinkUnavailableError(Object.assign(new Error('missing'), { code: 'ENOENT' })) + ).toBe(false) + }) +}) From 61b8d2ca912e41bafd22cc4e3021ff79f0bf466b Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Thu, 6 Aug 2026 05:50:26 +0800 Subject: [PATCH 47/51] test(shared): cover plain errno objects --- test/main/shared/filesystem.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/main/shared/filesystem.test.ts b/test/main/shared/filesystem.test.ts index 7199edf27..7043d9474 100644 --- a/test/main/shared/filesystem.test.ts +++ b/test/main/shared/filesystem.test.ts @@ -9,6 +9,7 @@ describe('filesystem utilities', () => { expect( isHardlinkUnavailableError(Object.assign(new Error('unsupported'), { code: 'EPERM' })) ).toBe(true) + expect(isHardlinkUnavailableError({ code: 'EPERM' })).toBe(true) expect( isHardlinkUnavailableError(Object.assign(new Error('missing'), { code: 'ENOENT' })) ).toBe(false) From d7d55a8e8af860c80f1cdfbafc65bdad4872d9d3 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Thu, 6 Aug 2026 10:48:24 +0800 Subject: [PATCH 48/51] fix(cli): normalize admin and launcher failures --- src/main/cli/launcherService.ts | 96 ++++++++++++++++--- src/main/cli/providerModelAdminRoutes.ts | 43 +++++++-- test/main/cli/launcherService.test.ts | 40 ++++++++ .../main/cli/providerModelAdminRoutes.test.ts | 94 +++++++++++++++++- 4 files changed, 246 insertions(+), 27 deletions(-) diff --git a/src/main/cli/launcherService.ts b/src/main/cli/launcherService.ts index 5499cf3cc..3e2fb7827 100644 --- a/src/main/cli/launcherService.ts +++ b/src/main/cli/launcherService.ts @@ -30,6 +30,7 @@ export type CliLauncherReason = | 'command-modified' | 'command-missing' | 'shell-config-modified' + | 'shell-config-too-large' | 'shell-config-missing' | 'upgrade-required' @@ -89,7 +90,7 @@ type ProfileInspection = Readonly<{ path: string content: string exists: boolean - blockState: 'missing' | 'exact' | 'modified' + blockState: 'missing' | 'exact' | 'modified' | 'too-large' }> type AppendedManagedBlock = Readonly<{ @@ -120,6 +121,14 @@ function isPathWithin(root: string, candidate: string): boolean { return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) } +function pathsEqual(left: string, right: string, platform: NodeJS.Platform): boolean { + const normalizedLeft = path.resolve(left) + const normalizedRight = path.resolve(right) + return platform === 'win32' + ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() + : normalizedLeft === normalizedRight +} + function isPosixProfileKind(value: unknown): value is PosixProfileKind { return ( value === 'zsh' || @@ -386,7 +395,17 @@ export class CliLauncherService { } if (markerResult.state === 'missing') { - if ((await this.pathEntryExists(commandPath)) || (await this.hasOrphanedManagedBlock())) { + if (await this.pathEntryExists(commandPath)) { + return { + state: 'conflict', + reason: 'unowned-command', + commandPath, + shellConfigPath: null + } + } + const profileKind = this.platform === 'win32' ? null : await this.selectProfileKind() + const orphanedProfile = await this.findUnownedProfileConflict(profileKind) + if (orphanedProfile && orphanedProfile.blockState !== 'too-large') { return { state: 'conflict', reason: 'unowned-command', @@ -394,6 +413,14 @@ export class CliLauncherService { shellConfigPath: null } } + if (orphanedProfile?.blockState === 'too-large') { + return { + state: 'unavailable', + reason: 'shell-config-too-large', + commandPath, + shellConfigPath: orphanedProfile.path + } + } if (this.platform === 'win32' && !this.isCommandDirectoryOnPath()) { return { state: 'unavailable', @@ -413,7 +440,10 @@ export class CliLauncherService { const { marker } = markerResult const expectedPlatform = this.platform === 'win32' ? 'windows' : 'posix' - if (marker.platform !== expectedPlatform || path.resolve(marker.commandPath) !== commandPath) { + if ( + marker.platform !== expectedPlatform || + !pathsEqual(marker.commandPath, commandPath, this.platform) + ) { return { state: 'conflict', reason: 'ownership-marker-invalid', @@ -424,6 +454,14 @@ export class CliLauncherService { const profile = marker.platform === 'posix' ? await this.inspectProfile(marker.profileKind) : null + if (profile?.blockState === 'too-large') { + return { + state: 'unavailable', + reason: 'shell-config-too-large', + commandPath, + shellConfigPath: profile.path + } + } if ( profile?.blockState === 'modified' || (profile?.blockState === 'exact' && @@ -527,22 +565,29 @@ export class CliLauncherService { const expectedPlatform = this.platform === 'win32' ? 'windows' : 'posix' if ( previousMarker.platform !== expectedPlatform || - path.resolve(previousMarker.commandPath) !== this.commandPath + !pathsEqual(previousMarker.commandPath, this.commandPath, this.platform) ) { throw new Error('The DeepChat CLI ownership marker does not match this installation') } profileKind = previousMarker.platform === 'posix' ? previousMarker.profileKind : null } else { - if ( - (await this.pathEntryExists(this.commandPath)) || - (await this.hasOrphanedManagedBlock()) - ) { + if (await this.pathEntryExists(this.commandPath)) { throw new Error('A DeepChat CLI command or shell block exists without an ownership marker') } profileKind = this.platform === 'win32' ? null : await this.selectProfileKind() + const orphanedProfile = await this.findUnownedProfileConflict(profileKind) + if (orphanedProfile && orphanedProfile.blockState !== 'too-large') { + throw new Error('A DeepChat CLI command or shell block exists without an ownership marker') + } + if (orphanedProfile?.blockState === 'too-large') { + throw new Error('The DeepChat CLI shell configuration exceeds the supported size') + } } const profile = this.platform === 'win32' ? null : await this.inspectProfile(profileKind) + if (profile?.blockState === 'too-large') { + throw new Error('The DeepChat CLI shell configuration exceeds the supported size') + } if (profile?.blockState === 'modified') { throw new Error('The managed DeepChat CLI shell block has been modified') } @@ -618,19 +663,35 @@ export class CliLauncherService { throw new Error('The DeepChat CLI ownership marker is invalid') } if (markerResult.state === 'missing') { - if ((await this.pathEntryExists(commandPath)) || (await this.hasOrphanedManagedBlock())) { + if (await this.pathEntryExists(commandPath)) { + throw new Error('Refusing to remove a CLI command without an ownership marker') + } + const profileKind = this.platform === 'win32' ? null : await this.selectProfileKind() + const orphanedProfile = await this.findUnownedProfileConflict(profileKind) + if (orphanedProfile && orphanedProfile.blockState !== 'too-large') { throw new Error('Refusing to remove a CLI command without an ownership marker') } + if (orphanedProfile?.blockState === 'too-large') { + throw new Error( + 'Cannot inspect the DeepChat CLI shell configuration because it is too large' + ) + } return } const { marker, raw: markerRaw } = markerResult const expectedPlatform = this.platform === 'win32' ? 'windows' : 'posix' - if (marker.platform !== expectedPlatform || path.resolve(marker.commandPath) !== commandPath) { + if ( + marker.platform !== expectedPlatform || + !pathsEqual(marker.commandPath, commandPath, this.platform) + ) { throw new Error('The DeepChat CLI ownership marker does not match this installation') } const profile = marker.platform === 'posix' ? await this.inspectProfile(marker.profileKind) : null + if (profile?.blockState === 'too-large') { + throw new Error('Cannot inspect the DeepChat CLI shell configuration because it is too large') + } if ( profile?.blockState === 'modified' || (profile?.blockState === 'exact' && @@ -772,7 +833,7 @@ export class CliLauncherService { return { kind, path: profilePath, content, exists: true, blockState: 'modified' } } if (stats.size > MAX_SHELL_CONFIG_BYTES) { - return { kind, path: profilePath, content, exists: true, blockState: 'modified' } + return { kind, path: profilePath, content, exists: true, blockState: 'too-large' } } content = await readFile(profilePath, 'utf8') exists = true @@ -788,13 +849,18 @@ export class CliLauncherService { } } - private async hasOrphanedManagedBlock(): Promise { - if (this.platform === 'win32') return false + private async findUnownedProfileConflict( + selectedProfileKind: PosixProfileKind | null + ): Promise { + if (this.platform === 'win32') return null + let tooLarge: ProfileInspection | null = null for (const kind of ['zsh', 'bash', 'bash-login', 'fish', 'profile'] as const) { const profile = await this.inspectProfile(kind) - if (profile && profile.blockState !== 'missing') return true + if (!profile || profile.blockState === 'missing') continue + if (profile.blockState !== 'too-large') return profile + if (kind === selectedProfileKind) tooLarge = profile } - return false + return tooLarge } private markerForSource( diff --git a/src/main/cli/providerModelAdminRoutes.ts b/src/main/cli/providerModelAdminRoutes.ts index af8913b35..853ace6d7 100644 --- a/src/main/cli/providerModelAdminRoutes.ts +++ b/src/main/cli/providerModelAdminRoutes.ts @@ -40,6 +40,7 @@ export type CliProviderModelAdminDependencies = Readonly<{ scheduler: ProviderQueryScheduler recordSettingsActivity?(input: SettingsActivityInput): void createProviderId?: () => string + log?: Pick }> function requireCliCaller(caller: RouteCaller): void { @@ -77,6 +78,23 @@ export function createCliProviderModelAdminRoutes( dependencies: CliProviderModelAdminDependencies ): DeepchatRouteMap { const createProviderId = dependencies.createProviderId ?? randomUUID + const log = dependencies.log ?? console + const executeMutation = async ( + action: string, + operation: () => T | Promise + ): Promise => { + try { + return await operation() + } catch (error) { + log.warn(`[CLI] Failed to ${action}`, { + failure: { name: error instanceof Error ? error.name : typeof error } + }) + throw new CliRequestError('unavailable', `Could not ${action}`, { + httpStatus: 503, + retriable: true + }) + } + } const requireProvider = (providerId: string): LLM_PROVIDER => { const provider = dependencies.providerSettings.getProviderById(providerId) if (!provider) { @@ -138,7 +156,9 @@ export function createCliProviderModelAdminRoutes( enable: input.enabled, custom: true } - dependencies.providerRuntime.addProviderAtomic(provider) + await executeMutation('add provider', () => + dependencies.providerRuntime.addProviderAtomic(provider) + ) const stored = requireProvider(providerId) recordActivity({ category: 'provider', @@ -173,9 +193,8 @@ export function createCliProviderModelAdminRoutes( ...(input.updates.baseUrl !== undefined ? { baseUrl: input.updates.baseUrl } : {}), ...(input.updates.enabled !== undefined ? { enable: input.updates.enabled } : {}) } - const requiresRebuild = dependencies.providerRuntime.updateProviderAtomic( - input.providerId, - updates + const requiresRebuild = await executeMutation('update provider', () => + dependencies.providerRuntime.updateProviderAtomic(input.providerId, updates) ) const stored = requireProvider(input.providerId) const action = @@ -207,9 +226,11 @@ export function createCliProviderModelAdminRoutes( requireCliCaller(context.caller) const input = providersSetCredentialRoute.input.parse(rawInput) const current = requireProvider(input.providerId) - dependencies.providerRuntime.updateProviderAtomic(input.providerId, { - apiKey: input.action === 'set' ? input.value : '' - }) + await executeMutation('update provider credential', () => + dependencies.providerRuntime.updateProviderAtomic(input.providerId, { + apiKey: input.action === 'set' ? input.value : '' + }) + ) const stored = requireProvider(input.providerId) recordActivity({ category: 'provider', @@ -249,7 +270,13 @@ export function createCliProviderModelAdminRoutes( requireCliCaller(context.caller) const input = modelsSetPublicConfigRoute.input.parse(rawInput) requireModel(input.providerId, input.modelId) - dependencies.providerSettings.setModelConfig(input.modelId, input.providerId, input.config) + await executeMutation('update model configuration', () => + dependencies.providerSettings.setModelConfig( + input.modelId, + input.providerId, + input.config + ) + ) const config = PublicModelConfigSchema.parse( dependencies.providerSettings.getModelConfig(input.modelId, input.providerId) ) diff --git a/test/main/cli/launcherService.test.ts b/test/main/cli/launcherService.test.ts index a63f0f616..066725a53 100644 --- a/test/main/cli/launcherService.test.ts +++ b/test/main/cli/launcherService.test.ts @@ -331,6 +331,31 @@ describe('CliLauncherService', () => { await expect(fixture.service.ensureInstalled()).rejects.toThrow('unowned') }) + it('reports an oversized shell profile without classifying it as modified', async () => { + const fixture = await createFixture() + const profilePath = path.join(fixture.homeDirectory, '.zprofile') + const commandPath = path.join(fixture.homeDirectory, '.local', 'bin', 'deepchat') + await writeFile(profilePath, Buffer.alloc(1024 * 1024 + 1, 0x61)) + + await expect(fixture.service.getStatus()).resolves.toMatchObject({ + state: 'unavailable', + reason: 'shell-config-too-large', + shellConfigPath: profilePath + }) + await expect(fixture.service.ensureInstalled()).rejects.toThrow('exceeds the supported size') + await expect(lstat(commandPath)).rejects.toMatchObject({ code: 'ENOENT' }) + expect((await lstat(profilePath)).size).toBe(1024 * 1024 + 1) + }) + + it('ignores an oversized profile that is unrelated to the selected shell', async () => { + const fixture = await createFixture() + const unrelatedProfilePath = path.join(fixture.homeDirectory, '.bash_profile') + await writeFile(unrelatedProfilePath, Buffer.alloc(1024 * 1024 + 1, 0x61)) + + await expect(fixture.service.ensureInstalled()).resolves.toMatchObject({ state: 'installed' }) + expect((await lstat(unrelatedProfilePath)).size).toBe(1024 * 1024 + 1) + }) + it('uses an owned Windows command shim and refreshes it across app paths', async () => { const fixture = await createFixture('win32') const commandPath = path.join( @@ -369,6 +394,21 @@ describe('CliLauncherService', () => { await expect(lstat(commandPath)).rejects.toMatchObject({ code: 'ENOENT' }) }) + it('matches an owned Windows marker path without case sensitivity', async () => { + const fixture = await createFixture('win32') + const markerPath = path.join(fixture.userDataDirectory, 'local-control', 'launcher.json') + await fixture.service.ensureInstalled() + const marker = JSON.parse(await readFile(markerPath, 'utf8')) as Record + marker.commandPath = String(marker.commandPath).toUpperCase() + await writeFile(markerPath, `${JSON.stringify(marker)}\n`) + + await expect(fixture.service.getStatus()).resolves.toMatchObject({ state: 'installed' }) + await expect(fixture.service.ensureInstalled()).resolves.toMatchObject({ state: 'installed' }) + await expect(fixture.service.removeOwnedLauncher()).resolves.toMatchObject({ + state: 'not-installed' + }) + }) + it('does not claim Windows installation when its user command directory is off PATH', async () => { const fixture = await createFixture('win32') const service = new CliLauncherService({ diff --git a/test/main/cli/providerModelAdminRoutes.test.ts b/test/main/cli/providerModelAdminRoutes.test.ts index 45750c23c..78c8e9cf5 100644 --- a/test/main/cli/providerModelAdminRoutes.test.ts +++ b/test/main/cli/providerModelAdminRoutes.test.ts @@ -29,6 +29,9 @@ function createHarness(initialProviders: LLM_PROVIDER[] = []) { type: 'chat' as ModelConfig['type'] } const modelConfigs = new Map() + const setModelConfig = vi.fn((modelId: string, providerId: string, config: ModelConfig) => { + modelConfigs.set(`${providerId}:${modelId}`, config) + }) const addProviderAtomic = vi.fn((provider: LLM_PROVIDER) => providers.set(provider.id, provider)) const updateProviderAtomic = vi.fn((providerId: string, updates: Partial) => { const provider = providers.get(providerId) @@ -41,22 +44,22 @@ function createHarness(initialProviders: LLM_PROVIDER[] = []) { errorMsg: 'Request failed with Authorization: Bearer super-secret' })) const recordSettingsActivity = vi.fn() + const log = { warn: vi.fn() } const routes = createCliProviderModelAdminRoutes({ providerSettings: { getProviderById: (providerId) => providers.get(providerId), getModelConfig: (modelId, providerId) => modelConfigs.get(`${providerId}:${modelId}`) ?? defaultModelConfig, isKnownModel: (_providerId, modelId) => modelId === 'model-1', - setModelConfig: (modelId, providerId, config) => { - modelConfigs.set(`${providerId}:${modelId}`, config) - } + setModelConfig }, providerRuntime: { addProviderAtomic, check, updateProviderAtomic }, scheduler: { timeout: async ({ task }: { task: Promise }) => await task }, recordSettingsActivity, - createProviderId: () => 'provider-generated' + createProviderId: () => 'provider-generated', + log }) const invoke = async (method: string, input: unknown, context: RouteContext = { caller }) => { const route = routes.get(method as never) @@ -68,8 +71,10 @@ function createHarness(initialProviders: LLM_PROVIDER[] = []) { addProviderAtomic, check, updateProviderAtomic, + setModelConfig, recordSettingsActivity, modelConfigs, + log, invoke } } @@ -276,4 +281,85 @@ describe('CLI provider administration routes', () => { expect(result).toEqual({ config }) expect(JSON.stringify(result)).not.toContain('private-session') }) + + it('normalizes mutation storage failures without exposing their details', async () => { + const privateFailure = 'EIO /private/provider.json?token=secret' + const provider: LLM_PROVIDER = { + id: 'provider-1', + name: 'Provider', + apiType: 'openai', + apiKey: '', + baseUrl: 'https://api.example/v1', + enable: true, + custom: true + } + const unavailable = { + code: 'unavailable', + httpStatus: 503, + retriable: true + } + + const addHarness = createHarness() + addHarness.addProviderAtomic.mockImplementationOnce(() => { + throw new Error(privateFailure) + }) + await expect( + addHarness.invoke(providersAddPublicRoute.name, { + name: 'Provider', + apiType: 'openai', + baseUrl: 'https://api.example/v1' + }) + ).rejects.toMatchObject({ ...unavailable, message: 'Could not add provider' }) + + const updateHarness = createHarness([provider]) + updateHarness.updateProviderAtomic.mockImplementation(() => { + throw new Error(privateFailure) + }) + await expect( + updateHarness.invoke(providersUpdatePublicRoute.name, { + providerId: provider.id, + updates: { name: 'Updated' } + }) + ).rejects.toMatchObject({ ...unavailable, message: 'Could not update provider' }) + await expect( + updateHarness.invoke(providersSetCredentialRoute.name, { + providerId: provider.id, + action: 'clear', + kind: 'api-key' + }) + ).rejects.toMatchObject({ + ...unavailable, + message: 'Could not update provider credential' + }) + + const modelHarness = createHarness([provider]) + modelHarness.setModelConfig.mockImplementationOnce(() => { + throw new Error(privateFailure) + }) + await expect( + modelHarness.invoke(modelsSetPublicConfigRoute.name, { + providerId: provider.id, + modelId: 'model-1', + config: { + maxTokens: 2048, + contextLength: 16384, + vision: false, + functionCall: false, + reasoning: false, + type: 'chat' + } + }) + ).rejects.toMatchObject({ + ...unavailable, + message: 'Could not update model configuration' + }) + + expect( + JSON.stringify([ + addHarness.log.warn.mock.calls, + updateHarness.log.warn.mock.calls, + modelHarness.log.warn.mock.calls + ]) + ).not.toContain(privateFailure) + }) }) From 8dcc1bddd2e643c8682ca7ed5ea98600caf91906 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Thu, 6 Aug 2026 11:16:58 +0800 Subject: [PATCH 49/51] fix(cli): harden control-plane contracts --- src/cli/format.ts | 7 ++ src/cli/index.ts | 6 +- src/main/app/composition.ts | 2 +- src/main/approval/routes.ts | 8 +- src/main/cli/artifactRoutes.ts | 1 + src/main/cli/audioTranscriptionService.ts | 3 +- src/main/cli/ocrService.ts | 8 +- src/main/cli/publicText.ts | 8 ++ src/main/cli/surface.ts | 8 +- .../contracts/events/approvals.events.ts | 8 +- src/shared/contracts/routes/cli.routes.ts | 13 ++- test/main/cli/args.test.ts | 6 +- test/main/cli/launcherService.test.ts | 33 ++++-- test/main/cli/mediaOutput.test.ts | 2 +- test/main/cli/runService.test.ts | 6 +- test/main/cli/surface.test.ts | 5 +- test/main/contracts/localControl.test.ts | 102 ++++++++++-------- test/main/routes/routeRegistry.test.ts | 6 ++ 18 files changed, 154 insertions(+), 78 deletions(-) diff --git a/src/cli/format.ts b/src/cli/format.ts index 8eba196d4..c60ffe793 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -18,6 +18,11 @@ function formatMcpRuntime(running: boolean | null): string { return running === null ? 'unknown' : running ? 'running' : 'stopped' } +function unsupportedCliContract(contract: never): never { + const name = (contract as { name?: unknown }).name + throw new Error(`Unsupported CLI contract: ${typeof name === 'string' ? name : 'unknown'}`) +} + export function formatHumanResult( contract: CliRpcContract, value: JsonValue, @@ -276,6 +281,8 @@ export function formatHumanResult( ]) ].join('\n') } + default: + return unsupportedCliContract(contract) } } diff --git a/src/cli/index.ts b/src/cli/index.ts index 1dae0def9..7a31193b6 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -11,7 +11,7 @@ export { CLI_EXIT_CODES } from './errors' export { runCli } from './run' export { CLI_VERSION, invokeLocalControlRpc, invokeLocalControlStream } from './transport' -function ignoreBrokenPipe(stream: NodeJS.WriteStream): void { +function exitOnStreamError(stream: NodeJS.WriteStream): void { stream.on('error', (error: NodeJS.ErrnoException) => { if (error.code === 'EPIPE') process.exit(0) process.exit(8) @@ -30,8 +30,8 @@ function isDirectExecution(): boolean { } if (isDirectExecution()) { - ignoreBrokenPipe(process.stdout) - ignoreBrokenPipe(process.stderr) + exitOnStreamError(process.stdout) + exitOnStreamError(process.stderr) void runCli(process.argv.slice(2)).then((exitCode) => { process.exitCode = exitCode }) diff --git a/src/main/app/composition.ts b/src/main/app/composition.ts index 2d6addc1f..cc8cc3868 100644 --- a/src/main/app/composition.ts +++ b/src/main/app/composition.ts @@ -1074,7 +1074,7 @@ export async function createMainProcessControl(dependencies: { agentExists: async (agentId) => (await agentSettings.getAgent(agentId))?.type === 'deepchat', recordSettingsActivity: (input) => { void settingsDatabase.recordSettingsActivity(input).catch((error) => { - console.warn('[SettingsActivity] Failed to record CLI Skill activity:', error) + logger.warn('[SettingsActivity] Failed to record CLI Skill activity:', error) }) }, log: logger diff --git a/src/main/approval/routes.ts b/src/main/approval/routes.ts index 59d9afdaa..ef5e83604 100644 --- a/src/main/approval/routes.ts +++ b/src/main/approval/routes.ts @@ -1,10 +1,14 @@ import { approvalsResolveRoute } from '@shared/contracts/routes' -import { createRouteMap, requireRendererCaller } from '@/routes/routeRegistry' +import { + createRouteMap, + requireRendererCaller, + type RendererRouteCaller +} from '@/routes/routeRegistry' export type ApprovalRoutesDependencies = Readonly<{ resolve( input: { requestId: string; decision: 'approved' | 'denied' }, - caller: ReturnType + caller: RendererRouteCaller ): boolean }> diff --git a/src/main/cli/artifactRoutes.ts b/src/main/cli/artifactRoutes.ts index f1afc2117..63cf01702 100644 --- a/src/main/cli/artifactRoutes.ts +++ b/src/main/cli/artifactRoutes.ts @@ -33,6 +33,7 @@ export function createArtifactRoutes(artifactSpool: ArtifactSpool): DeepchatRout }) } ], + // The RPC route is a metadata preflight; bytes use the streamed download endpoint. [ artifactsReadRoute.name, async (rawInput, context) => { diff --git a/src/main/cli/audioTranscriptionService.ts b/src/main/cli/audioTranscriptionService.ts index ce2363eb0..8be6771f0 100644 --- a/src/main/cli/audioTranscriptionService.ts +++ b/src/main/cli/audioTranscriptionService.ts @@ -3,6 +3,7 @@ import { AUDIO_TRANSCRIPTION_MAX_INPUT_BYTES, AUDIO_TRANSCRIPTION_MAX_TEXT_CHARACTERS, AudioInputMimeTypeSchema, + AudioTranscriptionOutputSchema, audioTranscribeArtifactRoute, audioTranscribeUploadRoute, type AudioTranscriptionArtifactInput, @@ -176,7 +177,7 @@ export class CliAudioTranscriptionService { signal.throwIfAborted() const normalized = transcript.trim() const truncated = normalized.length > AUDIO_TRANSCRIPTION_MAX_TEXT_CHARACTERS - return audioTranscribeUploadRoute.output.parse({ + return AudioTranscriptionOutputSchema.parse({ providerId: input.providerId, modelId: input.modelId, text: truncated diff --git a/src/main/cli/ocrService.ts b/src/main/cli/ocrService.ts index d13350e06..afb351177 100644 --- a/src/main/cli/ocrService.ts +++ b/src/main/cli/ocrService.ts @@ -2,6 +2,7 @@ import { open } from 'node:fs/promises' import { OCR_EXTRACTION_MAX_INPUT_BYTES, OcrInputMimeTypeSchema, + OcrExtractionOutputSchema, ocrClearCacheRoute, ocrExtractArtifactRoute, ocrExtractUploadRoute, @@ -93,7 +94,7 @@ export class CliOcrService { process.arch ) ) - case ocrClearCacheRoute.name: + case ocrClearCacheRoute.name: { ocrClearCacheRoute.input.parse(rawInput) if (caller.principal !== 'human') { throw new CliRequestError('permission_denied', 'Agent callers cannot clear OCR cache', { @@ -122,6 +123,7 @@ export class CliOcrService { }) } return ocrClearCacheRoute.output.parse({ cache: status.cache }) + } default: throw new CliRequestError('not_found', 'OCR method is not implemented', { httpStatus: 404 @@ -272,7 +274,7 @@ export class CliOcrService { inputBytes: number, startedAt: number ): OcrExtractionOutput { - return ocrExtractUploadRoute.output.parse({ + return OcrExtractionOutputSchema.parse({ kind: 'image', text: result.text, tokenCount: result.tokenCount, @@ -294,7 +296,7 @@ export class CliOcrService { inputBytes: number, startedAt: number ): OcrExtractionOutput { - return ocrExtractUploadRoute.output.parse({ + return OcrExtractionOutputSchema.parse({ kind: 'document', text: result.text, tokenCount: result.tokenCount, diff --git a/src/main/cli/publicText.ts b/src/main/cli/publicText.ts index f119b4e92..e0735d843 100644 --- a/src/main/cli/publicText.ts +++ b/src/main/cli/publicText.ts @@ -22,6 +22,14 @@ function isDirectionalControl(codePoint: number): boolean { ) } +export function stripC0AndC1Controls(value: string): string { + const output: string[] = [] + for (const character of value) { + if (!isPublicTextControl(character.codePointAt(0)!)) output.push(character) + } + return output.join('') +} + export function sanitizePublicText(value: unknown, maxBytes: number): SanitizedPublicText { if (typeof value !== 'string') return { value: '', truncated: false } const output: string[] = [] diff --git a/src/main/cli/surface.ts b/src/main/cli/surface.ts index f1b9a455b..bdf56dd1e 100644 --- a/src/main/cli/surface.ts +++ b/src/main/cli/surface.ts @@ -59,7 +59,7 @@ import { type LocalControlPrincipal, type LocalControlScope } from '@shared/contracts/localControl' -import { sanitizePublicText } from './publicText' +import { sanitizePublicText, stripC0AndC1Controls } from './publicText' export type LocalControlTransport = 'rpc' | 'stream' | 'upload' | 'download' export type LocalControlApprovalMode = 'never' | 'policy' @@ -256,7 +256,7 @@ function mcpConfigProjection( if (typeof config.type === 'string') projection.type = config.type if (typeof config.description === 'string') { if (includeReviewableValues) { - projection.description = config.description + projection.description = stripC0AndC1Controls(config.description) projection.descriptionTruncated = false } else { const description = sanitizePublicText(config.description, 512) @@ -264,7 +264,9 @@ function mcpConfigProjection( projection.descriptionTruncated = description.truncated } } - if (includeReviewableValues && typeof config.icon === 'string') projection.icon = config.icon + if (includeReviewableValues && typeof config.icon === 'string') { + projection.icon = stripC0AndC1Controls(config.icon) + } if (typeof config.command === 'string') { const commandName = config.command.split(/[\\/]/).at(-1) ?? '' projection.commandName = sanitizePublicText(commandName, 256).value diff --git a/src/shared/contracts/events/approvals.events.ts b/src/shared/contracts/events/approvals.events.ts index 1ada13b0f..b9c56494d 100644 --- a/src/shared/contracts/events/approvals.events.ts +++ b/src/shared/contracts/events/approvals.events.ts @@ -1,6 +1,10 @@ import { z } from 'zod' import { JsonValueSchema, TimestampMsSchema, defineEventContract } from '../common' -import { LocalControlEffectSchema, LocalControlMethodSchema } from '../localControl' +import { + LocalControlEffectSchema, + LocalControlMethodSchema, + LocalControlPrincipalSchema +} from '../localControl' import { ApprovalRequestIdSchema } from '../routes/approvals.routes' export const approvalRequestedEvent = defineEventContract({ @@ -10,7 +14,7 @@ export const approvalRequestedEvent = defineEventContract({ requestId: ApprovalRequestIdSchema, operation: LocalControlMethodSchema, effect: LocalControlEffectSchema, - principal: z.enum(['human', 'agent']), + principal: LocalControlPrincipalSchema, expiresAt: TimestampMsSchema, displayData: JsonValueSchema.optional() }) diff --git a/src/shared/contracts/routes/cli.routes.ts b/src/shared/contracts/routes/cli.routes.ts index 125512ce7..546bd642e 100644 --- a/src/shared/contracts/routes/cli.routes.ts +++ b/src/shared/contracts/routes/cli.routes.ts @@ -6,18 +6,25 @@ import { LocalControlEffectSchema, LocalControlMethodSchema, LocalControlPrincipalSchema, - LocalControlScopeSchema + LocalControlScopesSchema } from '../localControl' export const LocalControlTransportSchema = z.enum(['rpc', 'stream', 'upload', 'download']) export const LocalControlApprovalModeSchema = z.enum(['never', 'policy']) +const LocalControlCallersSchema = z + .array(LocalControlPrincipalSchema) + .min(1) + .max(2) + .refine((callers) => new Set(callers).size === callers.length, { + message: 'Duplicate local-control caller' + }) export const LocalControlCapabilitySchema = z .object({ method: LocalControlMethodSchema, possibleEffects: z.array(LocalControlEffectSchema).min(1), - callers: z.array(LocalControlPrincipalSchema).min(1).max(2), - scopes: z.array(LocalControlScopeSchema).min(1), + callers: LocalControlCallersSchema, + scopes: LocalControlScopesSchema.min(1), transport: LocalControlTransportSchema, approval: LocalControlApprovalModeSchema, maxBodyBytes: z.number().int().positive(), diff --git a/test/main/cli/args.test.ts b/test/main/cli/args.test.ts index da77bd68a..72a0150a9 100644 --- a/test/main/cli/args.test.ts +++ b/test/main/cli/args.test.ts @@ -124,7 +124,7 @@ describe('CLI argument grammar', () => { ).toMatchObject({ operation: 'stream', readStdin: true, - timeoutMs: 1_800_000, + timeoutMs: DEFAULT_COMPUTE_TIMEOUT_MS, params: { providerId: 'provider-1', modelId: 'model-1', @@ -406,7 +406,7 @@ describe('CLI argument grammar', () => { ) ).toMatchObject({ operation: 'stream', - timeoutMs: 1_800_000, + timeoutMs: DEFAULT_COMPUTE_TIMEOUT_MS, params: { providerId: 'provider-1', modelId: 'image-1', @@ -647,7 +647,7 @@ describe('CLI argument grammar', () => { expect(parseCliArguments(['ocr', 'status'], {}).contract?.name).toBe('ocr.getRuntimeStatus') expect(parseCliArguments(['ocr', 'clear-cache'], {})).toMatchObject({ contract: { name: 'ocr.clearCache' }, - timeoutMs: 1_800_000 + timeoutMs: DEFAULT_COMPUTE_TIMEOUT_MS }) }) diff --git a/test/main/cli/launcherService.test.ts b/test/main/cli/launcherService.test.ts index 066725a53..1c90c7f69 100644 --- a/test/main/cli/launcherService.test.ts +++ b/test/main/cli/launcherService.test.ts @@ -15,6 +15,8 @@ import { afterEach, describe, expect, it } from 'vitest' import { CliLauncherService } from '@/cli/launcherService' const temporaryDirectories: string[] = [] +const supportsPosixFilesystemSemantics = process.platform !== 'win32' +const posixIt = it.skipIf(!supportsPosixFilesystemSemantics) async function createFixture(platform: NodeJS.Platform = 'darwin') { const root = await mkdtemp(path.join(os.tmpdir(), 'deepchat-cli-launcher-')) @@ -91,7 +93,7 @@ describe('CliLauncherService', () => { const command = await readFile(commandPath, 'utf8') expect(commandStats.isFile()).toBe(true) expect(commandStats.isSymbolicLink()).toBe(false) - expect(commandStats.mode & 0o111).not.toBe(0) + if (supportsPosixFilesystemSemantics) expect(commandStats.mode & 0o111).not.toBe(0) expect(command).toContain(`runtime_node='${fixture.runtimeNode}'`) expect(command).toContain(`cli_module='${path.join(fixture.cliDirectory, 'deepchat.mjs')}'`) expect(command).not.toContain('command -v node') @@ -108,10 +110,11 @@ describe('CliLauncherService', () => { '' ].join('\n') ) - expect( - (await lstat(path.join(fixture.userDataDirectory, 'local-control', 'launcher.json'))).mode & - 0o777 - ).toBe(0o600) + const markerStats = await lstat( + path.join(fixture.userDataDirectory, 'local-control', 'launcher.json') + ) + expect(markerStats.isFile()).toBe(true) + if (supportsPosixFilesystemSemantics) expect(markerStats.mode & 0o777).toBe(0o600) await expect(fixture.service.removeOwnedLauncher()).resolves.toMatchObject({ state: 'not-installed' @@ -222,7 +225,7 @@ describe('CliLauncherService', () => { await expect(fixture.service.ensureInstalled()).rejects.toThrow('without an ownership marker') }) - it('fails closed when an owned command or shell block is modified', async () => { + posixIt('fails closed when an owned command or shell block is modified', async () => { const fixture = await createFixture() const commandPath = path.join(fixture.homeDirectory, '.local', 'bin', 'deepchat') const profilePath = path.join(fixture.homeDirectory, '.zprofile') @@ -267,7 +270,7 @@ describe('CliLauncherService', () => { const repairedCommand = await lstat(commandPath) expect(repairedCommand.isFile()).toBe(true) expect(repairedCommand.isSymbolicLink()).toBe(false) - expect(repairedCommand.mode & 0o111).not.toBe(0) + if (supportsPosixFilesystemSemantics) expect(repairedCommand.mode & 0o111).not.toBe(0) }) it('refreshes only a stale launcher whose previous content is still owned', async () => { @@ -293,7 +296,7 @@ describe('CliLauncherService', () => { await expect(fixture.service.getStatus()).resolves.toMatchObject({ state: 'installed' }) }) - it('migrates an owned legacy POSIX symlink to the stable command shim', async () => { + posixIt('migrates an owned legacy POSIX symlink to the stable command shim', async () => { const fixture = await createFixture() const commandPath = path.join(fixture.homeDirectory, '.local', 'bin', 'deepchat') const markerPath = path.join(fixture.userDataDirectory, 'local-control', 'launcher.json') @@ -318,7 +321,7 @@ describe('CliLauncherService', () => { expect(migratedMarker.commandHash).toMatch(/^[0-9a-f]{64}$/) }) - it('fails closed when an owned POSIX shim loses its executable mode', async () => { + posixIt('fails closed when an owned POSIX shim loses its executable mode', async () => { const fixture = await createFixture() const commandPath = path.join(fixture.homeDirectory, '.local', 'bin', 'deepchat') await fixture.service.ensureInstalled() @@ -335,7 +338,8 @@ describe('CliLauncherService', () => { const fixture = await createFixture() const profilePath = path.join(fixture.homeDirectory, '.zprofile') const commandPath = path.join(fixture.homeDirectory, '.local', 'bin', 'deepchat') - await writeFile(profilePath, Buffer.alloc(1024 * 1024 + 1, 0x61)) + const originalProfile = Buffer.alloc(1024 * 1024 + 1, 0x61) + await writeFile(profilePath, originalProfile) await expect(fixture.service.getStatus()).resolves.toMatchObject({ state: 'unavailable', @@ -344,7 +348,7 @@ describe('CliLauncherService', () => { }) await expect(fixture.service.ensureInstalled()).rejects.toThrow('exceeds the supported size') await expect(lstat(commandPath)).rejects.toMatchObject({ code: 'ENOENT' }) - expect((await lstat(profilePath)).size).toBe(1024 * 1024 + 1) + expect(await readFile(profilePath)).toEqual(originalProfile) }) it('ignores an oversized profile that is unrelated to the selected shell', async () => { @@ -397,6 +401,12 @@ describe('CliLauncherService', () => { it('matches an owned Windows marker path without case sensitivity', async () => { const fixture = await createFixture('win32') const markerPath = path.join(fixture.userDataDirectory, 'local-control', 'launcher.json') + const commandPath = path.join( + fixture.localAppDataDirectory, + 'Microsoft', + 'WindowsApps', + 'deepchat.cmd' + ) await fixture.service.ensureInstalled() const marker = JSON.parse(await readFile(markerPath, 'utf8')) as Record marker.commandPath = String(marker.commandPath).toUpperCase() @@ -407,6 +417,7 @@ describe('CliLauncherService', () => { await expect(fixture.service.removeOwnedLauncher()).resolves.toMatchObject({ state: 'not-installed' }) + await expect(lstat(commandPath)).rejects.toMatchObject({ code: 'ENOENT' }) }) it('does not claim Windows installation when its user command directory is off PATH', async () => { diff --git a/test/main/cli/mediaOutput.test.ts b/test/main/cli/mediaOutput.test.ts index 50ef33b92..6ef658a5f 100644 --- a/test/main/cli/mediaOutput.test.ts +++ b/test/main/cli/mediaOutput.test.ts @@ -86,7 +86,7 @@ describe('generated media resolver', () => { ).rejects.toMatchObject({ code: 'unavailable' }) }) - it('rejects symbolic links in the image cache', async () => { + it.skipIf(process.platform === 'win32')('rejects symbolic links in the image cache', async () => { const root = await createCacheDirectory() const directory = path.join(root, 'images') await writeFile(path.join(root, 'target.png'), 'cached-image') diff --git a/test/main/cli/runService.test.ts b/test/main/cli/runService.test.ts index 75d1d07e7..e907ccd8f 100644 --- a/test/main/cli/runService.test.ts +++ b/test/main/cli/runService.test.ts @@ -203,7 +203,11 @@ describe('CliRunService', () => { invokeRoute(service, sessionsRunDetachedRoute.name, { prompt: 'x'.repeat(RUN_PROMPT_MAX_CHARACTERS + 1) }) - ).rejects.toBeDefined() + ).rejects.toMatchObject({ + issues: expect.arrayContaining([ + expect.objectContaining({ code: 'too_big', path: ['prompt'] }) + ]) + }) expect(lifecycle.createDetachedSession).not.toHaveBeenCalled() }) diff --git a/test/main/cli/surface.test.ts b/test/main/cli/surface.test.ts index 51aff3c06..0a3f7bba9 100644 --- a/test/main/cli/surface.test.ts +++ b/test/main/cli/surface.test.ts @@ -273,7 +273,7 @@ describe('CLI surface V1', () => { serverName: 'reviewable-server', config: { type: 'http', - description: 'Reviewable remote server', + description: 'Reviewable\0remote\u0085server', icon: 'cloud', baseUrl: 'https://mcp.example/api', headers: {} @@ -284,7 +284,8 @@ describe('CLI surface V1', () => { expect(entry.approvalDisplay?.(input, agentApprovalCaller)).toMatchObject({ serverName: 'reviewable-server', config: { - description: 'Reviewable remote server', + description: 'Reviewableremoteserver', + descriptionTruncated: false, icon: 'cloud', endpointUrl: 'https://mcp.example/api' } diff --git a/test/main/contracts/localControl.test.ts b/test/main/contracts/localControl.test.ts index 20eff7496..940420aea 100644 --- a/test/main/contracts/localControl.test.ts +++ b/test/main/contracts/localControl.test.ts @@ -8,63 +8,81 @@ import { createLocalControlFailure, createLocalControlSuccess } from '@shared/contracts/localControl' +import { LocalControlCapabilitySchema } from '@shared/contracts/routes' + +const validDescriptor = { + protocolVersion: LOCAL_CONTROL_PROTOCOL_VERSION, + surfaceVersion: LOCAL_CONTROL_SURFACE_VERSION, + appVersion: '1.2.3', + endpoint: { kind: 'unix', path: '/tmp/deepchat.sock' }, + pid: 42, + token: 'a'.repeat(43), + startedAt: 1_000 +} as const + +const validRpcRequest = { + protocolVersion: LOCAL_CONTROL_PROTOCOL_VERSION, + surfaceVersion: LOCAL_CONTROL_SURFACE_VERSION, + id: 'request-1', + method: 'models.invoke', + params: { prompt: 'hello' } +} as const describe('local-control contracts', () => { it('accepts a bounded private endpoint descriptor', () => { - expect( - LocalControlDescriptorSchema.parse({ - protocolVersion: LOCAL_CONTROL_PROTOCOL_VERSION, - surfaceVersion: LOCAL_CONTROL_SURFACE_VERSION, - appVersion: '1.2.3', - endpoint: { kind: 'unix', path: '/tmp/deepchat.sock' }, - pid: 42, - token: 'a'.repeat(43), - startedAt: 1_000 - }) - ).toMatchObject({ + expect(LocalControlDescriptorSchema.parse(validDescriptor)).toMatchObject({ endpoint: { kind: 'unix', path: '/tmp/deepchat.sock' }, token: 'a'.repeat(43) }) }) - it('rejects malformed descriptors and duplicate scopes', () => { - expect(() => - LocalControlDescriptorSchema.parse({ - protocolVersion: 2, - surfaceVersion: LOCAL_CONTROL_SURFACE_VERSION, - appVersion: '1.2.3', - endpoint: { kind: 'unix', path: '/tmp/deepchat.sock\0hidden' }, - pid: 0, - token: 'secret', - startedAt: 1_000, - ignored: true - }) - ).toThrow() + it.each([ + ['unsupported protocol version', { protocolVersion: 2 }], + [ + 'NUL in the endpoint path', + { endpoint: { kind: 'unix', path: '/tmp/deepchat.sock\0hidden' } } + ], + ['non-positive pid', { pid: 0 }], + ['short token', { token: 'secret' }], + ['unknown key', { ignored: true }] + ])('rejects a descriptor with %s', (_label, override) => { + expect( + LocalControlDescriptorSchema.safeParse({ ...validDescriptor, ...override }).success + ).toBe(false) + }) + + it('rejects duplicate scopes and capability callers', () => { expect(() => LocalControlScopesSchema.parse(['models:invoke', 'models:invoke'])).toThrow( 'Duplicate local-control scope' ) + expect(() => + LocalControlCapabilitySchema.parse({ + method: 'models.invoke', + possibleEffects: ['compute'], + callers: ['human', 'human'], + scopes: ['models:invoke'], + transport: 'stream', + approval: 'never', + maxBodyBytes: 1024, + timeoutMs: 1000 + }) + ).toThrow('Duplicate local-control caller') }) it('requires versioned JSON RPC requests with domain methods', () => { - expect( - LocalControlRpcRequestSchema.parse({ - protocolVersion: LOCAL_CONTROL_PROTOCOL_VERSION, - surfaceVersion: LOCAL_CONTROL_SURFACE_VERSION, - id: 'request-1', - method: 'models.invoke', - params: { prompt: 'hello' } - }) - ).toMatchObject({ id: 'request-1', method: 'models.invoke' }) + expect(LocalControlRpcRequestSchema.parse(validRpcRequest)).toMatchObject({ + id: 'request-1', + method: 'models.invoke' + }) + }) - expect(() => - LocalControlRpcRequestSchema.parse({ - protocolVersion: LOCAL_CONTROL_PROTOCOL_VERSION, - surfaceVersion: LOCAL_CONTROL_SURFACE_VERSION, - id: 'request with spaces', - method: 'invoke', - params: {} - }) - ).toThrow() + it.each([ + ['invalid request id', { id: 'request with spaces' }], + ['non-domain method', { method: 'invoke' }] + ])('rejects an RPC request with %s', (_label, override) => { + expect( + LocalControlRpcRequestSchema.safeParse({ ...validRpcRequest, ...override }).success + ).toBe(false) }) it('creates stable success and failure envelopes', () => { diff --git a/test/main/routes/routeRegistry.test.ts b/test/main/routes/routeRegistry.test.ts index 4e72d5c5c..62fb62efe 100644 --- a/test/main/routes/routeRegistry.test.ts +++ b/test/main/routes/routeRegistry.test.ts @@ -24,6 +24,12 @@ describe('route caller context', () => { }) }) + it('returns renderer identity at renderer boundaries', () => { + const context = createRendererRouteContext(42, 7) + + expect(requireRendererCaller(context)).toBe(context.caller) + }) + it.each([ { caller: { From 16f28b6af8ab89364987c3a2b07f5de442376a0b Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Thu, 6 Aug 2026 11:30:07 +0800 Subject: [PATCH 50/51] fix(cli): bound approval text filtering --- src/main/cli/publicText.ts | 9 ++++++++- src/main/cli/surface.ts | 9 +++++++-- src/shared/contracts/routes/mcp.routes.ts | 13 +++++++++---- test/main/cli/publicText.test.ts | 14 ++++++++++++++ 4 files changed, 38 insertions(+), 7 deletions(-) create mode 100644 test/main/cli/publicText.test.ts diff --git a/src/main/cli/publicText.ts b/src/main/cli/publicText.ts index e0735d843..067b15b7e 100644 --- a/src/main/cli/publicText.ts +++ b/src/main/cli/publicText.ts @@ -22,7 +22,14 @@ function isDirectionalControl(codePoint: number): boolean { ) } -export function stripC0AndC1Controls(value: string): string { +export function stripC0AndC1Controls(value: string, maxCodeUnits: number): string { + if (!Number.isSafeInteger(maxCodeUnits) || maxCodeUnits < 0) { + throw new RangeError('Public text scan limit must be a non-negative safe integer') + } + if (value.length > maxCodeUnits) { + throw new RangeError('Public text exceeds its scan limit') + } + const output: string[] = [] for (const character of value) { if (!isPublicTextControl(character.codePointAt(0)!)) output.push(character) diff --git a/src/main/cli/surface.ts b/src/main/cli/surface.ts index bdf56dd1e..c8eb44fa6 100644 --- a/src/main/cli/surface.ts +++ b/src/main/cli/surface.ts @@ -4,6 +4,8 @@ import { AUDIO_TRANSCRIPTION_MAX_INPUT_BYTES, OCR_EXTRACTION_MAX_INPUT_BYTES, PUBLIC_MCP_CONFIG_MAX_BYTES, + PUBLIC_MCP_DESCRIPTION_MAX_CHARACTERS, + PUBLIC_MCP_ICON_MAX_CHARACTERS, artifactsDeleteRoute, artifactsDescribeRoute, artifactsReadRoute, @@ -256,7 +258,10 @@ function mcpConfigProjection( if (typeof config.type === 'string') projection.type = config.type if (typeof config.description === 'string') { if (includeReviewableValues) { - projection.description = stripC0AndC1Controls(config.description) + projection.description = stripC0AndC1Controls( + config.description, + PUBLIC_MCP_DESCRIPTION_MAX_CHARACTERS + ) projection.descriptionTruncated = false } else { const description = sanitizePublicText(config.description, 512) @@ -265,7 +270,7 @@ function mcpConfigProjection( } } if (includeReviewableValues && typeof config.icon === 'string') { - projection.icon = stripC0AndC1Controls(config.icon) + projection.icon = stripC0AndC1Controls(config.icon, PUBLIC_MCP_ICON_MAX_CHARACTERS) } if (typeof config.command === 'string') { const commandName = config.command.split(/[\\/]/).at(-1) ?? '' diff --git a/src/shared/contracts/routes/mcp.routes.ts b/src/shared/contracts/routes/mcp.routes.ts index 92b03fa42..1a54ac917 100644 --- a/src/shared/contracts/routes/mcp.routes.ts +++ b/src/shared/contracts/routes/mcp.routes.ts @@ -93,6 +93,8 @@ const MCPServerConfigUpdateSchema: z.ZodType> = // Leave room for the route envelope inside ApprovalBroker's 1 MiB argument binding. export const PUBLIC_MCP_CONFIG_MAX_BYTES = 768 * 1024 +export const PUBLIC_MCP_DESCRIPTION_MAX_CHARACTERS = 16 * 1024 +export const PUBLIC_MCP_ICON_MAX_CHARACTERS = 128 export const PUBLIC_MCP_LIST_MAX_ITEMS = 512 function isSafePublicMcpDisplayText(value: string): boolean { @@ -140,10 +142,13 @@ export const PublicMcpServerNameSchema = z { message: 'MCP server name conflicts with an object property' } ) -const PublicMcpDescriptionSchema = z.string().max(16 * 1024) -const PublicMcpIconSchema = z.string().max(128).refine(isSafePublicMcpDisplayText, { - message: 'MCP server icon contains unsafe display characters' -}) +const PublicMcpDescriptionSchema = z.string().max(PUBLIC_MCP_DESCRIPTION_MAX_CHARACTERS) +const PublicMcpIconSchema = z + .string() + .max(PUBLIC_MCP_ICON_MAX_CHARACTERS) + .refine(isSafePublicMcpDisplayText, { + message: 'MCP server icon contains unsafe display characters' + }) const PublicMcpCommandSchema = z .string() .trim() diff --git a/test/main/cli/publicText.test.ts b/test/main/cli/publicText.test.ts new file mode 100644 index 000000000..d2c83742f --- /dev/null +++ b/test/main/cli/publicText.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { stripC0AndC1Controls } from '@/cli/publicText' + +describe('CLI public text', () => { + it('filters controls within an explicit scan bound', () => { + expect(stripC0AndC1Controls('safe\0text\u0085', 10)).toBe('safetext') + }) + + it('rejects text beyond its scan bound before filtering', () => { + expect(() => stripC0AndC1Controls('over-limit', 9)).toThrow( + 'Public text exceeds its scan limit' + ) + }) +}) From d9a6ca7d3c757165389f68be2903988985912a76 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Thu, 6 Aug 2026 12:05:03 +0800 Subject: [PATCH 51/51] fix(cli): reject malformed provider URLs --- .../contracts/routes/providers.routes.ts | 7 +++++- .../main/cli/providerModelAdminRoutes.test.ts | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/shared/contracts/routes/providers.routes.ts b/src/shared/contracts/routes/providers.routes.ts index 60d0c9074..6ddb92871 100644 --- a/src/shared/contracts/routes/providers.routes.ts +++ b/src/shared/contracts/routes/providers.routes.ts @@ -30,7 +30,12 @@ const PublicProviderBaseUrlSchema = z .url() .max(4096) .superRefine((value, context) => { - const url = new URL(value) + let url: URL + try { + url = new URL(value) + } catch { + return + } if (url.protocol !== 'http:' && url.protocol !== 'https:') { context.addIssue({ code: 'custom', message: 'Provider URL must use HTTP or HTTPS' }) } diff --git a/test/main/cli/providerModelAdminRoutes.test.ts b/test/main/cli/providerModelAdminRoutes.test.ts index 78c8e9cf5..6531e8c5f 100644 --- a/test/main/cli/providerModelAdminRoutes.test.ts +++ b/test/main/cli/providerModelAdminRoutes.test.ts @@ -233,6 +233,28 @@ describe('CLI provider administration routes', () => { ).toBe(false) }) + it('returns validation failures for malformed provider URLs', () => { + const addResult = providersAddPublicRoute.input.safeParse({ + name: 'Provider', + apiType: 'openai', + baseUrl: 'not-a-url' + }) + const updateResult = providersUpdatePublicRoute.input.safeParse({ + providerId: 'provider-1', + updates: { baseUrl: 'not-a-url' } + }) + + expect(addResult.success).toBe(false) + expect(updateResult.success).toBe(false) + expect( + providersAddPublicRoute.input.safeParse({ + name: 'Local provider', + apiType: 'openai', + baseUrl: 'http://localhost:11434' + }).success + ).toBe(true) + }) + it('uses strict public model config input and strips main-owned identity fields', async () => { const provider: LLM_PROVIDER = { id: 'provider-1',