diff --git a/.github/workflows/pi-ai-provider-e2e.yml b/.github/workflows/pi-ai-provider-e2e.yml new file mode 100644 index 0000000000..d198abf5b5 --- /dev/null +++ b/.github/workflows/pi-ai-provider-e2e.yml @@ -0,0 +1,78 @@ +name: E2E (pi-ai Azure OpenAI and Anthropic) + +# This suite spends tokens against two external providers and is intentionally +# opt-in. It has no push, pull_request, schedule, or workflow_call trigger. +on: + workflow_dispatch: + inputs: + azure_openai_model: + description: Azure OpenAI model from pi-ai's installed catalog + required: true + default: gpt-5.5 + type: string + anthropic_model: + description: Anthropic model from pi-ai's installed catalog + required: true + default: claude-opus-4-8 + type: string + +permissions: + contents: read + +jobs: + e2e: + runs-on: ubuntu-latest + name: Azure OpenAI Responses + Anthropic Messages + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Enable corepack (pnpm) + run: corepack enable + + - name: Resolve pnpm store path + id: pnpm-store + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-24-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-24-pnpm- + + - name: Install (immutable) + run: pnpm install --frozen-lockfile + + # The tests self-skip locally when a credential is absent. A manually + # dispatched CI run must fail instead of reporting an all-skipped green. + - name: Preflight (require provider API keys) + env: + AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY_EXTERNAL }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY_EXTERNAL }} + run: | + set -euo pipefail + missing=0 + for name in AZURE_OPENAI_API_KEY ANTHROPIC_API_KEY; do + if [ -z "${!name:-}" ]; then + echo "::error::${name} is empty. Configure the corresponding *_EXTERNAL repository secret." + missing=1 + fi + done + exit "$missing" + + - name: E2E tests (real Azure OpenAI and Anthropic APIs) + env: + AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY_EXTERNAL }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY_EXTERNAL }} + DSH_PI_AI_OPENAI_MODEL: ${{ inputs.azure_openai_model }} + DSH_PI_AI_OPENAI_BASE_URL: https://openai-routerhub-resource.services.ai.azure.com/api/projects/openai/openai/v1 + DSH_PI_AI_ANTHROPIC_MODEL: ${{ inputs.anthropic_model }} + DSH_E2E_MAX_WORKERS: 2 + run: >- + pnpm exec vitest run --config vitest.e2e.config.ts + packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2b540f61a8..15433ee095 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -64,7 +64,7 @@ export interface Config { skills?: agentCore.SkillConfig /** Model-facing bash tool config forwarded through agent-core. */ toolBash?: NonNullable - /** Generic background-task control-tool config forwarded through agent-core. */ + /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ toolTasks?: NonNullable } ``` @@ -140,12 +140,14 @@ export interface Config { skills?: SkillConfig /** Model-facing bash tool config, including this producer's background opt-in. */ toolBash?: toolBash.Config - /** Generic background-task control-tool wait bounds. */ - toolTasks?: toolTasks.Config + /** Generic background-task controls; set false to keep the task service without model-facing task tools. */ + toolTasks?: toolTasks.Config | false } /** Skill bundle config forwarded to the registry, local provider, and model-facing consumer. */ export interface SkillConfig { + /** Mount the bundled local skill provider and model-facing skill tool (default true). */ + enabled?: boolean /** Registry-level discovery cache settings. */ registry?: SkillRegistryConfig /** Local filesystem skill provider settings. */ @@ -157,7 +159,7 @@ export interface SkillConfig { Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:57`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:59`](../packages/examples/agent-spine-demo/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -172,7 +174,9 @@ export interface Config { maxTimeoutMs?: number /** Per-stream in-memory output cap; overflow spills to a temp file. */ maxOutputBytes?: number - /** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */ + /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ + maxSpillBytes?: number + /** Grace period for kill escalation and for inherited pipes after shell exit. */ graceMs?: number } ``` @@ -398,8 +402,10 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:42`](../packages/hooks/hooks-c Requires: `agents` ```ts config-catalog -/** Runtime-only test seams; no field is configurable from `cordis.yml`. */ +/** JSON-RPC deployment config plus runtime-only test seams. */ export interface JsonRpcConfig { + /** Report max-token turn/subagent termination as a successful SDK result. */ + maxTokensAsSuccess?: boolean /** Transport input override; production uses `process.stdin`. */ input?: Readable /** Transport output override; production uses `process.stdout`. */ @@ -870,7 +876,7 @@ export interface Config { skills?: agentCore.SkillConfig /** Model-facing bash tool config forwarded through agent-core. */ toolBash?: NonNullable - /** Generic background-task control-tool config forwarded through agent-core. */ + /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ toolTasks?: NonNullable /** * If set, the pre-created agent RESUMES this persisted session id instead of @@ -1531,4 +1537,5 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-scripts` ([`packages/sdk/scripts/src/index.ts`](../packages/sdk/scripts/src/index.ts)) - `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts)) - `@deepseek-ai/dsh-subagent-subprocess` ([`packages/subagent/subagent-subprocess/src/index.ts`](../packages/subagent/subagent-subprocess/src/index.ts)) +- `@deepseek-ai/dsh-telemetry` ([`packages/sdk/telemetry/src/index.ts`](../packages/sdk/telemetry/src/index.ts)) - `@deepseek-ai/dsh-timeout` ([`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts)) diff --git a/docs/module-graph.md b/docs/module-graph.md index ffdcc066af..7222397cd9 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -146,6 +146,7 @@ flowchart TD subgraph group_sdk["packages/sdk"] pkg_helper["helper"] pkg_scripts["scripts"] + pkg_telemetry["telemetry"] end subgraph group_tasks["packages/tasks"] pkg_tasks["tasks"] @@ -160,6 +161,7 @@ flowchart TD pkg_code_runtime_worker --> pkg_code_runtime pkg_helper --> pkg_brand pkg_scripts --> pkg_app_boot + pkg_telemetry --> pkg_brand pkg_llm_deepseek --> pkg_llm pkg_llm_pi_ai --> pkg_llm pkg_session --> pkg_brand @@ -490,6 +492,7 @@ flowchart TD | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot) | +| [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`llm`](../packages/llm/llm) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`llm`](../packages/llm/llm) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 470a763687..f7019bf60e 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -16,6 +16,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Stream workflow progress through tool calls](proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.md) | 2026-07-13 | | [Developer-owned SDK projects](proposed/feature/2026-07-14-sdk-developer-projects.md) | 2026-07-14 | | [harness-level goal-based loop](proposed/feature/2026-07-16-harness-level-loop.md) | 2026-07-16 | +| [SDK follow-up capabilities](proposed/feature/2026-07-17-sdk-follow-up-capabilities.md) | 2026-07-17 | ### Simplification diff --git a/docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml b/docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml new file mode 100644 index 0000000000..933cff9cb5 --- /dev/null +++ b/docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-17-sdk-follow-up-capabilities.md: dac859f95e4ee4c628c50be72b3a18720f2b19fb +2026-07-17-sdk-follow-up-capabilities.zh.md: 55648a368b4f9aa865129f3e0501e417d15f09ed diff --git a/docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md b/docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md new file mode 100644 index 0000000000..dac859f95e --- /dev/null +++ b/docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md @@ -0,0 +1,118 @@ +# RFC: SDK follow-up capabilities + +Status: proposed + +English | [中文](2026-07-17-sdk-follow-up-capabilities.zh.md) + +## Problem + +The first SDK release creates and edits developer-owned Cordis projects through the shared model defined by the [developer-project RFC](2026-07-14-sdk-developer-projects.md) and the [project-editing architecture](../architecture/2026-07-15-sdk-project-editing-architecture.md). Its create and config workflows are interactive, external Cordis plugins require manual dependency and configuration edits, command-line telemetry has no owning boundary, and interactive branches lack a stable test strategy. + +These gaps are coupled. Create and config already share questions, feature configuration, and `ProjectEditSession`; adding separate automation paths would duplicate that domain logic. External-plugin installation must update both the package manager's files and `cordis.yml`. Telemetry must observe commands such as create and build that do not boot Cordis. Interactive testing must exercise Harness behavior without making terminal rendering a brittle product contract. + +## Proposal + +The SDK extends the existing prompt and project-editing boundaries instead of creating parallel workflows. A non-interactive prompt port and structured feature plan drive create and config, `dsh-sdk create ` delegates dependency resolution to the project package manager before mounting the resolved package through `ProjectEditSession`, launcher-side telemetry wraps `create-sdk` and every `dsh-sdk` command, and injected prompt streams provide the primary interactive-test seam. + +| Capability | Product entrypoint | Owning mechanism | Required outcome | +|---|---|---|---| +| Headless project creation | `create-sdk --config ` or `--config-json ` with optional `--json` | `HeadlessPromptPort`, structured project answers, and a complete feature plan | No terminal blocking; missing required input is explicit | +| External Cordis plugin installation | `dsh-sdk create ` | Native package-manager `add` plus `ProjectEditSession` | The dependency and `cordis.yml` entry identify the package manager's resolved package | +| Developer-cycle telemetry | `create-sdk` and every `dsh-sdk` command | Launcher-side consent, payload, redaction, anonymous identity, and delivery services | Reporting is best-effort and cannot change the command result | +| Interactive regression coverage | Create and config tests | Injected `PromptPort` input/output and filesystem assertions | Tests cover Harness decisions and generated files without snapshotting terminal repainting | + +## Shared headless workflow + +### Structured input and lifecycle events + +Headless create accepts a JSON object either inline through `--config-json` or from a file through `--config`. Scalar fields supply the ordinary create answers, while `features` supplies the complete selected feature set, feature options, secrets, and dedicated values. Defaults remain valid only where the owning question declares one; the headless path never invents an answer for a required prompt. + +With `--json`, stdout is an NDJSON event stream. `done` means creation and any requested setup completed, `action-required` names an unanswered required prompt, and `error` reports another failure. Human-readable progress and package-manager output go to stderr so every stdout line remains parseable as one event. A caller responds to `action-required` by adding the missing value and running the command again. + +Create and config consume the same feature-plan shape. Create exposes it through the command-line inputs above; config uses it at the shared workflow boundary so a later automation entrypoint does not need a second feature-selection model. + +### Prompt and project-editing boundaries + +`PromptPort` remains the only boundary between SDK questions and an interaction implementation. `ClackPromptPort` handles terminals. `HeadlessPromptPort` consumes defaults exposed by the question contract and otherwise fails with the unanswered prompt; prefilled values normally prevent the port from being called. + +Both paths use the same `Question` objects, `FeatureConfigurator`, `SdkProject`, and `ProjectEditSession`. The headless path therefore changes how answers arrive, not how features are interpreted or files are committed. + +### Agent skill + +The repository ships a thin `SKILL.md` that teaches an agent to construct the structured input, request NDJSON, fill an `action-required` value, and retry. The skill invokes the public CLI and does not import an internal SDK API or introduce another project specification. + +## External Cordis plugin installation + +`dsh-sdk create ` accepts a package-manager-native npm specifier such as `pkg@version` or a GitHub specifier such as `github:owner/repo#ref`. After confirmation, it asks the project's package manager to add the source, compares the direct dependency names before and after the operation, reopens the project, and mounts each newly resolved package in `cordis.yml` through `ProjectEditSession`. + +The package manager owns source parsing, version or commit resolution, integrity data, lockfile updates, and any build policy. The SDK does not download or unpack a second copy through giget or pacote. An external plugin remains a dependency under `node_modules`; local plugin scaffolding remains a separate project-creation concern. + +## Launcher telemetry + +### Consent and collection + +Telemetry wraps the `create-sdk` initializer and the `dsh-sdk` launcher command lifecycle because project initialization, plugin creation, and build do not reliably boot Cordis. One event records the command name, duration, success, a random per-user anonymous identifier, and redacted `cordis.yml` and `package.json` text when those project files are eligible. + +Reporting is enabled unless a present telemetry config entry is explicitly disabled. `DO_NOT_TRACK` and CI deny reporting regardless of project configuration. A missing `cordis.yml` does not itself deny the event, but `package.json` content is included only when `cordis.yml` establishes that the directory is an SDK project. + +### Safety and delivery + +The payload builder never reads `.env`. It redacts secret-shaped keys and values, known token forms, PEM blocks, URL credentials, and high-entropy opaque strings in the two eligible text files. Redaction is a safety backstop rather than a guarantee; SDK projects must keep credentials in `.env`. + +The reporter uses a fixed endpoint and resolves every send path without throwing. Command dispatch records success or failure in a `finally` path, starts reporting after the command outcome is known, and drains within a bounded interval. Consent parsing, payload construction, storage, or network failures are swallowed only at this telemetry boundary and never alter the command's exit code. + +## Interactive workflow testing + +Create and config tests inject a `PromptPort` and scripted input/output streams into the existing workflows. Parameterized scenarios cover feature selection, feature options, secrets, cancellation, review, and apply behavior, then assert the resulting `cordis.yml` and other project files. The stable product assertion is the generated project state, not clack's ANSI redraw sequence. + +One or two optional real-PTY smoke tests may cover the shipped binary and TTY guard that injection cannot reproduce. Native PTY tooling does not belong on the required path unless it is reliable across the repository's supported Node and host versions. + +## Deferred work + +- Extend the headless create specification to express local `plugin` or `tool` scaffolding instead of defaulting that interactive choice to none. +- Expose the telemetry opt-out in create and config while preserving the consent representation in which only a disabled telemetry entry is written. +- Define whether GitHub source dependencies must be prebuilt or may run package-manager-controlled preparation scripts, and surface the policy before installation. +- Replace the telemetry package's `.invalid` endpoint placeholder with the production endpoint before release. + +## Alternatives considered + +**Build a separate headless creation engine.** This would duplicate questions, feature requirements, configuration behavior, and project-editing rules. Reusing the prompt and edit-session boundaries keeps one implementation of project semantics. + +**Make a specification file the primary automation interface.** Agents can pass the same typed JSON object inline, while people and CI may still use a file. A file-only protocol adds persistence and cleanup without adding semantics. + +**Use `npx skills add` as the project creator.** The skills CLI installs Markdown skills; it does not create SDK projects or install npm packages. The agent skill therefore drives the SDK initializer instead of replacing it. + +**Fetch GitHub and npm sources through giget or pacote.** A second fetch layer would duplicate package-manager resolution, integrity, lockfile, and lifecycle policy. Native dependency specifiers keep those decisions in the selected package manager. + +**Implement telemetry as a Cordis runtime plugin.** Create and build do not necessarily boot Cordis, so a runtime plugin cannot observe the complete developer command cycle. The launcher is the boundary shared by those commands. + +**Derive the anonymous identifier from git metadata.** Repository remotes can identify a project or organization. A random per-user identifier supports aggregation without encoding repository identity. + +**Collect only aggregate counters.** Aggregate-only events reduce exposure but cannot answer which plugins, dependencies, and configuration shapes developers actually use. This proposal accepts collection of redacted project text and makes that exposure explicit. + +**Use real PTYs and transcript snapshots as the primary test strategy.** Native PTY dependencies and terminal repaint sequences add platform and rendering instability while mostly testing clack. Injected interaction plus generated-file assertions tests the SDK-owned behavior directly. + +## Acceptance criteria + +- Create runs without a TTY from a complete structured input, emits only NDJSON on stdout under `--json`, and reports missing required input as `action-required` without writing a partial project. +- Create and config resolve the same feature-plan contract through the shared question, feature-configuration, and project-editing code paths. +- `dsh-sdk create ` uses the selected project package manager, mounts the dependency name that operation actually added, and fails loudly when no new dependency can be identified. +- The initializer and every `dsh-sdk` command reach one best-effort telemetry completion path; an explicit disabled entry, `DO_NOT_TRACK`, or CI prevents delivery, and telemetry failures never change the command result. +- Telemetry never reads `.env`, withholds unrelated `package.json` content when no `cordis.yml` exists, redacts both eligible text payloads, and uses an identifier unrelated to git metadata. +- Interactive tests cover create and config decisions through injected interaction and assert committed project files; any real-PTY coverage remains a narrow smoke layer. +- The agent skill documents the public structured-input and event contracts without depending on private package exports. + +## Risks + +- Full redacted `cordis.yml` and `package.json` text still reveals plugin and dependency names, URLs, paths, and configuration values to the endpoint operator, and heuristic redaction can miss a secret. +- Default-on reporting may surprise developers when no telemetry entry exists; the CLI must make the opt-out discoverable before release. +- A package-manager add can change `package.json`, the lockfile, and installed files before `ProjectEditSession` mounts the plugin, so a later mount failure can leave dependency changes that require manual recovery. +- GitHub dependencies may execute preparation or lifecycle code according to package-manager policy; an unresolved build policy is a supply-chain and reproducibility risk. +- Injected prompt tests do not prove raw-mode, signal, or repaint behavior in a real terminal; the optional smoke layer must cover only those residual contracts. + +## References + +- [Vercel Eve](https://github.com/vercel/eve) and [Vercel Labs Skills](https://github.com/vercel-labs/skills) for the distinction between a headless initializer and skill distribution. +- [npm package specifications](https://docs.npmjs.com/cli/v11/using-npm/package-spec), [pnpm add](https://pnpm.io/cli/add), and [Yarn add](https://yarnpkg.com/cli/add) for package-manager-native sources. +- [`DO_NOT_TRACK`](https://donottrack.sh/) for the environment-level opt-out convention. +- [Clack](https://github.com/bombshell-dev/clack) and [Vitest snapshots](https://vitest.dev/guide/snapshot) for injected prompts and generated-file assertions. diff --git a/docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md b/docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md new file mode 100644 index 0000000000..55648a368b --- /dev/null +++ b/docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md @@ -0,0 +1,118 @@ +# RFC: SDK 后续功能 + +Status: proposed + +[English](2026-07-17-sdk-follow-up-capabilities.md) | 中文 + +## 问题 + +首个 SDK 版本通过[开发者工程 RFC](2026-07-14-sdk-developer-projects.md) 和 [SDK 工程编辑架构](../architecture/2026-07-15-sdk-project-editing-architecture.md)定义的共享模型创建和编辑开发者拥有的 Cordis 工程。create 和 config 工作流仅支持交互调用,接入外部 Cordis 插件需要手工修改依赖和配置,命令行遥测没有明确的所属边界,交互分支也缺少稳定的测试策略。 + +这些缺口彼此关联。create 和 config 已经共享问题、功能配置和 `ProjectEditSession`;若另建自动化路径,就会复制领域逻辑。安装外部插件必须同时修改包管理器文件和 `cordis.yml`。遥测需要观察 create、build 等不会启动 Cordis 的命令。交互测试需要覆盖 Harness 自身行为,同时避免把终端渲染固化成脆弱的产品契约。 + +## 提案 + +SDK 扩展现有提示词与工程编辑边界,不另建平行工作流。非交互式 `PromptPort` 实现和结构化功能计划驱动 create 与 config;`dsh-sdk create ` 先把依赖解析交给工程的包管理器,再通过 `ProjectEditSession` 挂载解析所得的包;启动器侧遥测包住 `create-sdk` 和每个 `dsh-sdk` 命令;交互测试主要通过注入的提示词输入输出流完成。 + +| 功能 | 产品入口 | 所属机制 | 必须达到的结果 | +|---|---|---|---| +| Headless 工程创建 | `create-sdk --config ` 或 `--config-json `,可搭配 `--json` | `HeadlessPromptPort`、结构化工程答案和完整功能计划 | 不阻塞等待终端;明确报告缺失的必答输入 | +| 外部 Cordis 插件安装 | `dsh-sdk create ` | 包管理器原生 `add` 加 `ProjectEditSession` | 依赖和 `cordis.yml` 配置项指向包管理器解析出的包 | +| 开发周期遥测 | `create-sdk` 和每个 `dsh-sdk` 命令 | 启动器侧的上报条件判断、遥测内容构建、脱敏、匿名身份和传输服务 | 上报采用尽力而为语义,不能改变命令结果 | +| 交互回归覆盖 | create 和 config 测试 | 注入的 `PromptPort` 输入输出和文件系统断言 | 测试覆盖 Harness 决策与生成文件,不快照终端重绘 | + +## 共享 headless 工作流 + +### 结构化输入和生命周期事件 + +Headless create 通过 `--config-json` 接收内联 JSON 对象,或通过 `--config` 从文件读取。标量字段提供普通 create 答案,`features` 提供完整的已选功能、功能选项、secret(密钥)和专用值。只有所属问题明确声明的默认值才有效;headless 路径绝不为必答问题臆造答案。 + +使用 `--json` 时,stdout 是 NDJSON 事件流。`done` 表示创建及要求执行的安装和构建均已完成,`action-required` 指明一个尚未回答的必答问题,`error` 报告其他失败。面向人的进度信息和包管理器输出写入 stderr,确保 stdout 每一行都能解析成一个事件。调用方收到 `action-required` 后补充缺失值,再次运行命令。 + +Create 和 config 使用相同的功能计划形状。create 通过上述命令行输入公开该形状;config 在共享工作流边界使用同一形状,使后续自动化入口无需另建功能选择模型。 + +### Prompt 与工程编辑边界 + +`PromptPort` 仍是 SDK 问题与交互实现之间的唯一边界。`ClackPromptPort` 负责终端交互。`HeadlessPromptPort` 使用问题契约公开的默认值,否则通过未回答问题快速失败;预填值通常会让流程根本不调用该 port。 + +两条路径使用相同的 `Question` 对象、`FeatureConfigurator`、`SdkProject` 和 `ProjectEditSession`。因此,headless 路径只改变答案的到达方式,不改变功能解释或文件提交方式。 + +### Agent skill + +仓库提供一份轻量 `SKILL.md`,指导 agent skill(智能体技能)构造结构化输入、请求 NDJSON、补充 `action-required` 指明的值并重试。该 skill 调用公开 CLI,不导入 SDK 内部 API,也不引入另一套工程规格。 + +## 外部 Cordis 插件安装 + +`dsh-sdk create ` 接受包管理器原生的 npm package specifier,例如 `pkg@version`,也接受 `github:owner/repo#ref` 等 GitHub package specifier。用户确认后,命令要求工程包管理器添加来源,对比操作前后的直接依赖名,重新打开工程,再通过 `ProjectEditSession` 把每个新增且已解析的包挂载进 `cordis.yml`。 + +包管理器负责来源解析、版本或 commit 解析、`integrity` 数据、lockfile 更新和构建策略。SDK 不再通过 giget 或 pacote 下载、解压第二份副本。外部插件是 `node_modules` 下的依赖;本地插件脚手架仍属于独立的工程创建问题。 + +## Launcher 遥测 + +### Consent 与采集 + +遥测包住 `create-sdk` 初始化命令与 `dsh-sdk` launcher 的命令生命周期,因为工程初始化、插件创建和 build 都不会稳定地启动 Cordis。每个事件记录命令名、时长、成败、随机生成的用户级匿名标识符,以及符合条件时经过脱敏的 `cordis.yml` 与 `package.json` 文本。 + +除非当前存在的遥测配置项被明确禁用,否则允许上报。`DO_NOT_TRACK` 和 CI 无论工程配置如何都禁止上报。缺少 `cordis.yml` 本身不会禁止事件,但只有 `cordis.yml` 能证明目录是 SDK 工程时,遥测内容才包含 `package.json` 文本。 + +### 安全与传输 + +Payload 构建器绝不读取 `.env`。它会脱敏两个符合条件的文本文件中的疑似密钥键和值、已知 token 形式、PEM 块、URL 凭据和高熵不透明字符串。脱敏只是安全兜底,不能提供绝对保证;SDK 工程必须把凭据放进 `.env`。 + +`TelemetryReporter` 使用固定 endpoint,每条发送路径都会正常结束且不抛错。命令分发通过 `finally` 路径记录成败,在命令结果已确定后启动上报,并在有界时间内等待传输结束。只有遥测边界会吞掉上报条件解析、遥测内容构建、存储或网络错误,这些错误绝不改变命令退出码。 + +## 交互工作流测试 + +Create 和 config 测试向现有工作流注入 `PromptPort` 和脚本化输入输出流。参数化场景覆盖功能选择、功能选项、secret、取消、评审和应用行为,再断言最终的 `cordis.yml` 及其他工程文件。稳定的产品断言是生成后的工程状态,不是 clack 的 ANSI 重绘序列。 + +可以用一到两个可选的真实 PTY 冒烟测试覆盖注入无法复现的发布二进制和 TTY 检查。除非原生 PTY 工具在仓库支持的 Node 与宿主版本上足够可靠,否则它不进入必跑路径。 + +## 延后工作 + +- 扩展 headless create 规格,使其能表达本地 `plugin` 或 `tool` 脚手架,而不是把该交互选择默认为 none。 +- 在 create 和 config 中公开遥测关闭选项,同时保留只有禁用时才写入遥测配置项的上报许可表示。 +- 明确 GitHub 来源依赖必须预先构建,还是允许运行由包管理器控制的 preparation script(准备脚本),并在安装前向用户展示该策略。 +- 发布前把遥测包中的 `.invalid` endpoint 占位符替换为生产端点。 + +## 曾考虑的替代方案 + +**另建 headless 创建引擎。** 该方案会复制问题、功能依赖、配置行为和工程编辑规则。复用提示词与编辑会话边界,可以保证工程语义只有一份实现。 + +**把规格文件作为主要自动化接口。** Agent 可以内联传入相同的类型化 JSON 对象,人和 CI 仍可选用文件。文件专用协议会增加持久化与清理工作,却不增加语义。 + +**使用 `npx skills add` 创建工程。** Skills CLI 只安装 Markdown skill,不创建 SDK 工程,也不安装 npm 包。因此,agent skill 驱动 SDK 初始化命令,而不是取代它。 + +**通过 giget 或 pacote 获取 GitHub 与 npm 来源。** 第二套获取层会复制包管理器的解析、完整性、lockfile 和生命周期策略。原生 package specifier 让这些决策留在所选包管理器中。 + +**把遥测实现成 Cordis 运行时插件。** Create 和 build 不一定启动 Cordis,因此运行时插件无法观察完整的开发命令周期。Launcher 是这些命令共用的边界。 + +**从 git 元数据派生匿名标识符。** 仓库的 git remote 可能识别工程或组织。随机的用户级标识符能够支持聚合,同时不编码仓库身份。 + +**只采集聚合计数。** 仅聚合事件可以降低暴露,但无法回答开发者实际使用哪些插件、依赖和配置形状。本提案接受采集脱敏后的工程文本,并明确记录这项暴露。 + +**把真实 PTY 和 transcript(文本记录)快照作为主要测试策略。** 原生 PTY 依赖与终端重绘序列会带来平台和渲染不稳定性,而且主要是在测试 clack。注入交互并断言生成文件,可以直接测试 SDK 拥有的行为。 + +## 验收标准 + +- Create 能依据完整结构化输入在没有 TTY 时运行;使用 `--json` 时 stdout 只输出 NDJSON;缺少必答输入时通过 `action-required` 报告,且不写入部分工程。 +- Create 和 config 通过共享的问题、功能配置和工程编辑代码路径解析相同的功能计划契约。 +- `dsh-sdk create ` 使用工程选定的包管理器,挂载该操作实际新增的依赖名;无法识别新增依赖时快速失败。 +- 初始化命令与每个 `dsh-sdk` 命令都进入同一条尽力而为的遥测收尾路径;明确禁用的配置项、`DO_NOT_TRACK` 或 CI 会阻止传输,遥测失败绝不改变命令结果。 +- 遥测绝不读取 `.env`;没有 `cordis.yml` 时不发送无关的 `package.json` 内容;两个符合条件的文本都经过脱敏;匿名标识符与 git 元数据无关。 +- 交互测试通过注入交互覆盖 create 和 config 决策,并断言已提交的工程文件;真实 PTY 覆盖只作为窄范围冒烟层。 +- Agent skill 说明公开的结构化输入与事件契约,不依赖包的私有导出。 + +## 风险 + +- 即使经过脱敏,完整的 `cordis.yml` 与 `package.json` 文本仍会向 endpoint 运营方暴露插件名、依赖名、URL、路径和配置值;启发式脱敏也可能漏掉 secret。 +- 没有遥测配置项时默认上报可能让开发者意外;发布前 CLI 必须让关闭方法易于发现。 +- 在 `ProjectEditSession` 挂载插件前,包管理器的 add 操作已经可能修改 `package.json`、lockfile 和安装文件;后续挂载失败会留下需要手工恢复的依赖改动。 +- GitHub 依赖可能按包管理器策略执行 preparation 或 lifecycle script;尚未解决的构建策略会带来供应链与可复现性风险。 +- 注入提示词交互的测试无法证明真实终端中的 raw mode、signal 或重绘行为;可选冒烟层只应覆盖这些残余契约。 + +## 参考资料 + +- [Vercel Eve](https://github.com/vercel/eve) 与 [Vercel Labs Skills](https://github.com/vercel-labs/skills) 用于区分 headless 初始化命令与 skill 分发。 +- [npm package specifications](https://docs.npmjs.com/cli/v11/using-npm/package-spec)、[pnpm add](https://pnpm.io/cli/add)和 [Yarn add](https://yarnpkg.com/cli/add)说明包管理器原生来源。 +- [`DO_NOT_TRACK`](https://donottrack.sh/)定义环境级关闭约定。 +- [Clack](https://github.com/bombshell-dev/clack) 和 [Vitest snapshots](https://vitest.dev/guide/snapshot) 说明注入提示词交互与生成文件断言。 diff --git a/examples/README.md b/examples/README.md index abeee1d97e..c06bb791df 100644 --- a/examples/README.md +++ b/examples/README.md @@ -33,6 +33,10 @@ The full-screen terminal sibling of `repl-agent`: it reuses the same coding back Run with: `pnpm run demo:tui` (needs `DEEPSEEK_API_KEY`). See [tui-agent/README.md](tui-agent/README.md) for controls and composition. +## jsonrpc-agent + +An unattended coding agent driven through the Python SDK: JSON-RPC stdio, foreground-only `bash`, `read` / `write` / `edit`, one foreground `subagent`, `todo_write`, JSONL persistence, and compaction. It excludes terminal UI, stdout logging, approvals, skills, and background task controls. See [jsonrpc-agent/README.md](jsonrpc-agent/README.md). + ## cordis-agent The **self-referential** demo: the coding spine plus [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis), whose three tools (`cordis_inspect` / `cordis_mount` / `cordis_unmount`) let the agent inspect the live cordis runtime it runs inside, mount model-written plugins into it (an event listener, a brand-new tool for itself, or a service another mount injects), and dispose them again — all dynamic mounts grouped under one `cordis-dynamic` fiber subtree. The `ctx.fs`/`ctx.web` services ride along provider-only, as the capabilities those plugins build on. diff --git a/examples/jsonrpc-agent/README.md b/examples/jsonrpc-agent/README.md new file mode 100644 index 0000000000..cfbf6787b1 --- /dev/null +++ b/examples/jsonrpc-agent/README.md @@ -0,0 +1,24 @@ +# jsonrpc-agent + +The unattended coding-agent composition for the Python SDK's bundled JSON-RPC runtime. It intentionally loads no terminal UI, console logger, approval surface, or user-interaction tool because stdout belongs to the SDK protocol and turns are driven by the SDK. + +The model-facing tools are: + +- `bash`, foreground only +- `read`, `write`, and `edit` +- `subagent`, using one foreground in-process spawn provider +- `todo_write` + +The surrounding runtime also loads JSONL session persistence and automatic context compaction. `maxTokensAsSuccess` keeps a token-limited model turn as an accepted evaluation result while preserving its `max-tokens` reason. + +## Runtime environment + +| Variable | Purpose | +|---|---| +| `DEEPSEEK_API_KEY` | Credential passed to the OpenAI-compatible host endpoint | +| `DEEPSEEK_BASE_URL` | Host endpoint used by `dsh-llm-deepseek` | +| `DSH_CWD` | Agent workspace for bash and filesystem tools | +| `DSH_SESSION_ROOT` | JSONL trajectory directory | +| `DSH_SYSTEM_PROMPT` | Deployment-provided coding persona | + +Pass the config path through the Python SDK's `cordis` option or `DSH_CORDIS_CONFIG`. The bundled executable already carries every plugin named by this file; the target machine does not need Node.js. diff --git a/examples/jsonrpc-agent/cordis.yml b/examples/jsonrpc-agent/cordis.yml new file mode 100644 index 0000000000..de19b3da94 --- /dev/null +++ b/examples/jsonrpc-agent/cordis.yml @@ -0,0 +1,74 @@ +# Unattended coding-agent deployment for the bundled dsh-jsonrpc-agent runtime. +# stdout is reserved for JSON-RPC; do not add a console logger or terminal UI. + +- id: jsonrpc + name: '@deepseek-ai/dsh-jsonrpc' + config: + maxTokensAsSuccess: true + +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + config: + cwd: !!js process.env.DSH_CWD ?? process.cwd() + timeoutMs: 60000 + +- id: agent-spine + name: '@deepseek-ai/dsh-agent-spine-demo' + config: + persona: !!js process.env.DSH_SYSTEM_PROMPT ?? 'You are a coding agent.' + workspaceContext: false + skills: + enabled: false + toolBash: + enableRunInBackground: false + toolTasks: false + +- id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions' + +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subagent-spawn + name: '@deepseek-ai/dsh-subagent-spawn' + config: + providerName: spawn + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + enableRunInBackground: false + +- id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + +- id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.env.DSH_CWD ?? process.cwd() + +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +- id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + config: + contextWindow: 128000 + thresholdRatio: 0.8 + retainTokens: 20480 + summarizationModel: '' + maxTokens: 8192 + compactionRetries: 1 diff --git a/examples/jsonrpc-agent/package.json b/examples/jsonrpc-agent/package.json new file mode 100644 index 0000000000..080b0649a6 --- /dev/null +++ b/examples/jsonrpc-agent/package.json @@ -0,0 +1,7 @@ +{ + "name": "jsonrpc-agent-example", + "private": true, + "version": "0.0.1", + "type": "module", + "description": "Unattended JSON-RPC coding-agent composition" +} diff --git a/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts b/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts new file mode 100644 index 0000000000..41afb03574 --- /dev/null +++ b/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts @@ -0,0 +1,146 @@ +import { spawn } from 'node:child_process' +import { createServer } from 'node:http' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const binScript = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url)) +const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) +const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)) + +function waitForLine( + lines: string[], + predicate: (value: Record) => boolean, + stderr: () => string, +): Promise> { + return new Promise((resolve, reject) => { + const deadline = Date.now() + 30_000 + const poll = (): void => { + while (lines.length > 0) { + const line = lines.shift()! + if (!line.trim()) continue + try { + const value = JSON.parse(line) as Record + if (predicate(value)) { + resolve(value) + return + } + } catch { + reject(new Error(`non-JSON stdout from JSON-RPC agent runtime: ${line}`)) + return + } + } + if (Date.now() >= deadline) { + reject(new Error(`timed out waiting for JSON-RPC response; stderr=${stderr()}`)) + return + } + setTimeout(poll, 10) + } + poll() + }) +} + +describe('jsonrpc-agent keyless smoke', () => { + it('boots the real Cordis tree and serves initialize/shutdown over clean stdout', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-agent-smoke-')) + const modelRequests: Record[] = [] + const modelServer = createServer((request, response) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { + modelRequests.push(JSON.parse(body) as Record) + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.write('data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n') + response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n') + response.write('data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n') + response.end('data: [DONE]\n\n') + }) + }) + await new Promise(resolve => modelServer.listen(0, '127.0.0.1', resolve)) + const address = modelServer.address() + if (address === null || typeof address === 'string') throw new Error('model server did not bind a TCP port') + const child = spawn(process.execPath, [ + '--expose-internals', + '--import', + 'tsx', + binScript, + configPath, + ], { + cwd: repoRoot, + env: { + ...process.env, + DEEPSEEK_API_KEY: 'keyless-smoke-no-call', + DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, + DSH_CWD: root, + DSH_SESSION_ROOT: join(root, '.sessions'), + }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + const lines: string[] = [] + let stdoutBuffer = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stdout.on('data', (chunk: string) => { + stdoutBuffer += chunk + const parts = stdoutBuffer.split('\n') + stdoutBuffer = parts.pop() ?? '' + lines.push(...parts) + }) + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => { stderr += chunk }) + + try { + child.stdin.write(`${JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { cwd: root, provider: 'deepseek', model: 'deepseek-v4-pro' }, + })}\n`) + const initialized = await waitForLine(lines, value => value.id === 1, () => stderr) + expect(initialized).toMatchObject({ + jsonrpc: '2.0', + id: 1, + result: { serverInfo: { name: 'deepseek-harness-sdk-runtime' } }, + }) + + child.stdin.write(`${JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'session/prompt', + params: { sessionId: 'main', contentBlocks: [{ type: 'text', text: 'inspect tools' }] }, + })}\n`) + const prompt = await waitForLine(lines, value => value.id === 2, () => stderr) + expect(prompt).toMatchObject({ jsonrpc: '2.0', id: 2, result: { accepted: true } }) + const tools = modelRequests[0]?.tools as { function?: { name?: string } }[] + expect(tools.map(tool => tool.function?.name).sort()).toEqual([ + 'bash', + 'edit', + 'read', + 'subagent', + 'todo_write', + 'write', + ]) + + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`) + const shutdown = await waitForLine(lines, value => value.id === 3, () => stderr) + expect(shutdown).toMatchObject({ jsonrpc: '2.0', id: 3, result: {} }) + if (child.exitCode === null) { + await new Promise((resolve, reject) => { + child.once('exit', (code) => { + if (code === 0) resolve() + else reject(new Error(`runtime exited ${code}; stderr=${stderr}`)) + }) + }) + } else { + expect(child.exitCode, stderr).toBe(0) + } + } finally { + if (child.exitCode === null) child.kill('SIGKILL') + await new Promise(resolve => modelServer.close(() => { resolve() })) + await rm(root, { recursive: true, force: true }) + } + }, 40_000) +}) diff --git a/examples/package.json b/examples/package.json index 52cebaee31..f893ab6bc2 100644 --- a/examples/package.json +++ b/examples/package.json @@ -8,6 +8,7 @@ "@cordisjs/plugin-hmr": "workspace:*", "@cordisjs/plugin-include": "workspace:*", "@deepseek-ai/dsh-acp-demo": "workspace:*", + "@deepseek-ai/dsh-agent-spine-demo": "workspace:*", "@deepseek-ai/dsh-bash-local": "workspace:*", "@deepseek-ai/dsh-bash-sandbox": "workspace:*", "@deepseek-ai/dsh-cli-demo": "workspace:*", @@ -19,12 +20,14 @@ "@deepseek-ai/dsh-goal-session": "workspace:*", "@deepseek-ai/dsh-hooks-claude": "workspace:*", "@deepseek-ai/dsh-hooks-codex": "workspace:*", + "@deepseek-ai/dsh-jsonrpc": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek": "workspace:*", "@deepseek-ai/dsh-llm-replay": "workspace:*", "@deepseek-ai/dsh-permission": "workspace:*", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*", "@deepseek-ai/dsh-sandbox-local": "workspace:*", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", "@deepseek-ai/dsh-spill-local": "workspace:*", "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-stdio-demo": "workspace:*", diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index fcd1317b75..c63f86f797 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-bash-local -Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c ` per call in its own process group, collects bounded output with full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group. +Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c ` per call in its own process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group. The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`; subprocess plumbing stays internal to the implementation package. @@ -14,7 +14,8 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i timeoutMs: 120000 # default foreground timeout maxTimeoutMs: 600000 # cap for per-call overrides maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk - graceMs: 3000 # SIGTERM→SIGKILL escalation grace on kills + maxSpillBytes: 67108864 # per-stream full-output spill cap + graceMs: 3000 # kill escalation and post-exit pipe-drain grace ``` ## Behavior (and where it came from) @@ -22,8 +23,8 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; the notable choices: - **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them. -- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools. -- **Tail-keep truncation + spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background tasks still use `maxOutputBytes`. If the final spill close reports a delayed writeback failure, the executor still returns the tail but withholds the path rather than advertising a possibly incomplete file. +- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). After the main shell exits, inherited stdout/stderr pipes receive the same bounded drain grace so a surviving descendant cannot hold the command open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools. +- **Tail-keep truncation + bounded spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background tasks still use `maxOutputBytes`. A stream larger than `maxSpillBytes` discards its now-incomplete spill and returns only the marked truncated tail. If the final spill close reports a delayed writeback failure, the executor likewise withholds the path rather than advertising an incomplete file. - **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names, then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. A spec's ordinary `env` is merged after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment RFC](../../../docs/rfc/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). - **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), the handle's `readOutput()` is incremental with whole-stream byte offsets, and disposal kills every running process and awaits its exit. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry. @@ -41,6 +42,6 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **No persistent shell or PTY** — every call starts a fresh non-login `bash -c`; cwd-only persistence and interactive terminal sessions remain deferred until a real workflow requires them. - **POSIX-only** — the `bash` binary, detached process groups, group kills, and SIGTERM→SIGKILL escalation are hardcoded; Windows is unsupported. - **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work. -- **Spill files are never deleted** — full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them. +- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind. The raw process handling lives in `src/run.ts`; `src/index.ts` is the service wiring. diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 587c33b9a2..6428cd7ac8 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -10,7 +10,7 @@ import z from 'schemastery' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash' import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' -import { DEFAULT_GRACE_MS, runBash } from './run.ts' +import { DEFAULT_GRACE_MS, DEFAULT_MAX_SPILL_BYTES, runBash } from './run.ts' import type { RunInternals, RunningBash } from './run.ts' /** Plugin config (all optional — `static Config` supplies the defaults). */ @@ -23,7 +23,9 @@ export interface Config { maxTimeoutMs?: number /** Per-stream in-memory output cap; overflow spills to a temp file. */ maxOutputBytes?: number - /** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */ + /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ + maxSpillBytes?: number + /** Grace period for kill escalation and for inherited pipes after shell exit. */ graceMs?: number } @@ -46,6 +48,7 @@ export class LocalBashExecutor extends BashExecutor { timeoutMs: z.number().default(120_000), maxTimeoutMs: z.number().default(600_000), maxOutputBytes: z.number().default(64_000), + maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES), graceMs: z.number().default(DEFAULT_GRACE_MS), }) @@ -64,6 +67,7 @@ export class LocalBashExecutor extends BashExecutor { assertPositiveFinite('timeoutMs', this.config.timeoutMs) assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs) assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes) + assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes) assertPositiveFinite('graceMs', this.config.graceMs) ctx.effect(() => async () => { // Await closure so even a TERM-trapping child cannot outlive the fiber. @@ -120,6 +124,7 @@ export class LocalBashExecutor extends BashExecutor { cwd: spec.workdir, stdoutMaxBytes: spec.stdoutMaxBytes, stderrMaxBytes: this.config.maxOutputBytes, + maxSpillBytes: this.config.maxSpillBytes, graceMs: this.config.graceMs, signal: d.signal, stdin: spec.stdin, @@ -139,6 +144,7 @@ export class LocalBashExecutor extends BashExecutor { cwd: spec.workdir, stdoutMaxBytes: this.config.maxOutputBytes, stderrMaxBytes: this.config.maxOutputBytes, + maxSpillBytes: this.config.maxSpillBytes, graceMs: this.config.graceMs, signal: spec.signal, stdin: spec.stdin, diff --git a/packages/bash/bash-local/src/run.ts b/packages/bash/bash-local/src/run.ts index fa4dae73d6..600e920c96 100644 --- a/packages/bash/bash-local/src/run.ts +++ b/packages/bash/bash-local/src/run.ts @@ -8,7 +8,7 @@ import { type ChildProcessByStdio, spawn } from 'node:child_process' import type { Readable, Writable } from 'node:stream' import { randomBytes } from 'node:crypto' -import { closeSync, mkdtempSync, openSync, writeSync } from 'node:fs' +import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash' @@ -72,7 +72,9 @@ export interface SpawnSpec { stdoutMaxBytes: number /** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */ stderrMaxBytes: number - /** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */ + /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ + maxSpillBytes: number + /** Grace period for kill escalation and for inherited pipes after shell exit. */ graceMs: number /** * Abort signal — kills the process group when it fires. The executor owns @@ -119,6 +121,9 @@ export interface RunInternals { /** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */ export const DEFAULT_GRACE_MS = 3_000 +/** Default per-stream spill cap (the `maxSpillBytes` config). */ +export const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024 + let spillCounter = 0 let defaultSpillDir: string | undefined @@ -133,9 +138,9 @@ function privateSpillDir(): string { } /** - * Collects one stream with a bounded in-memory tail. The FULL stream is - * always recoverable: on first overflow a spill file is created and every - * chunk (including those already collected) is appended there. + * Collects one stream with a bounded in-memory tail. On first overflow a + * spill file is created and every chunk (including those already collected) + * is appended there while the full stream remains within `maxSpillBytes`. * * Tail-keep rationale (pi/OpenCode): errors and final results cluster at the * end of command output; the spill file covers the head. @@ -146,11 +151,13 @@ export class OutputCollector { private dropped = false private spillFd: number | undefined private spillFile: string | undefined + private spillDisabled = false /** Total bytes ever pushed (not just retained). */ private total = 0 constructor( private readonly maxBytes: number, + private readonly maxSpillBytes: number, private readonly label: string, private readonly spillDir: string, ) {} @@ -166,7 +173,7 @@ export class OutputCollector { push(chunk: Buffer): void { this.total += chunk.length const overflows = this.bytes + chunk.length > this.maxBytes - if (overflows || this.spillFd !== undefined) this.spillAll(chunk) + if (!this.spillDisabled && (overflows || this.spillFd !== undefined)) this.spillAll(chunk) this.chunks.push(chunk) this.bytes += chunk.length while (this.bytes > this.maxBytes && this.chunks.length > 1) { @@ -188,6 +195,10 @@ export class OutputCollector { /** Open the spill file lazily and append `chunk` (and any prior chunks once). */ private spillAll(chunk: Buffer): void { + if (this.total > this.maxSpillBytes) { + this.discardSpill() + return + } if (this.spillFd === undefined) { // Random suffix + O_EXCL + no-follow-equivalent ('wx' fails on any // existing path, symlink or not) + owner-only mode: defeats spill-path @@ -202,6 +213,30 @@ export class OutputCollector { writeSync(this.spillFd, chunk) } + /** Stop spilling and remove the file once it can no longer hold the complete stream. */ + private discardSpill(): void { + const fd = this.spillFd + const file = this.spillFile + this.spillFd = undefined + this.spillFile = undefined + this.spillDisabled = true + if (fd !== undefined) { + try { + closeSync(fd) + } catch { + // Retain the descriptor so finalize can retry the failed close. + this.spillFd = fd + } + } + if (file !== undefined) { + try { + unlinkSync(file) + } catch { + // A failed unlink leaves at most maxSpillBytes behind, never an unbounded file. + } + } + } + /** * Incremental read in whole-stream byte coordinates: returns everything * pushed since `fromByte`. When `fromByte` has already slid out of the @@ -301,8 +336,8 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB ? spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true }) : spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true }) - const stdout = new OutputCollector(spec.stdoutMaxBytes, 'stdout', spillDir) - const stderr = new OutputCollector(spec.stderrMaxBytes, 'stderr', spillDir) + const stdout = new OutputCollector(spec.stdoutMaxBytes, spec.maxSpillBytes, 'stdout', spillDir) + const stderr = new OutputCollector(spec.stderrMaxBytes, spec.maxSpillBytes, 'stderr', spillDir) child.stdout.on('data', (chunk: Buffer) => { stdout.push(chunk) }) child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk) }) @@ -328,12 +363,13 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB } const done = new Promise((resolve, reject) => { - child.on('error', (error) => { - // No meaningful close outcome follows a spawn failure. - cleanup() - reject(error) - }) - child.on('close', (exitCode, signal) => { + let settled = false + let pipeDrainTimer: NodeJS.Timeout | undefined + const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => { + if (settled) return + settled = true + child.stdout.destroy() + child.stderr.destroy() cleanup() resolve({ exitCode, @@ -341,9 +377,20 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB stdout: stdout.finalize(), stderr: stderr.finalize(), }) + } + child.on('error', (error) => { + // No meaningful close outcome follows a spawn failure. + settled = true + cleanup() + reject(error) }) + child.on('exit', (exitCode, signal) => { + pipeDrainTimer = setTimeout(() => { settle(exitCode, signal) }, spec.graceMs) + }) + child.on('close', settle) function cleanup(): void { if (graceTimer !== undefined) clearTimeout(graceTimer) + if (pipeDrainTimer !== undefined) clearTimeout(pipeDrainTimer) spec.signal?.removeEventListener('abort', onAbort) } }) diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts index 9db0c2eebd..2e24addb3b 100644 --- a/packages/bash/bash-local/tests/executor.spec.ts +++ b/packages/bash/bash-local/tests/executor.spec.ts @@ -66,6 +66,7 @@ describe('LocalBashExecutor.run', () => { await expect(setup({ timeoutMs: Number.NaN })).rejects.toThrow(/timeoutMs/) await expect(setup({ maxTimeoutMs: 0 })).rejects.toThrow(/maxTimeoutMs/) await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/) + await expect(setup({ maxSpillBytes: 0 })).rejects.toThrow(/maxSpillBytes/) await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/) const { bash } = await setup() diff --git a/packages/bash/bash-local/tests/run.spec.ts b/packages/bash/bash-local/tests/run.spec.ts index e65500a4b5..91afd1aede 100644 --- a/packages/bash/bash-local/tests/run.spec.ts +++ b/packages/bash/bash-local/tests/run.spec.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, statSync } from 'node:fs' +import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { describe, expect, it, vi } from 'vitest' @@ -6,7 +6,10 @@ import type { DshEnvironment } from '@deepseek-ai/dsh-bash' import { killGroup, OutputCollector, runBash } from '../src/run.ts' import type { RunningBash } from '../src/run.ts' -const { failNextClose } = vi.hoisted(() => ({ failNextClose: { value: false } })) +const { failNextClose, failNextUnlink } = vi.hoisted(() => ({ + failNextClose: { value: false }, + failNextUnlink: { value: false }, +})) vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal() return { @@ -18,6 +21,13 @@ vi.mock('node:fs', async (importOriginal) => { } actual.closeSync(fd) }, + unlinkSync(path: Parameters[0]): void { + if (failNextUnlink.value) { + failNextUnlink.value = false + throw Object.assign(new Error('simulated EIO on unlink'), { code: 'EIO' }) + } + actual.unlinkSync(path) + }, } }) @@ -29,6 +39,7 @@ function spec(command: string, overrides: Partial[0]> cwd: process.cwd(), stdoutMaxBytes: 64_000, stderrMaxBytes: 64_000, + maxSpillBytes: 64 * 1024 * 1024, graceMs: 3_000, ...overrides, } @@ -173,6 +184,22 @@ describe('runBash', () => { const result = await running.done expect(result.signal).toBe('SIGTERM') }) + + it('bounds inherited-pipe draining after the shell exits', async () => { + const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`) + const started = Date.now() + const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 })) + const descendant = await waitForPidFile(pidFile) + try { + const result = await running.done + expect(Date.now() - started).toBeLessThan(1_000) + expect(result.exitCode).toBe(0) + expect(result.stdout.text).toBe('shell-done\n') + } finally { + process.kill(descendant, 'SIGKILL') + await waitGone(descendant) + } + }) }) describe('stdin and extra env (set by in-process plugins)', () => { @@ -282,7 +309,7 @@ describe('output truncation and spill', () => { describe('OutputCollector', () => { it('keeps the tail of a single oversized chunk', () => { - const collector = new OutputCollector(10, 'test', spillDir) + const collector = new OutputCollector(10, 100, 'test', spillDir) collector.push(Buffer.from('0123456789abcdef')) const out = collector.finalize() expect(out.text).toBe('6789abcdef') @@ -291,7 +318,7 @@ describe('OutputCollector', () => { }) it('readFrom returns increments and flags lossy reads', () => { - const collector = new OutputCollector(10, 'test', spillDir) + const collector = new OutputCollector(10, 100, 'test', spillDir) collector.push(Buffer.from('aaaaa')) const first = collector.readFrom(0) expect(first.text).toBe('aaaaa') @@ -312,7 +339,7 @@ describe('OutputCollector', () => { }) it('contains close failures and drops the spill path', () => { - const collector = new OutputCollector(4, 'closefail', spillDir) + const collector = new OutputCollector(4, 100, 'closefail', spillDir) collector.push(Buffer.from('aaaa')) collector.push(Buffer.from('bbbb')) expect(collector.readFrom(0).spillPath).toBeDefined() @@ -326,6 +353,46 @@ describe('OutputCollector', () => { expect(out!.truncated).toBe(true) expect(out!.spillPath).toBeUndefined() }) + + it('discards a spill that exceeds its configured cap', () => { + const collector = new OutputCollector(4, 8, 'bounded', spillDir) + collector.push(Buffer.from('aaaa')) + collector.push(Buffer.from('bbbb')) + const spillPath = collector.readFrom(0).spillPath! + expect(readFileSync(spillPath, 'utf8')).toBe('aaaabbbb') + + collector.push(Buffer.from('c')) + collector.push(Buffer.from('dddd')) + const out = collector.finalize() + expect(out.text).toBe('dddd') + expect(out.truncated).toBe(true) + expect(out.spillPath).toBeUndefined() + expect(() => readFileSync(spillPath)).toThrow() + }) + + it('does not create a spill when the first overflowing chunk exceeds the cap', () => { + const collector = new OutputCollector(4, 4, 'no-spill', spillDir) + collector.push(Buffer.from('abcdefgh')) + const out = collector.finalize() + expect(out.text).toBe('efgh') + expect(out.truncated).toBe(true) + expect(out.spillPath).toBeUndefined() + }) + + it('contains cleanup failures while disabling an oversize spill', () => { + const collector = new OutputCollector(4, 8, 'cleanup-fail', spillDir) + collector.push(Buffer.from('aaaa')) + collector.push(Buffer.from('bbbb')) + const spillPath = collector.readFrom(0).spillPath! + + failNextClose.value = true + failNextUnlink.value = true + expect(() => { collector.push(Buffer.from('c')) }).not.toThrow() + expect(failNextClose.value).toBe(false) + expect(failNextUnlink.value).toBe(false) + expect(collector.finalize().spillPath).toBeUndefined() + unlinkSync(spillPath) + }) }) describe('killGroup', () => { diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index e3eac668a4..4000954c72 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -54,7 +54,7 @@ export interface Config { skills?: agentCore.SkillConfig /** Model-facing bash tool config forwarded through agent-core. */ toolBash?: NonNullable - /** Generic background-task control-tool config forwarded through agent-core. */ + /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ toolTasks?: NonNullable } @@ -76,7 +76,7 @@ export const Config: z = z.object({ workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), skills: agentCore.SkillConfigSchema, toolBash: agentCore.ToolBashConfigSchema, - toolTasks: agentCore.ToolTasksConfigSchema, + toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), }) /* jscpd:ignore-end */ diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index f7a0f28cea..76efd77845 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -46,7 +46,7 @@ import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — a stdio app pre-creates `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer, while `toolTasks` controls generic `task_output` wait bounds; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. +The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — a stdio app pre-creates `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. ## Why a code bundle, not a shared YAML include @@ -62,5 +62,5 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work -- **The spine set is fixed in code** — `apply()` mounts every child unconditionally (including `tool-bash`); no config excludes or replaces one, so swapping the loop or dropping a spine member means composing a different bundle. +- **Most of the spine set is fixed in code** — `apply()` always mounts the core services and `tool-bash`; config can omit the bundled skills and task-control tools, but swapping the loop or dropping another spine member means composing a different bundle. - **`dsh-invariants` mounts unconditionally** — this bundle has no toggle, so every composition using it pays the dev-mode relational assertions; Session's always-on validation and freezing are separate. diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index a4965ca457..74ba5e0cc9 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -31,6 +31,8 @@ export const name = 'agent-spine-demo' /** Skill bundle config forwarded to the registry, local provider, and model-facing consumer. */ export interface SkillConfig { + /** Mount the bundled local skill provider and model-facing skill tool (default true). */ + enabled?: boolean /** Registry-level discovery cache settings. */ registry?: SkillRegistryConfig /** Local filesystem skill provider settings. */ @@ -73,12 +75,13 @@ export interface Config { skills?: SkillConfig /** Model-facing bash tool config, including this producer's background opt-in. */ toolBash?: toolBash.Config - /** Generic background-task control-tool wait bounds. */ - toolTasks?: toolTasks.Config + /** Generic background-task controls; set false to keep the task service without model-facing task tools. */ + toolTasks?: toolTasks.Config | false } /** The skill config schema exported for app packages that forward `skills`. */ export const SkillConfigSchema: z = z.object({ + enabled: z.boolean().default(true), registry: SkillService.Config, local: SkillLocal.Config, tool: toolSkill.Config, @@ -100,7 +103,7 @@ export const Config = z.intersect([ skills: SkillConfigSchema, workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), toolBash: ToolBashConfigSchema, - toolTasks: ToolTasksConfigSchema, + toolTasks: z.union([z.const(false), ToolTasksConfigSchema]), }) as unknown as z>, ]) as unknown as z @@ -150,8 +153,11 @@ export function apply(ctx: Context, config: Config): void { ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {}, }) ctx.plugin(ToolRegistry, config.tools ?? {}) - ctx.plugin(SkillService, config.skills?.registry ?? {}) - ctx.plugin(SkillLocal, Object.assign({}, config.skills?.local, { dshHome })) + const skillsEnabled = config.skills?.enabled ?? true + if (skillsEnabled) { + ctx.plugin(SkillService, config.skills?.registry ?? {}) + ctx.plugin(SkillLocal, Object.assign({}, config.skills?.local, { dshHome })) + } ctx.plugin(AgentRegistry) ctx.plugin(TaskService) ctx.plugin(invariants) @@ -161,8 +167,8 @@ export function apply(ctx: Context, config: Config): void { } // Both plugins prepend session-prefix messages. Registration order is the // rendered order, so workspace instructions must precede the skill catalog. - ctx.plugin(toolSkill, config.skills?.tool ?? {}) - ctx.plugin(toolTasks, config.toolTasks ?? {}) + if (skillsEnabled) ctx.plugin(toolSkill, config.skills?.tool ?? {}) + if (config.toolTasks !== false) ctx.plugin(toolTasks, config.toolTasks ?? {}) ctx.plugin(AgentLoop, { agents: config.agents ?? [], ...config.maxParallelToolCalls !== undefined ? { maxParallelToolCalls: config.maxParallelToolCalls } : {}, diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 5d5b336ff9..bc12706e93 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -344,6 +344,21 @@ describe('dsh-agent-spine-demo bundle', () => { await ctx.fiber.dispose() }) + it('can omit skills and model-facing task controls for a foreground-only deployment', async () => { + const ctx = await mount({ + workspaceContext: false, + skills: { enabled: false }, + toolBash: { enableRunInBackground: false }, + toolTasks: false, + }, true) + + expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['bash']) + expect(ctx.get('skills')).toBeUndefined() + expect(ctx.get('tasks')).toBeDefined() + + await ctx.fiber.dispose() + }) + it('picks shared spine config without leaking front-door fields', () => { const appConfig = { model: 'front-door-only', @@ -352,9 +367,9 @@ describe('dsh-agent-spine-demo bundle', () => { tools: { mode: 'native' as const }, dshHome: '/tmp/dsh-home', workspaceContext: false as const, - skills: {}, + skills: { enabled: false }, toolBash: { enableRunInBackground: false }, - toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 }, + toolTasks: false as const, } expect(agentCore.pickSpineConfig(appConfig)).toEqual({ @@ -363,7 +378,7 @@ describe('dsh-agent-spine-demo bundle', () => { tools: appConfig.tools, dshHome: appConfig.dshHome, workspaceContext: false, - skills: {}, + skills: appConfig.skills, toolBash: appConfig.toolBash, toolTasks: appConfig.toolTasks, }) diff --git a/packages/examples/cli-demo/src/index.ts b/packages/examples/cli-demo/src/index.ts index 1308209681..e5c77af9ed 100644 --- a/packages/examples/cli-demo/src/index.ts +++ b/packages/examples/cli-demo/src/index.ts @@ -61,7 +61,7 @@ export const Config: z = z.object({ toolOrder: z.array(z.string()).default(undefined as unknown as string[]), tools: ToolRegistry.Config, toolBash: agentCore.ToolBashConfigSchema, - toolTasks: agentCore.ToolTasksConfigSchema, + toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), }) /* jscpd:ignore-end */ diff --git a/packages/examples/cli-demo/tests/cli-demo.spec.ts b/packages/examples/cli-demo/tests/cli-demo.spec.ts index 343d6184d7..2111ff6aa8 100644 --- a/packages/examples/cli-demo/tests/cli-demo.spec.ts +++ b/packages/examples/cli-demo/tests/cli-demo.spec.ts @@ -146,6 +146,21 @@ describe('dsh-cli-demo app composition', () => { expect(wait).toHaveBeenCalledWith(id, 7, undefined, undefined) }) + it('accepts false to keep task services without model-facing task controls', async () => { + const ctx = await mount({ + provider: 'mock', + model: 'mock', + skills: { enabled: false }, + toolTasks: false, + workspaceContext: false, + }) + + expect(ctx.get('tasks')).toBeDefined() + expect(ctx.get('tools')?.get('task_output')).toBeUndefined() + expect(ctx.get('tools')?.get('task_list')).toBeUndefined() + expect(ctx.get('tools')?.get('task_kill')).toBeUndefined() + }) + it('exposes the Loader-safe namespace plugin shape and schema', () => { expect(cliDemo.name).toBe('cli-demo') expect(cliDemo.Config).toBeDefined() diff --git a/packages/examples/stdio-demo/src/index.ts b/packages/examples/stdio-demo/src/index.ts index fecdaf8547..c8fa7b5fd0 100644 --- a/packages/examples/stdio-demo/src/index.ts +++ b/packages/examples/stdio-demo/src/index.ts @@ -98,7 +98,7 @@ export interface Config { skills?: agentCore.SkillConfig /** Model-facing bash tool config forwarded through agent-core. */ toolBash?: NonNullable - /** Generic background-task control-tool config forwarded through agent-core. */ + /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ toolTasks?: NonNullable /** * If set, the pre-created agent RESUMES this persisted session id instead of @@ -126,7 +126,7 @@ export const Config: z = z.object({ ui: UiConfigSchema, skills: agentCore.SkillConfigSchema, toolBash: agentCore.ToolBashConfigSchema, - toolTasks: agentCore.ToolTasksConfigSchema, + toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), resumeSessionId: z.string(), workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), }) diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 4f7cc66f87..918c8eee82 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -91,6 +91,9 @@ export class DeepSeekAdapter extends LlmAdapter { 'content-type': 'application/json', 'accept': 'text/event-stream', ...attributionHeaders(), + ...options.sessionId !== undefined + ? { 'x-deepseek-harness-session-id': String(options.sessionId) } + : {}, }, body: JSON.stringify(body), ...options.signal ? { signal: options.signal } : {}, diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 5d15a1e437..954a0ecebd 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -3,6 +3,7 @@ import type { IncomingMessage, Server, ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, userAgent } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { DeepSeekAdapter } from '@deepseek-ai/dsh-llm-deepseek' import { httpErrorCode } from '../src/adapter.ts' @@ -133,6 +134,19 @@ describe('DeepSeekAdapter against a mock server', () => { expect(kinds).toEqual(['block-start', 'text-delta', 'block-end', 'usage', 'finish']) }) + it('forwards the harness session id for host-side trajectory routing', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const ctx = await harness(server.url) + + await assemble(ctx, { + model: 'deepseek-v4-flash', + messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + sessionId: SessionId('child-session'), + }) + + expect(server.headers[0]?.['x-deepseek-harness-session-id']).toBe('child-session') + }) + it('forwards thinking config onto the wire', async () => { const server = await mockServer([{ kind: 'sse', events: textEvents }]) const ctx = await harness(server.url, { thinking: 'disabled', reasoningEffort: 'high' }) diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 82376e3f5d..51f78e7760 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -168,6 +168,26 @@ describe('PiAiAdapter provider routing', () => { expect(server.paths).toEqual(['/v1/responses']) }) + it('uses OpenAI Responses against an Azure project v1 path with its API key header', async () => { + const server = await mockServer([{ status: 401, body: JSON.stringify({ error: { message: 'expected mock failure' } }) }]) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmPiAi, { + providers: [{ + provider: 'openai', + apiKey: 'test-key', + baseURL: `${server.url}/api/projects/openai/openai/v1`, + headers: { 'api-key': 'test-key', Authorization: '' }, + maxRetries: 0, + }], + }) + const result = await assemble(ctx, { provider: 'openai', model: 'gpt-5.5', messages: [] }) + expect(result.finish.kind).toBe('error') + expect(server.paths).toEqual(['/api/projects/openai/openai/v1/responses']) + expect(server.headers[0]?.['api-key']).toBe('test-key') + expect(server.headers[0]?.authorization).toBe('') + }) + it.each([ [401, 'AUTH'], [400, 'INVALID_REQUEST'], diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts new file mode 100644 index 0000000000..107c12d264 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -0,0 +1,163 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import LlmService, { CallId } from '@deepseek-ai/dsh-llm' +import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' +import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' +import type { PiAiReplayState } from '../src/replay.ts' +import { assemble, type AssembledResult } from './assemble.ts' + +interface ProviderCase { + provider: 'openai' | 'anthropic' + api: 'openai-responses' | 'anthropic-messages' + model: string + apiKey?: string + baseURL?: string + headers?: Record +} + +const openAIBaseURL = process.env.DSH_PI_AI_OPENAI_BASE_URL +const azureOpenAIKey = process.env.AZURE_OPENAI_API_KEY + +const providerCases: ProviderCase[] = [ + { + provider: 'openai', + api: 'openai-responses', + model: process.env.DSH_PI_AI_OPENAI_MODEL ?? 'gpt-5.5', + ...azureOpenAIKey + ? { apiKey: azureOpenAIKey, headers: { 'api-key': azureOpenAIKey, Authorization: '' } } + : {}, + ...openAIBaseURL ? { baseURL: openAIBaseURL } : {}, + }, + { + provider: 'anthropic', + api: 'anthropic-messages', + model: process.env.DSH_PI_AI_ANTHROPIC_MODEL ?? 'claude-opus-4-8', + ...process.env.ANTHROPIC_API_KEY ? { apiKey: process.env.ANTHROPIC_API_KEY } : {}, + }, +] + +const contexts: Context[] = [] + +async function harness(): Promise { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(LlmService) + await ctx.plugin(LlmPiAi, { + providers: providerCases.map(profile => ({ + provider: profile.provider, + ...profile.apiKey === undefined ? {} : { apiKey: profile.apiKey }, + ...profile.baseURL === undefined ? {} : { baseURL: profile.baseURL }, + ...profile.headers === undefined ? {} : { headers: profile.headers }, + })), + }) + return ctx +} + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +function ask(text: string): Message[] { + return [{ role: 'user', content: [{ type: 'text', text }] }] +} + +function textOf(result: AssembledResult): string { + return result.message.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') +} + +function expectFinish(result: AssembledResult, expected: 'stop' | 'tool-calls'): void { + if (result.finish.kind === 'error') { + throw new Error(`provider request failed (${result.finish.code ?? 'unknown'}): ${result.finish.message}`) + } + expect(result.finish.kind).toBe(expected) +} + +function expectNativeReplay(result: AssembledResult, profile: ProviderCase): PiAiReplayState { + const replayState = result.message.provenance?.replayState + expect(replayState).toMatchObject({ + kind: 'pi-ai', + version: 1, + api: profile.api, + provider: profile.provider, + model: profile.model, + }) + return replayState as PiAiReplayState +} + +const lookupTool: ToolSchema = { + name: 'lookup_code', + description: 'Look up the word represented by a short code.', + parameters: { + type: 'object', + properties: { code: { type: 'string', description: 'The code to look up.' } }, + required: ['code'], + }, +} + +for (const profile of providerCases) { + describe.skipIf(profile.apiKey === undefined)( + `llm-pi-ai ${profile.provider} e2e (${profile.api})`, + () => { + it('streams text with usage and native replay metadata', async () => { + const ctx = await harness() + const result = await assemble(ctx, { + provider: profile.provider, + model: profile.model, + messages: ask('Reply with exactly the word: pong'), + maxTokens: 1024, + }) + + expectFinish(result, 'stop') + expect(textOf(result).toLowerCase()).toContain('pong') + expect(result.usage?.inputTokens).toBeGreaterThan(0) + expect(result.usage?.outputTokens).toBeGreaterThan(0) + expect(expectNativeReplay(result, profile).stopReason).toBe('stop') + }) + + it('round-trips a tool call with provider-native replay metadata', async () => { + const ctx = await harness() + const prompt = ask('Use lookup_code with code "blue". Do not answer without calling the tool.') + const first = await assemble(ctx, { + provider: profile.provider, + model: profile.model, + messages: prompt, + tools: [lookupTool], + maxTokens: 2048, + }) + + expectFinish(first, 'tool-calls') + const call = first.message.content.find(block => block.type === 'tool-call') + expect(call).toBeDefined() + expect(call!.name).toBe('lookup_code') + expect(JSON.parse(call!.arguments)).toMatchObject({ code: 'blue' }) + expect(expectNativeReplay(first, profile).stopReason).toBe('toolUse') + + const second = await assemble(ctx, { + provider: profile.provider, + model: profile.model, + messages: [ + ...prompt, + first.message, + { + role: 'user', + content: [{ + type: 'tool-result', + toolCallId: CallId(call!.id), + content: [{ type: 'text', text: 'The code blue means ocean.' }], + }], + }, + ], + tools: [lookupTool], + maxTokens: 2048, + }) + + expectFinish(second, 'stop') + expect(textOf(second).toLowerCase()).toContain('ocean') + expect(expectNativeReplay(second, profile).stopReason).toBe('stop') + }) + }, + ) +} diff --git a/packages/sdk/create-sdk/README.md b/packages/sdk/create-sdk/README.md index 1a047a8cd7..c0d1f5d026 100644 --- a/packages/sdk/create-sdk/README.md +++ b/packages/sdk/create-sdk/README.md @@ -6,13 +6,13 @@ The supported package surface is the `create-sdk` bin. The package root exports The initializer rejects every existing target path, creates one `SdkProject` edit session, validates and commits it, then asks whether to install NPM dependencies and build. Install or build failures keep the generated project and print a retry command. -Public flags are `[directory]`, `--description`, `--provider`, `--base-url`, `--api-key`, `--model`, `--interface`, `--pm`, and `--install`/`--no-install`. Flags prefill matching questions, but creation always requires a TTY. +Public flags are `[directory]`, `--description`, `--provider`, `--base-url`, `--api-key`, `--model`, `--interface`, `--pm`, `--install`/`--no-install`, plus the headless flags `--config ` / `--config-json ` and `--json`. Interactive flags prefill matching questions; a headless spec (`--config`/`--config-json`) supplies every answer and its feature plan up front, so creation runs without a TTY and drives through a `HeadlessPromptPort` that fails loud on any missing required answer. `--json` emits NDJSON lifecycle events (`done` / `action-required` / `error`) so an agent can fill the named missing input and re-run. The provider choice is DeepSeek or a custom endpoint backed by `llm-pi-ai`. DeepSeek asks only for an API key and uses the public endpoint plus `deepseek-v4-flash`; custom also asks for a base URL. An empty key requires confirmation and creates a commented empty `.env` variable so provider startup fails clearly until it is filled. Existing plugin defaults are omitted; required SDK presets remain typed against the owning package's Config. ## Model Experience -Indirectly, through the generated project composition and its selected runtime plugins. +Indirectly, through the generated project composition and its selected runtime plugins; the headless `--config-json` + `--json` surface additionally lets an agent create a project end to end and react to `action-required` events. #### KV Cache effect @@ -20,4 +20,4 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work -- **TTY-only creation** — flags prefill questions, but the wizard still requires an interactive terminal before it writes a project. +- **Headless local plugins** — the headless spec supplies project answers and the feature plan; scaffolding a local plugin (the interactive none/plugin/tool choice) is not yet expressible in the spec and defaults to none. diff --git a/packages/sdk/create-sdk/src/args.ts b/packages/sdk/create-sdk/src/args.ts index 521132caa9..897159bd7c 100644 --- a/packages/sdk/create-sdk/src/args.ts +++ b/packages/sdk/create-sdk/src/args.ts @@ -19,6 +19,9 @@ export interface CreateArgs { packageManager?: PackageManagerName install?: boolean linkWorkspace?: boolean + config?: string + configJson?: string + json?: boolean help: boolean } @@ -32,6 +35,9 @@ interface CommanderCreateOptions { pm?: PackageManagerName install?: boolean linkWorkspace?: boolean + config?: string + configJson?: string + json?: boolean help?: boolean } @@ -60,6 +66,9 @@ function createProgram(): Command { .addOption(new Option('--install').default(undefined)) .addOption(new Option('--no-install').default(undefined)) .option('--link-workspace') + .option('--config ') + .option('--config-json ') + .addOption(new Option('--json').default(undefined)) } /** Parse create-sdk positionals/options through Commander into a domain-neutral value. */ @@ -79,6 +88,9 @@ export function parseCreateArgs(argv: readonly string[]): CreateArgs { ...options.pm === undefined ? {} : { packageManager: options.pm }, ...options.install === undefined ? {} : { install: options.install }, ...options.linkWorkspace ? { linkWorkspace: true } : {}, + ...options.config === undefined ? {} : { config: options.config }, + ...options.configJson === undefined ? {} : { configJson: options.configJson }, + ...options.json === undefined ? {} : { json: options.json }, help: options.help ?? false, } } diff --git a/packages/sdk/create-sdk/src/command.ts b/packages/sdk/create-sdk/src/command.ts index 0c7076c90b..9897db2a21 100644 --- a/packages/sdk/create-sdk/src/command.ts +++ b/packages/sdk/create-sdk/src/command.ts @@ -7,12 +7,16 @@ import { readFile } from 'node:fs/promises' import { ClackPromptPort, + HeadlessPromptError, + HeadlessPromptPort, + NodeCommandRunner, PromptCancelledError, type PackageManagerVersionProbe, type PromptPort, } from '@deepseek-ai/dsh-helper' -import { parseCreateArgs } from './args.ts' +import { parseCreateArgs, type CreateArgs } from './args.ts' import { CreateWizard, type ResolvedCreateRequest } from './create-wizard.ts' +import { resolveHeadless } from './headless.ts' import { scaffoldProject, type ScaffoldResult } from './project-scaffolder.ts' import { CREATE_TEMPLATES, packageManagerTemplateModel } from './templates/create-templates.ts' @@ -42,24 +46,29 @@ export async function createProject( context: CreateCommandContext, ): Promise { const args = parseCreateArgs(argv) + // Under --json, stdout carries only NDJSON events: human-readable progress + // and package-manager child output move to stderr. + const progress = args.json === true ? context.stderr : context.stdout if (args.help) { context.stdout.write(CREATE_TEMPLATES.usage.render({})) return undefined } - if (!context.port && (!context.stdin.isTTY || !context.stdout.isTTY)) { - throw new Error('create-sdk requires an interactive TTY') + const headless = await resolveHeadless(args) + if (!headless && !context.port && (!context.stdin.isTTY || !context.stdout.isTTY)) { + throw new Error('create-sdk requires an interactive TTY, --config , or --config-json ') } const wizard = new CreateWizard({ - args, + args: headless ? headless.args : args, /* v8 ignore next -- production TTY wiring is exercised by the built-bin smoke */ - port: context.port ?? new ClackPromptPort(context.stdin, context.stdout), + port: context.port ?? (headless ? new HeadlessPromptPort() : new ClackPromptPort(context.stdin, context.stdout)), cwd: context.cwd, releaseVersion: context.releaseVersion ?? await readCreateSdkVersion(), ...context.versionProbe ? { versionProbe: context.versionProbe } : {}, + ...headless?.features ? { features: headless.features } : {}, }) const resolved = await wizard.run() const result = await scaffoldProject(resolved.directory, resolved.request) - context.stdout.write(CREATE_TEMPLATES.created.render({ + progress.write(CREATE_TEMPLATES.created.render({ name: resolved.request.name, directory: resolved.directory, })) @@ -67,8 +76,9 @@ export async function createProject( try { if (context.setup) await context.setup(resolved) else { - await resolved.request.packageManager.install(resolved.directory) - await resolved.request.packageManager.build(resolved.directory) + const runner = args.json === true ? new NodeCommandRunner(context.stderr) : new NodeCommandRunner() + await resolved.request.packageManager.install(resolved.directory, runner) + await resolved.request.packageManager.build(resolved.directory, runner) } } catch (error) { context.stderr.write(CREATE_TEMPLATES.setupFailure.render({ @@ -79,7 +89,7 @@ export async function createProject( throw error } } - context.stdout.write(CREATE_TEMPLATES.nextSteps.render({ + progress.write(CREATE_TEMPLATES.nextSteps.render({ directory: resolved.directory, setupRequired: !resolved.install, ...packageManagerTemplateModel(resolved.request.packageManager), @@ -87,6 +97,17 @@ export async function createProject( return result } +/** Whether NDJSON lifecycle events were requested, tolerating unparseable argv. */ +function wantsJsonEvents(argv: readonly string[]): boolean { + let parsed: CreateArgs + try { + parsed = parseCreateArgs(argv) + } catch { + return false + } + return parsed.json === true +} + /** Run the create command with process defaults and convert cancellation to a clean exit. */ export async function runCreateCommand( argv: readonly string[] = process.argv.slice(2), @@ -97,15 +118,27 @@ export async function runCreateCommand( stderr: process.stderr, }, ): Promise { + const json = wantsJsonEvents(argv) + const emit = (event: Record): void => { + context.stdout.write(`${JSON.stringify(event)}\n`) + } try { await createProject(argv, context) + if (json) emit({ type: 'done' }) return 0 } catch (error) { if (error instanceof PromptCancelledError) { - context.stderr.write('create-sdk: cancelled\n') + if (json) emit({ type: 'error', reason: 'cancelled' }) + else context.stderr.write('create-sdk: cancelled\n') return 1 } - context.stderr.write(`create-sdk: ${error instanceof Error ? error.message : String(error)}\n`) + if (json && error instanceof HeadlessPromptError) { + emit({ type: 'action-required', prompt: error.prompt }) + return 1 + } + const message = error instanceof Error ? error.message : String(error) + if (json) emit({ type: 'error', message }) + else context.stderr.write(`create-sdk: ${message}\n`) return 1 } } diff --git a/packages/sdk/create-sdk/src/create-wizard.ts b/packages/sdk/create-sdk/src/create-wizard.ts index 8391fae1e4..fb6849ba2a 100644 --- a/packages/sdk/create-sdk/src/create-wizard.ts +++ b/packages/sdk/create-sdk/src/create-wizard.ts @@ -48,6 +48,7 @@ export class CreateWizard { private readonly versionProbe: PackageManagerVersionProbe private readonly userAgent: string private readonly linkWorkspaceRoot: string | undefined + private readonly featurePlan: readonly FeatureSelection[] | undefined /** Bind parsed args and infrastructure to one wizard run. */ constructor(options: { @@ -57,6 +58,7 @@ export class CreateWizard { releaseVersion: string versionProbe?: PackageManagerVersionProbe userAgent?: string + features?: readonly FeatureSelection[] }) { this.args = options.args this.port = options.port @@ -68,6 +70,7 @@ export class CreateWizard { this.linkWorkspaceRoot = options.args.linkWorkspace ? fileURLToPath(new URL('../../../../', import.meta.url)) : undefined + this.featurePlan = options.features } /** Collect all answers before constructing any project files. */ @@ -129,39 +132,43 @@ export class CreateWizard { const configurable = registry.all().filter(feature => feature.id === 'bash' || feature.id === 'persistence' || (!feature.required && feature.isApplicable(profile))) - const selected = [...requireAnswer(await this.port.nestedMultiselect({ - message: 'Select features', - options: configurable.map((feature) => { - const nested = feature.mode !== 'single' - const defaults = new Set(feature.defaultOptions(profile)) - return { - value: feature.id, - label: feature.summary, - required: feature.required, - default: feature.required || feature.id === 'hmr' || feature.id === 'fs' || feature.id === 'todo' - || feature.id === 'skill', - ...nested ? { - choiceMode: feature.mode === 'multiple' ? 'multiple' as const : 'exclusive' as const, - choices: feature.options.map(option => ({ - value: option.id, - label: option.label, - default: defaults.has(option.id), - })), - } : {}, + const selected = this.featurePlan + ? this.featurePlan.map(feature => ({ value: feature.id, choices: feature.options })) + : [...requireAnswer(await this.port.nestedMultiselect({ + message: 'Select features', + options: configurable.map((feature) => { + const nested = feature.mode !== 'single' + const defaults = new Set(feature.defaultOptions(profile)) + return { + value: feature.id, + label: feature.summary, + required: feature.required, + default: feature.required || feature.id === 'hmr' || feature.id === 'fs' || feature.id === 'todo' + || feature.id === 'skill', + ...nested ? { + choiceMode: feature.mode === 'multiple' ? 'multiple' as const : 'exclusive' as const, + choices: feature.options.map(option => ({ + value: option.id, + label: option.label, + default: defaults.has(option.id), + })), + } : {}, + } + }), + }))] + if (!this.featurePlan) { + for (const { value: id } of [...selected]) { + const feature = registry.get(id) + for (const suggestedId of feature.suggests) { + if (selected.some(item => item.value === suggestedId)) continue + const suggested = registry.get(suggestedId) + const add = requireAnswer(await new ConfirmQuestion({ + id: `${feature.id}.${suggested.id}`, + message: `Add the recommended ${suggested.summary.toLowerCase()} for ${feature.summary.toLowerCase()}?`, + initialValue: true, + }).resolve(this.port)) + if (add) selected.push({ value: suggested.id, choices: suggested.defaultOptions(profile) }) } - }), - }))] - for (const { value: id } of [...selected]) { - const feature = registry.get(id) - for (const suggestedId of feature.suggests) { - if (selected.some(item => item.value === suggestedId)) continue - const suggested = registry.get(suggestedId) - const add = requireAnswer(await new ConfirmQuestion({ - id: `${feature.id}.${suggested.id}`, - message: `Add the recommended ${suggested.summary.toLowerCase()} for ${feature.summary.toLowerCase()}?`, - initialValue: true, - }).resolve(this.port)) - if (add) selected.push({ value: suggested.id, choices: suggested.defaultOptions(profile) }) } } const fixed = new Set(selections.map(selection => selection.id)) @@ -174,12 +181,16 @@ export class CreateWizard { for (const choice of selected) { choices.set(choice.value, choice.choices.length > 0 ? choice.choices : undefined) } + const plannedById = new Map((this.featurePlan ?? []).map(feature => [feature.id, feature])) for (const [id, options] of choices) { + const planned = plannedById.get(id) selections.push(await configurator.configure( registry.get(id), profile, undefined, options, + planned?.secrets ?? {}, + planned?.values ?? {}, )) } return selections diff --git a/packages/sdk/create-sdk/src/headless.ts b/packages/sdk/create-sdk/src/headless.ts new file mode 100644 index 0000000000..164405e14f --- /dev/null +++ b/packages/sdk/create-sdk/src/headless.ts @@ -0,0 +1,98 @@ +/** + * Headless create input: a structured project spec supplied by an agent or CI + * instead of interactive prompts. + * + * @module @deepseek-ai/create-sdk/headless + */ + +import { readFile } from 'node:fs/promises' +import type { FeatureSelection, PackageManagerName, RunInterface } from '@deepseek-ai/dsh-helper' +import type { CreateArgs } from './args.ts' + +/** + * Structured, non-interactive create input. Scalar fields mirror {@link CreateArgs} + * project answers; `features` is the headless feature plan handed to `CreateWizard` + * (the interactive tree/suggests prompts are skipped). Absent required answers make + * the run fail loud through `HeadlessPromptPort` rather than blocking. + */ +interface HeadlessCreateSpec { + directory?: string + description?: string + provider?: 'deepseek' | 'custom' + baseURL?: string + apiKey?: string + model?: string + interface?: RunInterface + pm?: PackageManagerName + install?: boolean + linkWorkspace?: boolean + features?: readonly FeatureSelection[] +} + +/** Resolved headless input: the args the wizard reads plus the feature plan. */ +export interface ResolvedHeadless { + args: CreateArgs + features: readonly FeatureSelection[] | undefined +} + +function asRecord(value: unknown, source: string): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${source}: expected a JSON object`) + } + return value as Record +} + +/** Parse and shallow-validate a headless spec from JSON text. */ +function parseHeadlessSpec(text: string, source: string): HeadlessCreateSpec { + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch (error) { + /* v8 ignore next -- JSON.parse only throws Error instances; the String() branch is defensive */ + throw new Error(`${source}: invalid JSON (${error instanceof Error ? error.message : String(error)})`) + } + const record = asRecord(parsed, source) + if (record.features !== undefined && !Array.isArray(record.features)) { + throw new Error(`${source}: "features" must be an array`) + } + return record +} + +/** + * Load a headless spec from `--config-json` (inline) or `--config` (a JSON file), + * returning `undefined` when neither is supplied. + * @param args - parsed create args. + * @param readFileText - file reader seam for tests. + * @returns the resolved args + feature plan, or `undefined` for interactive runs. + */ +export async function resolveHeadless( + args: CreateArgs, + readFileText: (path: string) => Promise = path => readFile(path, 'utf8'), +): Promise { + let text: string + let source: string + if (args.configJson !== undefined) { + text = args.configJson + source = '--config-json' + } else if (args.config !== undefined) { + source = args.config + text = await readFileText(args.config) + } else { + return undefined + } + const spec = parseHeadlessSpec(text, source) + const resolvedArgs: CreateArgs = { + ...spec.directory === undefined ? {} : { directory: spec.directory }, + ...spec.description === undefined ? {} : { description: spec.description }, + ...spec.provider === undefined ? {} : { provider: spec.provider }, + ...spec.baseURL === undefined ? {} : { baseURL: spec.baseURL }, + ...spec.apiKey === undefined ? {} : { apiKey: spec.apiKey }, + ...spec.model === undefined ? {} : { model: spec.model }, + ...spec.interface === undefined ? {} : { runInterface: spec.interface }, + ...spec.pm === undefined ? {} : { packageManager: spec.pm }, + ...spec.install === undefined ? {} : { install: spec.install }, + ...spec.linkWorkspace ? { linkWorkspace: true } : {}, + help: false, + } + return { args: resolvedArgs, features: spec.features } +} diff --git a/packages/sdk/create-sdk/src/templates/assets/usage.txt.tpl b/packages/sdk/create-sdk/src/templates/assets/usage.txt.tpl index 2b734eb753..32f4d5c6d2 100644 --- a/packages/sdk/create-sdk/src/templates/assets/usage.txt.tpl +++ b/packages/sdk/create-sdk/src/templates/assets/usage.txt.tpl @@ -9,3 +9,6 @@ Options: --interface --pm --install / --no-install + --config + --config-json + --json diff --git a/packages/sdk/create-sdk/tests/create.spec.ts b/packages/sdk/create-sdk/tests/create.spec.ts index 04a251351d..c7c74c9659 100644 --- a/packages/sdk/create-sdk/tests/create.spec.ts +++ b/packages/sdk/create-sdk/tests/create.spec.ts @@ -5,9 +5,12 @@ import { PassThrough, Writable } from 'node:stream' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it, vi } from 'vitest' import { + HeadlessPromptPort, LocalPluginBlueprint, featureId, + NodeCommandRunner, NpmPackageManager, + type FeatureSelection, type NestedMultiSelectValue, type PromptPort, } from '@deepseek-ai/dsh-helper' @@ -28,6 +31,7 @@ import { type CreateCommandContext, } from '../src/command.ts' import { CreateWizard } from '../src/create-wizard.ts' +import { resolveHeadless } from '../src/headless.ts' import { scaffoldProject } from '../src/project-scaffolder.ts' class ScriptedPort implements PromptPort { @@ -233,6 +237,54 @@ describe('CreateWizard and scaffolder', () => { expect(resolved.request.features.find(item => item.id === 'hmr')).toMatchObject({ options: ['default'] }) }) + it('runs headlessly from a feature plan without reaching the terminal', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'create-headless-')) + temporary.push(cwd) + const features: FeatureSelection[] = [ + { id: featureId('persistence'), options: ['sqlite'], values: { region: 'us' } }, + { id: featureId('web'), options: ['exa'], secrets: { apiKey: 'exa-key' } }, + ] + const resolved = await new CreateWizard({ + args: parseCreateArgs([ + 'my-agent', '--description=demo', '--provider=deepseek', '--api-key=deepseek-key', + '--model=deepseek-v4-flash', '--interface=stdio', '--pm=npm', '--no-install', + ]), + port: new HeadlessPromptPort(), + cwd, + releaseVersion: '0.0.1', + versionProbe: async () => '10.0.0', + features, + }).run() + expect(resolved.install).toBe(false) + expect(resolved.request.localPlugins).toEqual([]) + expect(resolved.request.features.find(item => item.id === 'web')).toMatchObject({ + options: ['exa'], secrets: { apiKey: 'exa-key' }, + }) + expect(resolved.request.features.find(item => item.id === 'persistence')).toMatchObject({ options: ['sqlite'] }) + expect(resolved.request.features.find(item => item.id === 'provider')).toMatchObject({ + secrets: { apiKey: 'deepseek-key' }, + }) + }) + + it('rejects a non-string feature value in a headless plan', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'create-headless-bad-')) + temporary.push(cwd) + const features = [ + { id: featureId('persistence'), options: ['sqlite'], values: { bad: 1 } }, + ] as unknown as FeatureSelection[] + await expect(new CreateWizard({ + args: parseCreateArgs([ + 'my-agent', '--description=demo', '--provider=deepseek', '--api-key=k', + '--model=m', '--interface=stdio', '--pm=npm', '--no-install', + ]), + port: new HeadlessPromptPort(), + cwd, + releaseVersion: '0.0.1', + versionProbe: async () => '10.0.0', + features, + }).run()).rejects.toThrow('must be a string') + }) + it('writes the project once and refuses every existing target', async () => { const root = await mkdtemp(join(tmpdir(), 'create-scaffold-')) temporary.push(root) @@ -427,12 +479,65 @@ describe('create command composition', () => { context.stdout.isTTY = false await expect(createProject(['--help'], context)).resolves.toBeUndefined() expect(context.readStdout()).toContain('Usage: create-sdk') + expect(context.readStdout()).toContain('--config-json ') expect(context.readStdout()).not.toContain('--link-workspace') await expect(createProject(argv('agent', false), context)).rejects.toThrow('interactive TTY') context.stdin.isTTY = true await expect(createProject(argv('agent', false), context)).rejects.toThrow('interactive TTY') }) + it('creates headlessly from --config-json with no TTY', async () => { + const root = await mkdtemp(join(tmpdir(), 'create-headless-cmd-')) + temporary.push(root) + const spec = JSON.stringify({ + directory: 'agent', description: 'test', provider: 'deepseek', apiKey: 'key', + model: 'deepseek-v4-flash', interface: 'embed', pm: 'npm', install: false, + features: [{ id: 'persistence', options: ['jsonl'] }], + }) + const context = commandContext(root) + context.stdin.isTTY = false + context.stdout.isTTY = false + const result = await createProject(['--config-json', spec], context) + expect(result?.project.root).toBe(join(root, 'agent')) + }) + + it('emits NDJSON lifecycle events under --json', async () => { + const root = await mkdtemp(join(tmpdir(), 'create-headless-json-')) + temporary.push(root) + const base = { + description: 'test', model: 'deepseek-v4-flash', interface: 'embed', pm: 'npm', install: false, + } + const ok = commandContext(root) + ok.stdin.isTTY = false + ok.stdout.isTTY = false + const okSpec = JSON.stringify({ ...base, directory: 'done-agent', provider: 'deepseek', apiKey: 'key', features: [] }) + await expect(runCreateCommand(['--config-json', okSpec, '--json'], ok)).resolves.toBe(0) + expect(ok.readStdout()).toContain('{"type":"done"}') + // stdout stays pure NDJSON: every line parses, human progress goes to stderr + for (const line of ok.readStdout().split('\n').filter(line => line.length > 0)) { + expect(() => { JSON.parse(line) }).not.toThrow() + } + expect(ok.readStderr()).toContain('Created done-agent') + expect(ok.readStderr()).toContain('Next: cd') + + const missing = commandContext(root) + missing.stdin.isTTY = false + missing.stdout.isTTY = false + const missingSpec = JSON.stringify({ ...base, directory: 'miss-agent', provider: 'custom', baseURL: 'https://x', features: [] }) + await expect(runCreateCommand(['--config-json', missingSpec, '--json'], missing)).resolves.toBe(1) + expect(missing.readStdout()).toContain('"type":"action-required"') + + const broken = commandContext(root) + broken.stdin.isTTY = false + broken.stdout.isTTY = false + await expect(runCreateCommand(['--config-json', '{bad', '--json'], broken)).resolves.toBe(1) + expect(broken.readStdout()).toContain('"type":"error"') + + const cancelled = commandContext(root, new ScriptedPort([ScriptedPort.cancel])) + await expect(runCreateCommand(['--json', ...argv('cancel-agent', false)], cancelled)).resolves.toBe(1) + expect(cancelled.readStdout()).toContain('"reason":"cancelled"') + }) + it('creates through an injected prompt port and delegates optional setup', async () => { const root = await mkdtemp(join(tmpdir(), 'create-command-success-')) temporary.push(root) @@ -467,6 +572,17 @@ describe('create command composition', () => { await createProject(argv('agent', true), context) expect(install).toHaveBeenCalledOnce() expect(build).toHaveBeenCalledOnce() + const spec = JSON.stringify({ + directory: 'json-agent', description: 'test', provider: 'deepseek', apiKey: 'key', + model: 'deepseek-v4-flash', interface: 'embed', pm: 'npm', install: true, features: [], + }) + const json = commandContext(root) + json.stdin.isTTY = false + json.stdout.isTTY = false + await createProject(['--config-json', spec, '--json'], json) + // json mode hands install/build a runner that redirects child output to stderr + expect(install).toHaveBeenCalledTimes(2) + expect(install.mock.calls[1]?.[1]).toBeInstanceOf(NodeCommandRunner) install.mockRestore() build.mockRestore() }) @@ -501,3 +617,58 @@ describe('create command composition', () => { await expect(runCreateCommand(['--help'], help)).resolves.toBe(0) }) }) + +describe('resolveHeadless', () => { + it('returns undefined without a config source', async () => { + expect(await resolveHeadless(parseCreateArgs(['agent']))).toBeUndefined() + }) + + it('maps every inline --config-json field into args plus the feature plan', async () => { + const spec = JSON.stringify({ + directory: 'a', description: 'd', provider: 'custom', baseURL: 'https://x', apiKey: 'k', + model: 'm', interface: 'acp', pm: 'pnpm', install: true, linkWorkspace: true, + features: [{ id: 'todo', options: ['default'] }], + }) + const resolved = await resolveHeadless(parseCreateArgs(['--config-json', spec])) + expect(resolved?.args).toMatchObject({ + directory: 'a', description: 'd', provider: 'custom', baseURL: 'https://x', apiKey: 'k', + model: 'm', runInterface: 'acp', packageManager: 'pnpm', install: true, linkWorkspace: true, help: false, + }) + expect(resolved?.features).toEqual([{ id: 'todo', options: ['default'] }]) + }) + + it('reads --config from a file via the injected reader and omits absent fields', async () => { + const resolved = await resolveHeadless( + parseCreateArgs(['--config', '/spec.json']), + async () => JSON.stringify({ description: 'from-file' }), + ) + expect(resolved?.args.description).toBe('from-file') + expect(resolved?.args.directory).toBeUndefined() + expect(resolved?.args.linkWorkspace).toBeUndefined() + expect(resolved?.features).toBeUndefined() + }) + + it('reads --config from disk with the default reader', async () => { + const dir = await mkdtemp(join(tmpdir(), 'create-headless-file-')) + temporary.push(dir) + const file = join(dir, 'spec.json') + await writeFile(file, JSON.stringify({ description: 'on-disk' })) + const resolved = await resolveHeadless(parseCreateArgs(['--config', file])) + expect(resolved?.args.description).toBe('on-disk') + }) + + it('fails loud on invalid JSON, a non-object root, or a non-array features field', async () => { + await expect(resolveHeadless(parseCreateArgs(['--config-json', '{bad']))).rejects.toThrow('invalid JSON') + await expect(resolveHeadless(parseCreateArgs(['--config-json', '[]']))).rejects.toThrow('expected a JSON object') + await expect(resolveHeadless(parseCreateArgs(['--config-json', 'null']))).rejects.toThrow('expected a JSON object') + await expect(resolveHeadless(parseCreateArgs(['--config-json', '5']))).rejects.toThrow('expected a JSON object') + await expect(resolveHeadless(parseCreateArgs(['--config-json', '{"features":1}']))).rejects.toThrow('must be an array') + }) + + it('accepts a minimal spec, leaving unspecified answers undefined', async () => { + const resolved = await resolveHeadless(parseCreateArgs(['--config-json', '{"directory":"x"}'])) + expect(resolved?.args.directory).toBe('x') + expect(resolved?.args.description).toBeUndefined() + expect(resolved?.features).toBeUndefined() + }) +}) diff --git a/packages/sdk/helper/src/features/feature-configurator.ts b/packages/sdk/helper/src/features/feature-configurator.ts index 47cc011d16..e6e14030f9 100644 --- a/packages/sdk/helper/src/features/feature-configurator.ts +++ b/packages/sdk/helper/src/features/feature-configurator.ts @@ -26,6 +26,7 @@ export class FeatureConfigurator { * @param current - currently installed selection, when configuring. * @param prefilledOptions - options already chosen by a tree picker. * @param prefilledSecrets - non-interactive secret values supplied by creation. + * @param prefilledValues - non-interactive value inputs supplied by a headless spec. * @returns normalized selection with captured values and secrets. */ async configure( @@ -34,6 +35,7 @@ export class FeatureConfigurator { current?: FeatureSelection, prefilledOptions?: readonly string[], prefilledSecrets: Readonly> = {}, + prefilledValues: Readonly> = {}, ): Promise { let options: readonly string[] switch (feature.mode) { @@ -69,6 +71,11 @@ export class FeatureConfigurator { id: feature.id, options, } + const coercedPrefilled: Record = {} + for (const [key, value] of Object.entries(prefilledValues)) { + if (typeof value !== 'string') throw new Error(`${feature.id}.${key} value must be a string`) + coercedPrefilled[key] = value + } const values: Record = {} for (const input of feature.valueInputs(selected, profile)) { const existing = current?.values?.[input.id] @@ -81,7 +88,7 @@ export class FeatureConfigurator { ...existing === undefined ? {} : { initialValue: existing }, validate: value => value.trim().length === 0 ? 'A value is required' : undefined, }) - values[input.id] = requireAnswer(await question.resolve(this.port)) + values[input.id] = requireAnswer(await question.resolve(this.port, coercedPrefilled[input.id])) } const base: FeatureSelection = Object.keys(values).length === 0 ? selected diff --git a/packages/sdk/helper/src/index.ts b/packages/sdk/helper/src/index.ts index 98c55c3f8b..85aba58a99 100644 --- a/packages/sdk/helper/src/index.ts +++ b/packages/sdk/helper/src/index.ts @@ -43,3 +43,4 @@ export { } from './questions/question.ts' export type { Question } from './questions/question.ts' export { ClackPromptPort } from './questions/clack-prompt-port.ts' +export { HeadlessPromptError, HeadlessPromptPort } from './questions/headless-prompt-port.ts' diff --git a/packages/sdk/helper/src/package-managers/package-manager.ts b/packages/sdk/helper/src/package-managers/package-manager.ts index a6f2fdd103..8d6b617977 100644 --- a/packages/sdk/helper/src/package-managers/package-manager.ts +++ b/packages/sdk/helper/src/package-managers/package-manager.ts @@ -58,17 +58,38 @@ export function scrubEnvironment(environment: NodeJS.ProcessEnv = process.env): /** Node child-process command runner with inherited stdio and quiescent completion. */ export class NodeCommandRunner implements CommandRunner { - /** Spawn one child and settle only after its exit. */ + private readonly output: NodeJS.WritableStream | undefined + + /** + * @param output - redirect target for child stdout+stderr; the child inherits + * this process's stdio when absent. Callers whose own stdout carries a machine + * protocol (create-sdk --json NDJSON) redirect child output to keep the + * protocol stream pure. + */ + constructor(output?: NodeJS.WritableStream) { + this.output = output + } + + /** Spawn one child and settle only after exit, with redirected stdio drained. */ run(command: string, args: readonly string[], cwd: string): Promise { return new Promise((resolve, reject) => { + const output = this.output + if (output === undefined) { + const child = spawn(command, [...args], { cwd, env: scrubEnvironment(), stdio: 'inherit', shell: false }) + child.once('error', reject) + child.once('exit', (exitCode, signal) => { resolve({ exitCode, signal }) }) + return + } const child = spawn(command, [...args], { cwd, env: scrubEnvironment(), - stdio: 'inherit', + stdio: ['inherit', 'pipe', 'pipe'], shell: false, }) + child.stdout.pipe(output, { end: false }) + child.stderr.pipe(output, { end: false }) child.once('error', reject) - child.once('exit', (exitCode, signal) => { resolve({ exitCode, signal }) }) + child.once('close', (exitCode, signal) => { resolve({ exitCode, signal }) }) }) } } @@ -148,6 +169,25 @@ export abstract class PackageManager { await this.runChecked(runner, this.buildCommand(), cwd, 'build') } + /** + * Build add-dependency command arguments for one already-normalized source spec. + * @param spec - a package-manager-native dependency source (`pkg@version` or `github:owner/repo#ref`). + * @returns arguments following the manager executable. + */ + addCommand(spec: string): readonly string[] { + return ['add', spec] + } + + /** + * Add one dependency from a native source spec and fail on non-zero or signalled exit. + * @param spec - a package-manager-native dependency source. + * @param cwd - project directory. + * @param runner - optional subprocess boundary. + */ + async add(spec: string, cwd: string, runner: CommandRunner = new NodeCommandRunner()): Promise { + await this.runChecked(runner, this.addCommand(spec), cwd, 'add') + } + private async runChecked(runner: CommandRunner, args: readonly string[], cwd: string, operation: string): Promise { const result = await runner.run(this.name, args, cwd) if (result.signal !== null) { @@ -184,6 +224,11 @@ export class NpmPackageManager extends PackageManager { override linkSpec(relativePath: string): string { return `file:${relativePath}` } + + /** npm adds a dependency through `install ` rather than an `add` verb. */ + override addCommand(spec: string): readonly string[] { + return ['install', spec] + } } /** pnpm workspace behavior. */ diff --git a/packages/sdk/helper/src/project/project-edit-session.ts b/packages/sdk/helper/src/project/project-edit-session.ts index 9b2c885ac5..0d0a1cb6dd 100644 --- a/packages/sdk/helper/src/project/project-edit-session.ts +++ b/packages/sdk/helper/src/project/project-edit-session.ts @@ -220,6 +220,23 @@ export class ProjectEditSession implements FeatureProjectView { this.addedPlugins.add(entry.id) } + /** + * Mount a Cordis entry for an external dependency the package manager has already + * added (github or npm), without generating files or re-adding the dependency. + * @param id - stable Cordis config entry id. + * @param packageName - the installed dependency's package name. + */ + addExternalPlugin(id: string, packageName: string): void { + this.assertOpen() + if (!this.manifest().npmDependency(packageName)) { + throw new Error(`external plugin dependency is not installed: ${packageName}`) + } + const cordis = this.cordis() + if (cordis.entry(id)) throw new Error(`Cordis config entry already exists: ${id}`) + cordis.addEntry({ id, name: packageName }) + this.addedPlugins.add(id) + } + /** Enable or disable one custom/manual Cordis config entry by stable id. */ setCustomPluginDisabled(id: string, disabled: boolean): void { this.assertOpen() diff --git a/packages/sdk/helper/src/questions/headless-prompt-port.ts b/packages/sdk/helper/src/questions/headless-prompt-port.ts new file mode 100644 index 0000000000..658500aed0 --- /dev/null +++ b/packages/sdk/helper/src/questions/headless-prompt-port.ts @@ -0,0 +1,97 @@ +/** + * Non-interactive prompt port for headless create/config and skill-driven runs. + * + * @module @deepseek-ai/dsh-helper/questions/headless-prompt-port + */ + +import type { + ConfirmPromptRequest, + MultiSelectPromptRequest, + NestedMultiSelectRequest, + NestedMultiSelectValue, + PromptOutcome, + PromptPort, + SecretPromptRequest, + SelectPromptRequest, + TextPromptRequest, +} from './prompt-port.ts' + +/** + * Raised when a headless run reaches a decision that was neither prefilled nor + * carries a usable default. The message names the unanswered prompt so an agent + * or CI caller can see exactly which input the spec must supply. + */ +export class HeadlessPromptError extends Error { + /** The unanswered prompt's user-facing message. */ + readonly prompt: string + + /** Build an error naming the unanswered prompt. */ + constructor(prompt: string) { + super(`headless run needs an answer for: ${prompt}`) + this.name = 'HeadlessPromptError' + this.prompt = prompt + } +} + +/** Resolve an answered outcome. */ +function answered(value: T): Promise> { + return Promise.resolve({ status: 'answered', value }) +} + +/** Reject with a named unanswered-prompt error. */ +function unanswered(message: string): Promise> { + return Promise.reject(new HeadlessPromptError(message)) +} + +/** + * A {@link PromptPort} that never blocks on a terminal. + * + * Answers are expected to arrive as prefilled values through the `Question` / + * `FeatureConfigurator` layers, so in a fully specified run this port is never + * reached. When it *is* reached, it takes the prompt's own declared default + * (`defaultValue` / `initialValue`) if one exists; otherwise it fails loud with + * {@link HeadlessPromptError}. Nested feature selection has no scalar default, + * so it always fails loud — headless callers must supply the feature set through + * the spec rather than the tree picker. + */ +export class HeadlessPromptPort implements PromptPort { + /** Answer visible text from its default, or fail loud. */ + text(request: TextPromptRequest): Promise> { + const fallback = request.initialValue ?? request.defaultValue + if (fallback === undefined) return unanswered(request.message) + const diagnostic = request.validate?.(fallback) + if (diagnostic) return unanswered(`${request.message} (${diagnostic})`) + return answered(fallback) + } + + /** A secret has no safe default: always fail loud. */ + secret(request: SecretPromptRequest): Promise> { + return unanswered(request.message) + } + + /** Answer a single choice from its initial value, or fail loud. */ + select(request: SelectPromptRequest): Promise> { + if (request.initialValue === undefined) return unanswered(request.message) + return answered(request.initialValue) + } + + /** Answer a multi-choice from its initial values, or fail loud when required. */ + multiselect(request: MultiSelectPromptRequest): Promise> { + const initial = request.initialValues ?? [] + if (request.required && initial.length === 0) return unanswered(request.message) + return answered(initial) + } + + /** Answer a confirmation from its initial value, or fail loud. */ + confirm(request: ConfirmPromptRequest): Promise> { + if (request.initialValue === undefined) return unanswered(request.message) + return answered(request.initialValue) + } + + /** Nested feature selection has no scalar default: always fail loud. */ + nestedMultiselect( + request: NestedMultiSelectRequest, + ): Promise[]>> { + return unanswered(request.message) + } +} diff --git a/packages/sdk/helper/tests/documents.spec.ts b/packages/sdk/helper/tests/documents.spec.ts index 1b8050b90f..7181820725 100644 --- a/packages/sdk/helper/tests/documents.spec.ts +++ b/packages/sdk/helper/tests/documents.spec.ts @@ -1,6 +1,7 @@ import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { Writable } from 'node:stream' import { afterEach, describe, expect, it } from 'vitest' import { CordisYamlFile, JsExpression } from '../src/documents/cordis-yaml-file.ts' import { EnvFile } from '../src/documents/env-file.ts' @@ -299,6 +300,11 @@ describe('package manager strategies', () => { await npm.install('/tmp', runner) await npm.build('/tmp', runner) expect(calls).toEqual([['npm', 'install'], ['npm', 'run', 'build']]) + await npm.add('some-pkg@1.0.0', '/tmp', runner) + const pnpm = createPackageManager('pnpm', '10.0.0') + await pnpm.add('github:o/r#sha', '/tmp', runner) + expect(calls).toContainEqual(['npm', 'install', 'some-pkg@1.0.0']) + expect(calls).toContainEqual(['pnpm', 'add', 'github:o/r#sha']) const failed: CommandRunner = { run: async () => ({ exitCode: 2, signal: null }) } await expect(npm.install('/tmp', failed)).rejects.toThrow('exited with code 2') const killed: CommandRunner = { run: async () => ({ exitCode: null, signal: 'SIGTERM' }) } @@ -321,6 +327,19 @@ describe('package manager strategies', () => { const runner = new NodeCommandRunner() await expect(runner.run(process.execPath, ['-e', ''], root)).resolves.toEqual({ exitCode: 0, signal: null }) await expect(runner.run('missing-dsh-command', [], root)).rejects.toThrow() + let redirected = '' + const output = new Writable({ + write(chunk, _encoding, callback) { redirected += String(chunk); callback() }, + }) + const redirecting = new NodeCommandRunner(output) + await expect(redirecting.run( + process.execPath, + ['-e', 'process.stdout.write("child-out"); process.stderr.write("child-err")'], + root, + )).resolves.toEqual({ exitCode: 0, signal: null }) + expect(redirected).toContain('child-out') + expect(redirected).toContain('child-err') + await expect(redirecting.run('missing-dsh-command', [], root)).rejects.toThrow() }) it('discovers and rewrites a repository-local NPM dependency closure', async () => { diff --git a/packages/sdk/helper/tests/headless-prompt-port.spec.ts b/packages/sdk/helper/tests/headless-prompt-port.spec.ts new file mode 100644 index 0000000000..12febcfeff --- /dev/null +++ b/packages/sdk/helper/tests/headless-prompt-port.spec.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { HeadlessPromptError, HeadlessPromptPort } from '../src/questions/headless-prompt-port.ts' + +/** Unwrap an answered outcome or fail the test. */ +async function answered(promise: Promise<{ status: 'answered'; value: T } | { status: 'cancelled' }>): Promise { + const outcome = await promise + if (outcome.status !== 'answered') throw new Error('expected an answered outcome') + return outcome.value +} + +describe('HeadlessPromptError', () => { + it('names the unanswered prompt', () => { + const error = new HeadlessPromptError('DeepSeek API key') + expect(error).toBeInstanceOf(Error) + expect(error.name).toBe('HeadlessPromptError') + expect(error.prompt).toBe('DeepSeek API key') + expect(error.message).toContain('DeepSeek API key') + }) +}) + +describe('HeadlessPromptPort', () => { + const port = new HeadlessPromptPort() + + describe('text', () => { + it('takes the initial value when present', async () => { + expect(await answered(port.text({ message: 'name', initialValue: 'agent' }))).toBe('agent') + }) + + it('falls back to the default value', async () => { + expect(await answered(port.text({ message: 'dir', defaultValue: 'my-agent' }))).toBe('my-agent') + }) + + it('prefers the initial value over the default value', async () => { + expect(await answered(port.text({ message: 'dir', initialValue: 'given', defaultValue: 'my-agent' }))).toBe('given') + }) + + it('fails loud when no default exists', async () => { + await expect(port.text({ message: 'base URL' })).rejects.toThrow(HeadlessPromptError) + }) + + it('fails loud when the default is invalid', async () => { + await expect(port.text({ + message: 'name', + defaultValue: '', + validate: value => value.length === 0 ? 'required' : undefined, + })).rejects.toThrow(/required/) + }) + }) + + describe('secret', () => { + it('always fails loud', async () => { + await expect(port.secret({ message: 'API key' })).rejects.toThrow(HeadlessPromptError) + }) + }) + + describe('select', () => { + it('takes the initial value when present', async () => { + expect(await answered(port.select({ message: 'pm', options: [{ value: 'npm', label: 'npm' }], initialValue: 'npm' }))).toBe('npm') + }) + + it('fails loud without an initial value', async () => { + await expect(port.select({ message: 'pm', options: [{ value: 'npm', label: 'npm' }] })).rejects.toThrow(HeadlessPromptError) + }) + }) + + describe('multiselect', () => { + it('returns the initial values', async () => { + expect(await answered(port.multiselect({ message: 'x', options: [], initialValues: ['a', 'b'] }))).toEqual(['a', 'b']) + }) + + it('returns an empty selection when none are supplied and none are required', async () => { + expect(await answered(port.multiselect({ message: 'x', options: [] }))).toEqual([]) + }) + + it('fails loud when required and nothing is preselected', async () => { + await expect(port.multiselect({ message: 'x', options: [], required: true })).rejects.toThrow(HeadlessPromptError) + }) + }) + + describe('confirm', () => { + it('takes the initial value when present', async () => { + expect(await answered(port.confirm({ message: 'install?', initialValue: false }))).toBe(false) + }) + + it('fails loud without an initial value', async () => { + await expect(port.confirm({ message: 'apply?' })).rejects.toThrow(HeadlessPromptError) + }) + }) + + describe('nestedMultiselect', () => { + it('always fails loud', async () => { + await expect(port.nestedMultiselect({ message: 'Select features', options: [] })).rejects.toThrow(HeadlessPromptError) + }) + }) +}) diff --git a/packages/sdk/helper/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts index b0a19477a0..91199d316b 100644 --- a/packages/sdk/helper/tests/project.spec.ts +++ b/packages/sdk/helper/tests/project.spec.ts @@ -704,6 +704,28 @@ describe('SdkProject and ProjectEditSession', () => { expect(committed.packageManifest().dependencies?.['@deepseek-ai/dsh-subagent']).toMatch(/^file:/) expect(createBuiltinRegistry(committed.profile).get(featureId('subagent')).inspect(committed).state).toBe('absent') }) + + it('mounts an external plugin dependency and rejects missing deps or duplicate entries', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-external-plugin-')) + temporary.push(root) + const creation = request() + const project = SdkProject.create(root, creation) + const registry = createBuiltinRegistry(project.profile) + const edit = project.edit(registry) + for (const item of creation.features) edit.installFeature(registry.get(item.id), item) + await edit.commit() + const manifestPath = join(root, 'package.json') + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { dependencies?: Record } + manifest.dependencies = { ...manifest.dependencies, 'ext-plugin': 'github:o/r#sha' } + await writeFile(manifestPath, JSON.stringify(manifest, null, 2)) + const reopened = await SdkProject.open(root) + const edit2 = reopened.edit(createBuiltinRegistry(reopened.profile)) + edit2.addExternalPlugin('ext-plugin', 'ext-plugin') + expect(() => { edit2.addExternalPlugin('ext-plugin', 'ext-plugin') }).toThrow('already exists') + expect(() => { edit2.addExternalPlugin('missing', 'not-a-dep') }).toThrow('not installed') + const commit = await edit2.commit() + expect(commit.project.cordis.entry('ext-plugin')?.name).toBe('ext-plugin') + }) }) describe('extension points', () => { diff --git a/packages/sdk/helper/tests/questions.spec.ts b/packages/sdk/helper/tests/questions.spec.ts index 463f3b979f..5eb205075f 100644 --- a/packages/sdk/helper/tests/questions.spec.ts +++ b/packages/sdk/helper/tests/questions.spec.ts @@ -450,4 +450,35 @@ describe('feature configurator', () => { await expect(new FeatureConfigurator(new QueuePromptPort([])).configure(new EmptyExclusive(), profile)) .rejects.toThrow('has no default option') }) + + it('configures fully from prefilled options, values, and secrets without prompting', async () => { + const registry = createBuiltinRegistry(profile) + const port = new QueuePromptPort([]) + const result = await new FeatureConfigurator(port).configure( + registry.get(featureId('provider')), + profile, + undefined, + ['custom'], + { apiKey: 'prefilled-key' }, + { baseURL: 'https://prefilled' }, + ) + expect(result).toMatchObject({ + options: ['custom'], + values: { baseURL: 'https://prefilled' }, + secrets: { apiKey: 'prefilled-key' }, + }) + expect(port.requests).toEqual([]) + }) + + it('rejects a non-string prefilled feature value', async () => { + const registry = createBuiltinRegistry(profile) + await expect(new FeatureConfigurator(new QueuePromptPort([])).configure( + registry.get(featureId('provider')), + profile, + undefined, + ['custom'], + { apiKey: 'k' }, + { baseURL: 123 }, + )).rejects.toThrow('must be a string') + }) }) diff --git a/packages/sdk/scripts/README.md b/packages/sdk/scripts/README.md index 2b8a170a3c..13c46b47ad 100644 --- a/packages/sdk/scripts/README.md +++ b/packages/sdk/scripts/README.md @@ -8,6 +8,7 @@ The `dsh-sdk` launcher owns SDK project startup and configuration. | `dsh-sdk dev [target] [-- args…]` | Register TypeScript and local-workspace source resolution, then use the start path | | `dsh-sdk build [args…]` | Invoke the project's installed tsdown with the project arguments | | `dsh-sdk config` | Open one interactive edit session, review accumulated changes, commit once, and install once when NPM dependencies changed | +| `dsh-sdk create ` | Add an external Cordis plugin from a native package-manager source (`pkg@version` or `github:owner/repo#ref`): confirm, ` add `, then mount the resolved dependency in `cordis.yml`. No giget/pacote; the package manager resolves and pins the source (github deps build via their own `prepare` under the manager's policy) | `ProjectBuild(tsdownConfig)` and `PluginBuild(tsdownConfig)` are exported only from `@deepseek-ai/dsh-scripts/dev/tsdown-config`. Development and production read the same `cordis.yml`. diff --git a/packages/sdk/scripts/package.json b/packages/sdk/scripts/package.json index ba441a528c..6fdbcbc3da 100644 --- a/packages/sdk/scripts/package.json +++ b/packages/sdk/scripts/package.json @@ -32,6 +32,7 @@ "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-helper": "workspace:^", + "@deepseek-ai/dsh-telemetry": "workspace:^", "commander": "^15.0.0", "node-addon-require-builtin": "^0.1.0" }, diff --git a/packages/sdk/scripts/src/args.ts b/packages/sdk/scripts/src/args.ts index 1b91269592..4d1ce867de 100644 --- a/packages/sdk/scripts/src/args.ts +++ b/packages/sdk/scripts/src/args.ts @@ -8,12 +8,13 @@ import { parseArgs as parseNodeArgs } from 'node:util' import { Command } from 'commander' /** Commands implemented by the dsh-sdk launcher. */ -type DshSdkCommand = 'start' | 'dev' | 'build' | 'config' +type DshSdkCommand = 'start' | 'dev' | 'build' | 'config' | 'create' /** Parsed dsh-sdk invocation. */ export interface DshSdkArgs { command?: DshSdkCommand target?: string + source?: string forwarded: readonly string[] help: boolean } @@ -60,6 +61,9 @@ export function parseDshSdkArgs(argv: readonly string[]): DshSdkArgs { program.command('config').helpOption(false).action(() => { parsed = { command: 'config', forwarded: [], help: false } }) + program.command('create ').helpOption(false).action((source: string) => { + parsed = { command: 'create', source, forwarded: [], help: false } + }) program.parse([...launcherArgv], { from: 'user' }) /* v8 ignore next -- every registered Commander action above assigns parsed or Commander throws */ if (!parsed) throw new Error('dsh-sdk command did not resolve') diff --git a/packages/sdk/scripts/src/command.ts b/packages/sdk/scripts/src/command.ts index 9351cfa39c..ebf9b06846 100644 --- a/packages/sdk/scripts/src/command.ts +++ b/packages/sdk/scripts/src/command.ts @@ -7,7 +7,9 @@ import { parseDshSdkArgs } from './args.ts' import { runProjectBuild } from './build.ts' import { runConfigCommand, type ConfigCommandContext } from './config.ts' +import { runCreatePluginCommand } from './create-plugin.ts' import { runSDK } from './runtime.ts' +import { reportCommandTelemetry, type CommandTelemetryEvent } from './telemetry.ts' import { DSH_SDK_TEMPLATES } from './templates/dsh-sdk-templates.ts' /** Injectable process and command boundaries used by the dsh-sdk bin. */ @@ -19,6 +21,8 @@ export interface DshSdkCommandContext extends ConfigCommandContext { run?: typeof runSDK build?: typeof runProjectBuild config?: typeof runConfigCommand + createPlugin?: typeof runCreatePluginCommand + telemetry?: (event: CommandTelemetryEvent) => Promise } /** Run one parsed dsh-sdk command and return its process exit code. */ @@ -31,28 +35,42 @@ export async function runDshSdkCommand( stderr: process.stderr, }, ): Promise { + const startedAt = Date.now() + let command: string | undefined + let success = true try { const args = parseDshSdkArgs(argv) if (args.help || !args.command) { context.stdout.write(DSH_SDK_TEMPLATES.usage.render({})) return 0 } + command = args.command const run = context.run ?? runSDK const build = context.build ?? runProjectBuild const config = context.config ?? runConfigCommand + const createPlugin = context.createPlugin ?? runCreatePluginCommand switch (args.command) { case 'start': await run(args.target, { cwd: context.cwd, argv: args.forwarded }); break case 'dev': await run(args.target, { cwd: context.cwd, dev: true, argv: args.forwarded }); break case 'build': await build(args.forwarded, context.cwd); break case 'config': { const result = await config(context) - if (result.installError) return 1 + if (result.installError) { success = false; return 1 } break } + /* v8 ignore next -- Commander requires , so create never dispatches without it */ + case 'create': await createPlugin(args.source ?? '', context); break } return 0 } catch (error) { + success = false context.stderr.write(`dsh-sdk: ${error instanceof Error ? error.message : String(error)}\n`) return 1 + } finally { + if (command !== undefined) { + /* v8 ignore next -- production telemetry wiring is exercised by the built-bin smoke */ + const telemetry = context.telemetry ?? reportCommandTelemetry + await telemetry({ command, cwd: context.cwd, durationMs: Date.now() - startedAt, success }) + } } } diff --git a/packages/sdk/scripts/src/config/config-workflow.ts b/packages/sdk/scripts/src/config/config-workflow.ts index 7d3d7ee43a..016d9d09b8 100644 --- a/packages/sdk/scripts/src/config/config-workflow.ts +++ b/packages/sdk/scripts/src/config/config-workflow.ts @@ -28,6 +28,17 @@ export interface ConfigWorkflowResult { installError?: Error } +/** + * Non-interactive desired end-state for a config run: the complete set of enabled + * features, with options and any secrets/values a newly installed feature needs. + * Features not listed are reconciled to disabled, exactly as an interactive tree + * selection would be. Custom (non-feature) cordis plugins keep their current state; + * toggling them headlessly is not yet supported. + */ +export interface ConfigPlan { + features: readonly FeatureSelection[] +} + function featureTarget(feature: Feature): string { return `feature:${feature.id}` } @@ -66,48 +77,58 @@ export class ConfigWorkflow { } /** Select desired state, reconcile the working copy, review, and apply. */ - async run(project: SdkProject, registry: FeatureRegistry): Promise { + async run(project: SdkProject, registry: FeatureRegistry, plan?: ConfigPlan): Promise { const edit = project.edit(registry) const configurator = new FeatureConfigurator(this.port) const features = registry.all().filter(feature => feature.isApplicable(project.profile)) const inspections = new Map(edit.inspections().map(item => [item.id, item])) const custom = edit.cordisConfigEntries().filter(entry => !registry.ownerOfPackage(entry.name, project.profile)) - const desired = requireAnswer(await this.port.nestedMultiselect({ - message: 'Configure the project', - showChanges: true, - options: [ - ...features.map((feature) => { - const installation = inspections.get(feature.id) - /* v8 ignore next -- inspections() is built from this exact feature registry */ - if (!installation) throw new Error(`feature inspection is missing: ${feature.id}`) - const inconsistent = installation.state === 'inconsistent' - const selectedOptions = new Set(installation.options.length > 0 - ? installation.options - : feature.defaultOptions(project.profile)) - return { - value: featureTarget(feature), - label: feature.summary, - required: feature.required, - default: feature.required || installation.state === 'enabled' || inconsistent, - disabled: inconsistent, - ...inconsistent ? { warning: installation.diagnostics.join('; ') } : {}, - ...feature.mode === 'single' ? {} : { - choiceMode: feature.mode, - choices: feature.options.map(option => ({ - value: option.id, - label: option.label, - default: selectedOptions.has(option.id), - })), - }, - } - }), - ...custom.map(entry => ({ - value: pluginTarget(entry.id), - label: `${entry.name} [custom]`, - default: !entry.disabled, + const desired = plan + ? [ + ...plan.features.map(selection => ({ + value: featureTarget(registry.get(selection.id)), + choices: selection.options, })), - ], - })) + ...custom + .filter(entry => !entry.disabled) + .map(entry => ({ value: pluginTarget(entry.id), choices: [] as readonly string[] })), + ] + : requireAnswer(await this.port.nestedMultiselect({ + message: 'Configure the project', + showChanges: true, + options: [ + ...features.map((feature) => { + const installation = inspections.get(feature.id) + /* v8 ignore next -- inspections() is built from this exact feature registry */ + if (!installation) throw new Error(`feature inspection is missing: ${feature.id}`) + const inconsistent = installation.state === 'inconsistent' + const selectedOptions = new Set(installation.options.length > 0 + ? installation.options + : feature.defaultOptions(project.profile)) + return { + value: featureTarget(feature), + label: feature.summary, + required: feature.required, + default: feature.required || installation.state === 'enabled' || inconsistent, + disabled: inconsistent, + ...inconsistent ? { warning: installation.diagnostics.join('; ') } : {}, + ...feature.mode === 'single' ? {} : { + choiceMode: feature.mode, + choices: feature.options.map(option => ({ + value: option.id, + label: option.label, + default: selectedOptions.has(option.id), + })), + }, + } + }), + ...custom.map(entry => ({ + value: pluginTarget(entry.id), + label: `${entry.name} [custom]`, + default: !entry.disabled, + })), + ], + })) const desiredByTarget = new Map(desired.map(item => [item.value, item])) const targetProfile = { ...project.profile, @@ -117,6 +138,9 @@ export class ConfigWorkflow { if (!feature.isApplicable(targetProfile)) desiredByTarget.delete(featureTarget(feature)) } + const plannedById = new Map( + (plan?.features ?? []).map(selection => [selection.id, selection]), + ) for (const feature of features) { const installation = inspections.get(feature.id) /* v8 ignore next -- inspections() is built from this exact feature registry */ @@ -124,7 +148,7 @@ export class ConfigWorkflow { if (installation.state === 'inconsistent') continue const choice = desiredByTarget.get(featureTarget(feature)) if (!choice && !feature.required) continue - await this.enableOrConfigure(feature, installation, choice, project, edit, configurator) + await this.enableOrConfigure(feature, installation, choice, project, edit, configurator, plannedById.get(feature.id)) } for (const feature of [...features].reverse()) { @@ -176,6 +200,7 @@ export class ConfigWorkflow { project: SdkProject, edit: ReturnType, configurator: FeatureConfigurator, + planned?: FeatureSelection, ): Promise { const options = choice?.choices.length ? choice.choices @@ -183,7 +208,9 @@ export class ConfigWorkflow { ? installation.options : feature.defaultOptions(project.profile) if (installation.state === 'absent') { - const selection = await configurator.configure(feature, project.profile, undefined, options) + const selection = await configurator.configure( + feature, project.profile, undefined, options, planned?.secrets ?? {}, planned?.values ?? {}, + ) edit.installFeature(feature, selection) return } @@ -191,10 +218,7 @@ export class ConfigWorkflow { if (!installation.selection) throw new Error(`feature ${feature.id} has no readable selection`) if (!sameOptions(installation.options, options)) { const selection: FeatureSelection = await configurator.configure( - feature, - project.profile, - installation.selection, - options, + feature, project.profile, installation.selection, options, planned?.secrets ?? {}, planned?.values ?? {}, ) edit.configureFeature(feature, selection) } diff --git a/packages/sdk/scripts/src/create-plugin.ts b/packages/sdk/scripts/src/create-plugin.ts new file mode 100644 index 0000000000..f658451fde --- /dev/null +++ b/packages/sdk/scripts/src/create-plugin.ts @@ -0,0 +1,91 @@ +/** + * dsh-sdk create command: add an external Cordis plugin (github or npm) as a + * native package-manager dependency and mount it in cordis.yml. + * + * @module @deepseek-ai/dsh-scripts/create-plugin + */ + +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { + ClackPromptPort, + ConfirmQuestion, + SdkProject, + createBuiltinRegistry, + requireAnswer, + type PackageManager, + type ProjectCommitResult, + type PromptPort, +} from '@deepseek-ai/dsh-helper' + +/** Process and interaction slice required by dsh-sdk create. */ +export interface CreatePluginContext { + cwd: string + stdin: NodeJS.ReadStream + stdout: NodeJS.WriteStream + port?: PromptPort + add?: (manager: PackageManager, spec: string, cwd: string) => Promise +} + +/** Result of a create run; `undefined` when the confirmation was declined. */ +export type CreatePluginResult = ProjectCommitResult | undefined + +/** Derive a stable cordis entry id from a package name's last path segment. */ +function pluginId(packageName: string): string { + const base = packageName.startsWith('@') ? packageName.slice(packageName.indexOf('/') + 1) : packageName + const id = base.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') + /* v8 ignore next -- a valid npm package name always yields a non-empty id */ + if (!id) throw new Error(`cannot derive a plugin id from package name: ${packageName}`) + return id +} + +/** Read the direct dependency names declared in a project's package.json. */ +async function dependencyNames(cwd: string): Promise> { + const manifest = JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8')) as { + dependencies?: Record + } + /* v8 ignore next -- generated projects always declare a dependencies map */ + return new Set(Object.keys(manifest.dependencies ?? {})) +} + +/** + * Add one external plugin dependency to the current project and mount it. + * @param source - a package-manager-native source (`pkg@version` or `github:owner/repo#ref`). + * @param context - process, interaction, and dependency-add boundaries. + * @returns the commit result, or `undefined` when the confirmation was declined. + */ +export async function runCreatePluginCommand( + source: string, + context: CreatePluginContext, +): Promise { + const spec = source.trim() + if (!spec) throw new Error('dsh-sdk create requires a plugin source (pkg@version or github:owner/repo#ref)') + if (!context.port && (!context.stdin.isTTY || !context.stdout.isTTY)) { + throw new Error('dsh-sdk create requires an interactive TTY') + } + const project = await SdkProject.open(context.cwd) + /* v8 ignore next -- production TTY wiring is exercised by the built-bin smoke */ + const port = context.port ?? new ClackPromptPort(context.stdin, context.stdout) + const confirmed = requireAnswer(await new ConfirmQuestion({ + id: 'create.confirm', + message: `Add plugin '${spec}' as a dependency and mount it in cordis.yml?`, + initialValue: true, + }).resolve(port)) + if (!confirmed) return undefined + + const before = await dependencyNames(context.cwd) + /* v8 ignore next -- production package-manager wiring is exercised by the built-bin smoke */ + const add = context.add ?? ((manager, source, cwd) => manager.add(source, cwd)) + await add(project.profile.packageManager, spec, context.cwd) + const after = await dependencyNames(context.cwd) + const added = [...after].filter(name => !before.has(name)) + if (added.length === 0) throw new Error(`dsh-sdk create: '${spec}' added no new dependency`) + + const reopened = await SdkProject.open(context.cwd) + const registry = createBuiltinRegistry(reopened.profile) + const edit = reopened.edit(registry) + for (const packageName of added) edit.addExternalPlugin(pluginId(packageName), packageName) + const commit = await edit.commit() + context.stdout.write(`Mounted ${added.join(', ')} in cordis.yml.\n`) + return commit +} diff --git a/packages/sdk/scripts/src/telemetry.ts b/packages/sdk/scripts/src/telemetry.ts new file mode 100644 index 0000000000..1ef74fdbb7 --- /dev/null +++ b/packages/sdk/scripts/src/telemetry.ts @@ -0,0 +1,63 @@ +/** + * Launcher-side telemetry wiring: resolve consent and send one fire-and-forget + * event around each dsh-sdk command. Best-effort — never affects the command's + * outcome or exit code. + * + * @module @deepseek-ai/dsh-scripts/telemetry + */ + +import { + ConsentResolver, + TelemetryReporter, + buildTelemetryPayload, + type ConsentDecision, +} from '@deepseek-ai/dsh-telemetry' + +/** One command's telemetry lifecycle facts. */ +export interface CommandTelemetryEvent { + /** The dsh-sdk command that ran. */ + command: string + /** Project directory whose consent, `cordis.yml`, and `package.json` are read. */ + cwd: string + /** Wall-clock duration in milliseconds. */ + durationMs: number + /** Whether the command completed without error. */ + success: boolean +} + +/** Injectable consent and delivery seams for tests. */ +export interface CommandTelemetryDeps { + resolve?: (cwd: string) => Promise + reporter?: Pick +} + +/** + * Resolve consent for the project and, when allowed, assemble and send one + * telemetry event, draining in-flight sends before returning. Swallows every + * error so telemetry can never change a command's result. + * @param event - the command lifecycle facts. + * @param deps - consent and delivery seams; defaults hit the real endpoint. + */ +export async function reportCommandTelemetry( + event: CommandTelemetryEvent, + deps: CommandTelemetryDeps = {}, +): Promise { + try { + /* v8 ignore next -- the production ConsentResolver is exercised by the built-bin smoke */ + const resolve = deps.resolve ?? (cwd => new ConsentResolver().resolve(cwd)) + const consent = await resolve(event.cwd) + if (!consent.allowed) return + const payload = await buildTelemetryPayload({ + command: event.command, + durationMs: event.durationMs, + success: event.success, + projectDir: event.cwd, + }) + /* v8 ignore next -- the production TelemetryReporter is exercised by the built-bin smoke */ + const reporter = deps.reporter ?? new TelemetryReporter() + reporter.report(payload, consent) + await reporter.flush() + } catch { + // Telemetry is best-effort; a consent, payload, or delivery fault never reaches the command. + } +} diff --git a/packages/sdk/scripts/src/templates/assets/usage.txt.tpl b/packages/sdk/scripts/src/templates/assets/usage.txt.tpl index d4122198f5..b372c65d17 100644 --- a/packages/sdk/scripts/src/templates/assets/usage.txt.tpl +++ b/packages/sdk/scripts/src/templates/assets/usage.txt.tpl @@ -5,3 +5,4 @@ Commands: dev [target] [-- args...] Start with TypeScript and local-plugin source resolution build [args...] Run the project's installed tsdown config Interactively edit project features + create Add an external plugin dependency (pkg@version or github:owner/repo#ref) and mount it in cordis.yml diff --git a/packages/sdk/scripts/tests/scripts.spec.ts b/packages/sdk/scripts/tests/scripts.spec.ts index 8d110799ea..8b887d74db 100644 --- a/packages/sdk/scripts/tests/scripts.spec.ts +++ b/packages/sdk/scripts/tests/scripts.spec.ts @@ -5,6 +5,7 @@ import { PassThrough, Writable } from 'node:stream' import { fileURLToPath, pathToFileURL } from 'node:url' import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' import { + HeadlessPromptPort, LocalPluginBlueprint, NpmPackageManager, SdkProject, @@ -29,7 +30,9 @@ import { parseDshSdkArgs, parseSdkBootArgs } from '../src/args.ts' import { PluginBuild, ProjectBuild, runProjectBuild } from '../src/build.ts' import { runDshSdkCommand, type DshSdkCommandContext } from '../src/command.ts' import { runConfigCommand } from '../src/config.ts' -import { ConfigWorkflow } from '../src/config/config-workflow.ts' +import { ConfigWorkflow, type ConfigPlan } from '../src/config/config-workflow.ts' +import { runCreatePluginCommand } from '../src/create-plugin.ts' +import { reportCommandTelemetry, type CommandTelemetryEvent } from '../src/telemetry.ts' import { initialize, resolve as resolveLocalPlugin } from '../src/local-plugin-loader-hooks.ts' const temporary: string[] = [] @@ -165,6 +168,7 @@ describe('Commander launcher arguments', () => { await expect(runDshSdkCommand(['unknown'], context)).resolves.toBe(1) await expect(runDshSdkCommand([], context)).resolves.toBe(0) expect(context.readStdout()).toContain('Usage: dsh-sdk') + expect(context.readStdout()).toContain('create ') const defaults = commandContext(root) await writeFile(join(root, 'main.mjs'), 'export function main() { return "ok" }\n') @@ -399,6 +403,28 @@ describe('ConfigWorkflow', () => { expect(output.read()).toContain('Disable feature: todo') }) + it('reconciles a headless plan without prompting and preserves custom plugins', async () => { + const project = await committedProject([], [new LocalPluginBlueprint('plugin', 'plugin')]) + const registry = createBuiltinRegistry(project.profile) + const output = outputBuffer() + let installs = 0 + const plan: ConfigPlan = { + features: [ + { id: featureId('bash'), options: ['local'] }, + { id: featureId('persistence'), options: ['jsonl'] }, + { id: featureId('todo'), options: ['default'] }, + { id: featureId('web'), options: ['exa'], secrets: { apiKey: 'exa-key' } }, + ], + } + const result = await new ConfigWorkflow( + new HeadlessPromptPort(), output.stream, async () => { installs += 1 }, + ).run(project, registry, plan) + expect(result.commit?.project.cordis.entry('tool-todo')).toBeDefined() + // the unlisted custom local plugin keeps its enabled state (not nuked by the plan) + expect(result.commit?.project.cordis.entry('plugin')?.disabled).toBeFalsy() + expect(installs).toBe(1) + }) + it('installs once after NPM dependency changes and keeps committed files on install failure', async () => { const project = await committedProject() const registry = createBuiltinRegistry(project.profile) @@ -536,3 +562,108 @@ describe('ConfigWorkflow', () => { expect(output.read()).toContain('Disable feature: ask-user') }) }) + +describe('dsh-sdk create', () => { + const writeDependency = (name: string) => async (_m: unknown, spec: string, cwd: string): Promise => { + const path = join(cwd, 'package.json') + const manifest = JSON.parse(await readFile(path, 'utf8')) as { dependencies?: Record } + manifest.dependencies = { ...manifest.dependencies, [name]: spec } + await writeFile(path, JSON.stringify(manifest, null, 2)) + } + + it('adds a dependency and mounts it after confirmation', async () => { + const project = await committedProject() + const context = { ...commandContext(project.root), port: new QueuePort([true]), add: writeDependency('my-ext-plugin') } + const result = await runCreatePluginCommand('github:o/r#sha', context) + expect(result?.project.cordis.entry('my-ext-plugin')?.name).toBe('my-ext-plugin') + expect(context.readStdout()).toContain('Mounted my-ext-plugin') + }) + + it('derives the cordis id from a scoped package name', async () => { + const project = await committedProject() + const context = { ...commandContext(project.root), port: new QueuePort([true]), add: writeDependency('@acme/cool-plugin') } + const result = await runCreatePluginCommand('@acme/cool-plugin@1.0.0', context) + expect(result?.project.cordis.entry('cool-plugin')?.name).toBe('@acme/cool-plugin') + }) + + it('returns undefined and adds nothing when declined', async () => { + const project = await committedProject() + let added = false + const context = { + ...commandContext(project.root), + port: new QueuePort([false]), + add: async () => { added = true }, + } + await expect(runCreatePluginCommand('pkg@1.0.0', context)).resolves.toBeUndefined() + expect(added).toBe(false) + }) + + it('rejects an empty source, a non-TTY session, and a no-op add', async () => { + const project = await committedProject() + await expect(runCreatePluginCommand(' ', { ...commandContext(project.root), port: new QueuePort([]) })) + .rejects.toThrow('requires a plugin source') + const noTty = commandContext(project.root) + noTty.stdin.isTTY = false + noTty.stdout.isTTY = false + await expect(runCreatePluginCommand('pkg@1.0.0', noTty)).rejects.toThrow('interactive TTY') + const noOutTty = commandContext(project.root) + noOutTty.stdout.isTTY = false + await expect(runCreatePluginCommand('pkg@1.0.0', noOutTty)).rejects.toThrow('interactive TTY') + await expect(runCreatePluginCommand('pkg@1.0.0', { + ...commandContext(project.root), port: new QueuePort([true]), add: async () => {}, + })).rejects.toThrow('added no new dependency') + }) + + it('dispatches create through the launcher', async () => { + const project = await committedProject() + const context = commandContext(project.root) + context.createPlugin = async () => undefined + await expect(runDshSdkCommand(['create', 'pkg@1.0.0'], context)).resolves.toBe(0) + }) +}) + +describe('command telemetry', () => { + it('reports when consent allows and skips when denied or faulting', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-telemetry-')) + temporary.push(dir) + const sent: unknown[] = [] + const reporter = { report: () => { sent.push(1) }, flush: async () => {} } + await reportCommandTelemetry( + { command: 'build', cwd: dir, durationMs: 5, success: true }, + { resolve: async () => ({ allowed: true, reason: 'absent' }), reporter }, + ) + expect(sent).toHaveLength(1) + await reportCommandTelemetry( + { command: 'build', cwd: dir, durationMs: 5, success: true }, + { resolve: async () => ({ allowed: false, reason: 'disabled' }), reporter }, + ) + expect(sent).toHaveLength(1) + await expect(reportCommandTelemetry( + { command: 'build', cwd: dir, durationMs: 5, success: true }, + { resolve: async () => { throw new Error('boom') }, reporter }, + )).resolves.toBeUndefined() + expect(sent).toHaveLength(1) + }) + + it('emits a telemetry event carrying each command outcome', async () => { + const project = await committedProject() + const events: CommandTelemetryEvent[] = [] + const context = commandContext(project.root) + context.telemetry = async (event) => { events.push(event) } + context.build = async () => {} + await expect(runDshSdkCommand(['build'], context)).resolves.toBe(0) + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ command: 'build', cwd: project.root, success: true }) + + await runDshSdkCommand([], context) + expect(events).toHaveLength(1) + + context.build = async () => { throw new Error('boom') } + await expect(runDshSdkCommand(['build'], context)).resolves.toBe(1) + expect(events[1]).toMatchObject({ command: 'build', success: false }) + + context.config = async () => ({ installError: new Error('offline') }) + await expect(runDshSdkCommand(['config'], context)).resolves.toBe(1) + expect(events.at(-1)).toMatchObject({ command: 'config', success: false }) + }) +}) diff --git a/packages/sdk/scripts/tsconfig.json b/packages/sdk/scripts/tsconfig.json index 848de9a314..461c86c06d 100644 --- a/packages/sdk/scripts/tsconfig.json +++ b/packages/sdk/scripts/tsconfig.json @@ -7,6 +7,7 @@ "include": ["src"], "references": [ { "path": "../helper" }, + { "path": "../telemetry" }, { "path": "../../ui/app-boot" }, { "path": "../../../vendor/cordis" } ] diff --git a/packages/sdk/telemetry/README.md b/packages/sdk/telemetry/README.md new file mode 100644 index 0000000000..01a2ee6c2d --- /dev/null +++ b/packages/sdk/telemetry/README.md @@ -0,0 +1,28 @@ +# `@deepseek-ai/dsh-telemetry` + +Launcher-side telemetry primitives for the dsh-sdk toolchain. This is a plain library the launcher imports around each command; it is **not** a Cordis plugin, because `build` and first-init `create` never boot Cordis. Wiring the reporter into the launcher command dispatch and adding the telemetry consent feature to the `dsh-helper` catalog live in their owning packages, not here. + +| Export | Role | +|---|---| +| `SecretRedactor` | Conservative safety backstop: replaces secret-shaped values (secret-like keys, known token shapes, PEM blocks, URL credentials, high-entropy opaque tokens) with a placeholder in both parsed values (`redactValue`) and raw text (`redactText`). Never drops a field or line. | +| `ConsentResolver` | Parses (never boots) a project `cordis.yml` and reads the telemetry entry's enabled/disabled state as consent; `DO_NOT_TRACK`/CI env force a hard opt-out. | +| `buildTelemetryPayload` | Assembles `{command, durationMs, success, cordisYmlContent, packageJsonContent}`, running the redactor over the full `cordis.yml` and `package.json` text. Never reads `.env`; `package.json` ships only alongside a `cordis.yml`, so a command run in a non-SDK directory never uploads that directory's unrelated manifest. | +| `getOrCreateAnonymousId` | Random UUID persisted in a per-user GLOBAL config file (never in the project, never derived from git). | +| `TelemetryReporter` | Fire-and-forget send: `report()` never blocks or throws; delivery resolves on every path; `flush()` optionally drains in-flight sends within a cap. | + +Consent is carried by the telemetry entry in `cordis.yml`, so disabling telemetry is disabling that entry. Telemetry reports by default and is off only when a present telemetry entry is explicitly `disabled`: a missing `cordis.yml` (first `create`), an enabled entry, or a `cordis.yml` with no telemetry entry all report. `DO_NOT_TRACK`/CI always deny. The no-config and absent-entry defaults are configurable on `ConsentResolver`. + +The collection endpoint is a fixed constant (`DSH_TELEMETRY_ENDPOINT`); its `.invalid` placeholder must be replaced with the real endpoint before release. + +## Model Experience + +None, as the reporter sends developer-cycle telemetry from the launcher and never reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Placeholder endpoint** — `DSH_TELEMETRY_ENDPOINT` points at `.invalid` until the real endpoint is set. +- **Redaction is heuristic** — a conservative backstop, not a guarantee; secrets belong in `.env`, which is never read or reported. diff --git a/packages/sdk/telemetry/package.json b/packages/sdk/telemetry/package.json new file mode 100644 index 0000000000..fcb6efb797 --- /dev/null +++ b/packages/sdk/telemetry/package.json @@ -0,0 +1,35 @@ +{ + "name": "@deepseek-ai/dsh-telemetry", + "description": "Launcher-side dsh-sdk telemetry: secret redaction, consent resolution, anonymous id, payload builder, and fire-and-forget reporter", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "yaml": "^2.9.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/sdk/telemetry/src/anonymous-id.ts b/packages/sdk/telemetry/src/anonymous-id.ts new file mode 100644 index 0000000000..030fefa19f --- /dev/null +++ b/packages/sdk/telemetry/src/anonymous-id.ts @@ -0,0 +1,106 @@ +/** + * Per-machine anonymous telemetry id. + * + * The id is a random UUID persisted in a per-user GLOBAL config file — never in + * the project, and never derived from the git remote, repository URL, or any + * other identifying source (a derived id would make "anonymous" a fiction). The + * same id is reused across projects on one machine so telemetry counts machines, + * not repositories. + * + * @module @deepseek-ai/dsh-telemetry/anonymous-id + */ + +import { randomUUID } from 'node:crypto' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** A machine-scoped anonymous telemetry id (random UUID v4). */ +export type AnonymousId = Branded<'AnonymousId'> + +/** Config directory name owned by the DeepSeek Harness across tools. */ +const CONFIG_NAMESPACE = 'deepseek-harness' + +/** Default file, inside the global config dir, storing the anonymous id. */ +export const ANONYMOUS_ID_FILE_NAME = 'telemetry.json' + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +/** Ambient seams for locating and generating the id; every field has a default. */ +export interface AnonymousIdOptions { + /** Environment consulted for `DSH_CONFIG_HOME`/`XDG_CONFIG_HOME`/`APPDATA`; defaults to `process.env`. */ + env?: NodeJS.ProcessEnv + /** Platform string used to pick the Windows path; defaults to `process.platform`. */ + platform?: NodeJS.Platform + /** Home directory resolver; defaults to `os.homedir`. */ + homeDir?: () => string + /** UUID generator; defaults to `crypto.randomUUID` (test seam). */ + randomUUID?: () => string +} + +/** + * Resolve the per-user global config directory for harness tooling. + * Precedence: `DSH_CONFIG_HOME` (explicit override) > `XDG_CONFIG_HOME` > + * platform default (`%APPDATA%` on Windows, else `~/.config`). + * @param options - environment, platform, and home-directory seams. + * @returns absolute config directory path for the harness namespace. + */ +export function globalConfigDir(options: AnonymousIdOptions = {}): string { + const env = options.env ?? process.env + const platform = options.platform ?? process.platform + const home = options.homeDir ?? homedir + if (env.DSH_CONFIG_HOME !== undefined && env.DSH_CONFIG_HOME.length > 0) return env.DSH_CONFIG_HOME + if (env.XDG_CONFIG_HOME !== undefined && env.XDG_CONFIG_HOME.length > 0) { + return join(env.XDG_CONFIG_HOME, CONFIG_NAMESPACE) + } + if (platform === 'win32' && env.APPDATA !== undefined && env.APPDATA.length > 0) { + return join(env.APPDATA, CONFIG_NAMESPACE) + } + return join(home(), '.config', CONFIG_NAMESPACE) +} + +/** Read a valid persisted id from the store, or `undefined` when absent/corrupt. */ +async function readPersistedId(file: string): Promise { + let text: string + try { + text = await readFile(file, 'utf8') + } catch { + // Absent or unreadable: the caller mints and persists a fresh id. + return undefined + } + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + // Corrupt JSON: the caller overwrites the store with a fresh id. + return undefined + } + if (parsed !== null && typeof parsed === 'object') { + const value = (parsed as Record).anonymousId + if (typeof value === 'string' && UUID_PATTERN.test(value)) return value as AnonymousId + } + return undefined +} + +/** + * Return the machine's anonymous id, creating and persisting one on first use. + * Persistence is best-effort: a write failure still returns a usable id for the + * current run so telemetry is never blocked by config-dir permissions. + * @param options - config-location and UUID-generation seams. + * @returns the stable per-machine anonymous id. + */ +export async function getOrCreateAnonymousId(options: AnonymousIdOptions = {}): Promise { + const file = join(globalConfigDir(options), ANONYMOUS_ID_FILE_NAME) + const existing = await readPersistedId(file) + if (existing !== undefined) return existing + const generate = options.randomUUID ?? randomUUID + const created = generate() as AnonymousId + try { + await mkdir(dirname(file), { recursive: true }) + await writeFile(file, `${JSON.stringify({ anonymousId: created }, null, 2)}\n`, 'utf8') + } catch { + // Best-effort persistence: return the fresh id even when the store is unwritable. + } + return created +} diff --git a/packages/sdk/telemetry/src/consent-resolver.ts b/packages/sdk/telemetry/src/consent-resolver.ts new file mode 100644 index 0000000000..a4327dc9f7 --- /dev/null +++ b/packages/sdk/telemetry/src/consent-resolver.ts @@ -0,0 +1,125 @@ +/** + * Consent resolution for dsh-sdk telemetry. + * + * Telemetry is OFF only when `cordis.yml` contains a telemetry entry that is + * explicitly `disabled`; every other file state reports (no `cordis.yml`, an + * enabled entry, or no telemetry entry at all). The resolver PARSES `cordis.yml` + * — it never boots a Cordis application — because several launcher commands + * (`build`, `create`) never boot Cordis at all. `DO_NOT_TRACK` and CI + * environment signals force a denial regardless of file state. + * + * @module @deepseek-ai/dsh-telemetry/consent-resolver + */ + +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { parseDocument, type ScalarTag } from 'yaml' + +/** Default `cordis.yml` entry name that carries telemetry consent. */ +export const DEFAULT_TELEMETRY_PLUGIN_NAME = '@deepseek-ai/dsh-telemetry' + +/** + * Passthrough for Cordis' `!!js` expression tag so parsing consent never fails + * on projects that inline JavaScript expressions; the resolver only reads plain + * `name`/`disabled` scalars and does not evaluate expressions. + */ +const JS_EXPRESSION_TAG: ScalarTag = { + tag: 'tag:yaml.org,2002:js', + resolve: value => value, +} + +/** Why telemetry is or is not permitted for one command. */ +export type ConsentReason = + | 'enabled' + | 'disabled' + | 'absent' + | 'no-config' + | 'do-not-track' + | 'ci' + | 'unreadable' + +/** Resolved telemetry consent for one command invocation. */ +export interface ConsentDecision { + /** Whether telemetry may be sent. */ + allowed: boolean + /** The signal that determined {@link allowed}. */ + reason: ConsentReason +} + +/** Tuning for {@link ConsentResolver}; every field defaults to a documented value. */ +export interface ConsentResolverOptions { + /** `cordis.yml` entry name whose enabled state carries consent. */ + telemetryPluginName?: string + /** Environment used for `DO_NOT_TRACK`/CI checks; defaults to `process.env`. */ + env?: NodeJS.ProcessEnv + /** Honor `DO_NOT_TRACK`/CI env signals as a hard opt-out. Defaults to `true`. */ + honorEnvOptOut?: boolean + /** Consent when `cordis.yml` does not exist yet (first `create`). Defaults to `true` (telemetry is default-on). */ + allowWhenNoConfig?: boolean + /** Consent when `cordis.yml` exists but has no telemetry entry. Defaults to `true` (report unless a present entry is disabled). */ + allowWhenEntryAbsent?: boolean +} + +/** Whether an environment variable is set to a non-empty, non-"0"/"false" value. */ +function envEnabled(value: string | undefined): boolean { + if (value === undefined) return false + const normalized = value.trim().toLowerCase() + return normalized.length > 0 && normalized !== '0' && normalized !== 'false' +} + +/** Read a `cordis.yml` entry's `name`/`disabled` scalars, tolerating `!!js` tags. */ +function readTelemetryEntry(text: string, pluginName: string): { present: boolean; disabled: boolean } { + const document = parseDocument(text, { customTags: [JS_EXPRESSION_TAG] }) + const contents: unknown = document.toJS({ maxAliasCount: -1 }) + if (!Array.isArray(contents)) return { present: false, disabled: false } + for (const entry of contents) { + if (entry === null || typeof entry !== 'object') continue + const record = entry as Record + if (record.name === pluginName) return { present: true, disabled: record.disabled === true } + } + return { present: false, disabled: false } +} + +/** Resolve telemetry consent by parsing a project's `cordis.yml` and the environment. */ +export class ConsentResolver { + readonly #pluginName: string + readonly #env: NodeJS.ProcessEnv + readonly #honorEnvOptOut: boolean + readonly #allowWhenNoConfig: boolean + readonly #allowWhenEntryAbsent: boolean + + /** @param options - plugin name, environment, and default-decision knobs. */ + constructor(options: ConsentResolverOptions = {}) { + this.#pluginName = options.telemetryPluginName ?? DEFAULT_TELEMETRY_PLUGIN_NAME + this.#env = options.env ?? process.env + this.#honorEnvOptOut = options.honorEnvOptOut ?? true + this.#allowWhenNoConfig = options.allowWhenNoConfig ?? true + this.#allowWhenEntryAbsent = options.allowWhenEntryAbsent ?? true + } + + /** + * Resolve consent for a command run in the given project directory. + * @param projectDir - absolute or relative project root containing `cordis.yml`. + * @returns the consent decision and the signal that produced it. + */ + async resolve(projectDir: string): Promise { + if (this.#honorEnvOptOut) { + if (envEnabled(this.#env.DO_NOT_TRACK)) return { allowed: false, reason: 'do-not-track' } + if (envEnabled(this.#env.CI)) return { allowed: false, reason: 'ci' } + } + let text: string + try { + text = await readFile(join(projectDir, 'cordis.yml'), 'utf8') + } catch (error) { + // Missing cordis.yml is the first-init (`create`) path; any other read + // fault is treated conservatively as its own reason. + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { allowed: this.#allowWhenNoConfig, reason: 'no-config' } + } + return { allowed: false, reason: 'unreadable' } + } + const entry = readTelemetryEntry(text, this.#pluginName) + if (!entry.present) return { allowed: this.#allowWhenEntryAbsent, reason: 'absent' } + return entry.disabled ? { allowed: false, reason: 'disabled' } : { allowed: true, reason: 'enabled' } + } +} diff --git a/packages/sdk/telemetry/src/index.ts b/packages/sdk/telemetry/src/index.ts new file mode 100644 index 0000000000..107956fa39 --- /dev/null +++ b/packages/sdk/telemetry/src/index.ts @@ -0,0 +1,45 @@ +/** + * Launcher-side telemetry for the dsh-sdk toolchain: secret redaction, consent + * resolution, anonymous id, payload assembly, and a fire-and-forget reporter. + * + * This package is a plain library the launcher imports around each command — it + * is NOT a Cordis plugin (several commands never boot Cordis). Wiring it into + * the launcher command dispatch and the helper feature catalog lives outside + * this package. + * + * @module @deepseek-ai/dsh-telemetry + */ + +export { + DEFAULT_ENTROPY_THRESHOLD, + DEFAULT_MIN_TOKEN_LENGTH, + DEFAULT_REDACTION_PLACEHOLDER, + SecretRedactor, + keyLooksSecret, +} from './secret-redactor.ts' +export type { SecretRedactorOptions } from './secret-redactor.ts' +export { + ConsentResolver, + DEFAULT_TELEMETRY_PLUGIN_NAME, +} from './consent-resolver.ts' +export type { + ConsentDecision, + ConsentReason, + ConsentResolverOptions, +} from './consent-resolver.ts' +export { + ANONYMOUS_ID_FILE_NAME, + getOrCreateAnonymousId, + globalConfigDir, +} from './anonymous-id.ts' +export type { AnonymousId, AnonymousIdOptions } from './anonymous-id.ts' +export { buildTelemetryPayload } from './payload.ts' +export type { BuildTelemetryPayloadInput, TelemetryPayload } from './payload.ts' +export { + DEFAULT_FLUSH_TIMEOUT_MS, + DEFAULT_SEND_TIMEOUT_MS, + DSH_TELEMETRY_ENDPOINT, + TELEMETRY_SCHEMA_VERSION, + TelemetryReporter, +} from './reporter.ts' +export type { DeliveryOutcome, TelemetryReporterOptions } from './reporter.ts' diff --git a/packages/sdk/telemetry/src/payload.ts b/packages/sdk/telemetry/src/payload.ts new file mode 100644 index 0000000000..505cedb872 --- /dev/null +++ b/packages/sdk/telemetry/src/payload.ts @@ -0,0 +1,82 @@ +/** + * Telemetry payload assembly. + * + * The payload carries the command lifecycle plus the FULL redacted content of + * the project `cordis.yml` and `package.json`. It NEVER reads or includes `.env` + * — secrets live only in `.env`, and the redactor is the backstop for any that + * leak into the two reported files. A file that does not exist (the first + * `create` run) simply omits its field, and `package.json` ships only when + * `cordis.yml` is present: without it the directory is not an SDK project, and + * its manifest belongs to whatever unrelated project the command ran in. + * + * @module @deepseek-ai/dsh-telemetry/payload + */ + +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { SecretRedactor } from './secret-redactor.ts' + +/** Project files whose full (redacted) content ships with the payload. */ +const REPORTED_FILES = ['cordis.yml', 'package.json'] as const + +/** One command's telemetry payload. */ +export interface TelemetryPayload { + /** The dsh-sdk command that ran (`start`/`dev`/`build`/`config`/`create`). */ + command: string + /** Wall-clock duration of the command in milliseconds. */ + durationMs: number + /** Whether the command completed without error. */ + success: boolean + /** Redacted full text of the project `cordis.yml`, absent when the file does not exist. */ + cordisYmlContent?: string + /** Redacted full text of the project `package.json`, absent when it or `cordis.yml` does not exist. */ + packageJsonContent?: string +} + +/** Inputs for {@link buildTelemetryPayload}. */ +export interface BuildTelemetryPayloadInput { + /** The dsh-sdk command that ran. */ + command: string + /** Wall-clock duration of the command in milliseconds. */ + durationMs: number + /** Whether the command completed without error. */ + success: boolean + /** Project root whose `cordis.yml` and `package.json` are read. */ + projectDir: string + /** Redactor applied to reported file content; defaults to a fresh {@link SecretRedactor}. */ + redactor?: SecretRedactor +} + +/** Read a project file's text, returning `undefined` when it cannot be read. */ +async function readReportedFile(projectDir: string, name: string): Promise { + try { + return await readFile(join(projectDir, name), 'utf8') + } catch { + // Missing/unreadable reported file: telemetry omits the field rather than fail. + return undefined + } +} + +/** + * Assemble a redacted telemetry payload for one command invocation. + * @param input - command lifecycle facts, project directory, and optional redactor. + * @returns the payload with redacted `cordis.yml`/`package.json` content. + */ +export async function buildTelemetryPayload(input: BuildTelemetryPayloadInput): Promise { + const redactor = input.redactor ?? new SecretRedactor() + const [cordisYml, packageJson] = await Promise.all( + REPORTED_FILES.map(name => readReportedFile(input.projectDir, name)), + ) + return { + command: input.command, + durationMs: input.durationMs, + success: input.success, + ...cordisYml !== undefined ? { cordisYmlContent: redactor.redactText(cordisYml) } : {}, + // package.json is an SDK-project manifest only alongside cordis.yml; a + // command run in an arbitrary directory must not upload that directory's + // unrelated manifest. + ...cordisYml !== undefined && packageJson !== undefined + ? { packageJsonContent: redactor.redactText(packageJson) } + : {}, + } +} diff --git a/packages/sdk/telemetry/src/reporter.ts b/packages/sdk/telemetry/src/reporter.ts new file mode 100644 index 0000000000..d41c1db9b7 --- /dev/null +++ b/packages/sdk/telemetry/src/reporter.ts @@ -0,0 +1,149 @@ +/** + * Fire-and-forget telemetry reporter for the dsh-sdk launcher. + * + * The reporter must NEVER block or crash a command: {@link TelemetryReporter.report} + * schedules a detached send and returns immediately, and the underlying delivery + * resolves on every path (consent skip, network failure, non-OK status) instead + * of rejecting. {@link TelemetryReporter.flush} lets the launcher optionally + * drain in-flight sends within a cap before exit. + * + * @module @deepseek-ai/dsh-telemetry/reporter + */ + +import type { ConsentDecision } from './consent-resolver.ts' +import type { TelemetryPayload } from './payload.ts' +import { getOrCreateAnonymousId, type AnonymousId } from './anonymous-id.ts' +import { SecretRedactor } from './secret-redactor.ts' + +/** + * Placeholder collection endpoint. This is a fixed protocol constant, not a + * deployment tunable. + * + * FIXME(ccyu): replace with the real telemetry endpoint before release. The + * `.invalid` TLD guarantees delivery fails harmlessly until then. + */ +export const DSH_TELEMETRY_ENDPOINT = 'https://telemetry.example.invalid/v1/dsh-sdk' + +/** Wire-envelope schema version; bump on any incompatible body change. */ +export const TELEMETRY_SCHEMA_VERSION = 1 + +/** Default per-request send timeout in milliseconds. */ +export const DEFAULT_SEND_TIMEOUT_MS = 3000 + +/** Default cap for {@link TelemetryReporter.flush} in milliseconds. */ +export const DEFAULT_FLUSH_TIMEOUT_MS = 2000 + +/** Outcome of one delivery attempt; delivery never rejects. */ +export type DeliveryOutcome = + | { status: 'skipped'; reason: string } + | { status: 'sent' } + | { status: 'failed'; error: string } + +/** The JSON body posted to the telemetry endpoint. */ +interface TelemetryEnvelope extends TelemetryPayload { + schemaVersion: number + anonymousId: AnonymousId + sentAt: string +} + +/** Injectable seams for {@link TelemetryReporter}; every field has a default. */ +export interface TelemetryReporterOptions { + /** Collection endpoint; defaults to {@link DSH_TELEMETRY_ENDPOINT}. */ + endpoint?: string + /** `fetch` implementation; defaults to the global `fetch`. */ + fetch?: typeof globalThis.fetch + /** Anonymous-id provider; defaults to {@link getOrCreateAnonymousId}. */ + anonymousId?: () => Promise + /** Redactor applied to the assembled envelope as a final backstop; defaults to a fresh {@link SecretRedactor}. */ + redactor?: SecretRedactor + /** Per-request send timeout in milliseconds. */ + timeoutMs?: number + /** Clock for the envelope timestamp; defaults to `Date.now`. */ + now?: () => number +} + +/** Sends telemetry payloads fire-and-forget, swallowing every failure. */ +export class TelemetryReporter { + readonly #endpoint: string + readonly #fetch: typeof globalThis.fetch + readonly #anonymousId: () => Promise + readonly #redactor: SecretRedactor + readonly #timeoutMs: number + readonly #now: () => number + readonly #inflight = new Set>() + + /** @param options - endpoint, transport, id provider, and timing seams. */ + constructor(options: TelemetryReporterOptions = {}) { + this.#endpoint = options.endpoint ?? DSH_TELEMETRY_ENDPOINT + this.#fetch = options.fetch ?? globalThis.fetch + this.#anonymousId = options.anonymousId ?? getOrCreateAnonymousId + this.#redactor = options.redactor ?? new SecretRedactor() + this.#timeoutMs = options.timeoutMs ?? DEFAULT_SEND_TIMEOUT_MS + this.#now = options.now ?? Date.now + } + + /** + * Schedule a detached, non-blocking send. Returns immediately and never + * throws; the send's outcome is observable only through {@link flush}. + * @param payload - the command payload to report. + * @param consent - resolved consent; a denial short-circuits to a skip. + */ + report(payload: TelemetryPayload, consent: ConsentDecision): void { + const pending = this.#deliver(payload, consent) + this.#inflight.add(pending) + void pending.finally(() => this.#inflight.delete(pending)) + } + + /** + * Await in-flight sends up to a timeout so a caller can drain before exit. + * Resolves on the cap regardless of send progress; never rejects. + * @param timeoutMs - maximum time to wait; defaults to {@link DEFAULT_FLUSH_TIMEOUT_MS}. + */ + async flush(timeoutMs: number = DEFAULT_FLUSH_TIMEOUT_MS): Promise { + if (this.#inflight.size === 0) return + const drained = Promise.allSettled([...this.#inflight]).then(() => undefined) + let timer!: ReturnType + const capped = new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs) + }) + try { + await Promise.race([drained, capped]) + } finally { + clearTimeout(timer) + } + } + + /** Deliver one payload, resolving to an outcome on every path (never rejects). */ + async #deliver(payload: TelemetryPayload, consent: ConsentDecision): Promise { + if (!consent.allowed) return { status: 'skipped', reason: consent.reason } + try { + const envelope: TelemetryEnvelope = { + schemaVersion: TELEMETRY_SCHEMA_VERSION, + anonymousId: await this.#anonymousId(), + sentAt: new Date(this.#now()).toISOString(), + ...payload, + // Idempotent backstop over the only free-form fields, in case a caller + // built the payload without buildTelemetryPayload. Applied to content + // text only so the anonymous id and metadata are never disturbed. + ...payload.cordisYmlContent !== undefined + ? { cordisYmlContent: this.#redactor.redactText(payload.cordisYmlContent) } + : {}, + ...payload.packageJsonContent !== undefined + ? { packageJsonContent: this.#redactor.redactText(payload.packageJsonContent) } + : {}, + } + const response = await this.#fetch(this.#endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(envelope), + signal: AbortSignal.timeout(this.#timeoutMs), + }) + if (!response.ok) return { status: 'failed', error: `HTTP ${response.status}` } + return { status: 'sent' } + } catch (error) { + // Telemetry is best-effort: network faults, aborts, and id/redaction + // errors are swallowed so the command is never affected. + return { status: 'failed', error: error instanceof Error ? error.message : String(error) } + } + } +} diff --git a/packages/sdk/telemetry/src/secret-redactor.ts b/packages/sdk/telemetry/src/secret-redactor.ts new file mode 100644 index 0000000000..087ba2284a --- /dev/null +++ b/packages/sdk/telemetry/src/secret-redactor.ts @@ -0,0 +1,208 @@ +/** + * Conservative secret redactor: the safety backstop that scrubs credential-like + * values from telemetry content before it leaves the machine. + * + * The redactor never drops a field or line — it only replaces the secret-shaped + * VALUE with a fixed placeholder, so the surrounding structure (keys, package + * names, base URLs, dependency pins) stays intact for the maintainer. It leans + * toward redaction on strong signals (secret-like key names, known token + * shapes, PEM blocks, URL credentials, high-entropy opaque tokens) while + * deliberately leaving low-signal values (package names, versions, git SHAs, + * plain URLs, kebab identifiers) untouched, because those are exactly the + * signal telemetry exists to capture. + * + * @module @deepseek-ai/dsh-telemetry/secret-redactor + */ + +/** Default text substituted for a detected secret. */ +export const DEFAULT_REDACTION_PLACEHOLDER = '[REDACTED]' + +/** Default minimum length for the high-entropy opaque-token heuristic. */ +export const DEFAULT_MIN_TOKEN_LENGTH = 24 + +/** Default Shannon-entropy threshold (bits/char) that marks an opaque token secret. */ +export const DEFAULT_ENTROPY_THRESHOLD = 4 + +/** Tuning for {@link SecretRedactor}; every field defaults to a documented constant. */ +export interface SecretRedactorOptions { + /** Replacement text for a detected secret. */ + placeholder?: string + /** Minimum length before the high-entropy heuristic considers an opaque token. */ + minTokenLength?: number + /** Shannon entropy (bits/char) at or above which an opaque token is treated as secret. */ + entropyThreshold?: number +} + +/** + * Regexes for well-known credential shapes. A match anywhere in a candidate + * token marks it secret regardless of length, so short-but-recognizable tokens + * are caught even when the entropy heuristic would not fire. + */ +const KNOWN_SECRET_PATTERNS: readonly RegExp[] = [ + /sk-(?:ant-)?[A-Za-z0-9_-]{10,}/, // OpenAI / DeepSeek / Anthropic style + /gh[pousr]_[A-Za-z0-9]{16,}/, // GitHub personal/oauth/server/refresh tokens + /github_pat_[A-Za-z0-9_]{20,}/, // GitHub fine-grained PAT + /xox[baprs]-[A-Za-z0-9-]{10,}/, // Slack tokens + /AKIA[0-9A-Z]{16}/, // AWS access key id + /AIza[0-9A-Za-z_-]{35}/, // Google API key + /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/, // JWT +] + +/** + * Key names (normalized to lowercase, separators stripped) whose value is a + * secret. Split by match strategy so short/ambiguous words do not over-match: + * `author` must not trip the `auth` rule. + */ +const KEY_SUBSTRING_INDICATORS: readonly string[] = [ + 'password', 'passwd', 'passphrase', 'secret', 'apikey', 'apisecret', + 'clientsecret', 'privatekey', 'secretkey', 'accesskey', 'credential', + 'connectionstring', 'sastoken', 'xapikey', 'authtoken', 'accesstoken', + 'refreshtoken', 'idtoken', 'sessiontoken', 'bearertoken', +] +const KEY_SUFFIX_INDICATORS: readonly string[] = ['token'] +const KEY_EXACT_INDICATORS: readonly string[] = [ + 'auth', 'authorization', 'cookie', 'bearer', 'dsn', 'signature', +] + +/** + * Whether a key name marks its value as a secret. + * @param key - raw object key or assignment name. + * @returns whether the value under this key must be redacted. + */ +export function keyLooksSecret(key: string): boolean { + const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, '') + if (normalized.length === 0) return false + if (KEY_SUBSTRING_INDICATORS.some(indicator => normalized.includes(indicator))) return true + if (KEY_SUFFIX_INDICATORS.some(indicator => normalized.endsWith(indicator))) return true + return KEY_EXACT_INDICATORS.includes(normalized) +} + +/** Shannon entropy in bits per character. */ +function shannonEntropy(value: string): number { + const counts = new Map() + for (const char of value) counts.set(char, (counts.get(char) ?? 0) + 1) + let entropy = 0 + for (const count of counts.values()) { + const probability = count / value.length + entropy -= probability * Math.log2(probability) + } + return entropy +} + +/** Opaque-token character set (base64/base64url plus common token punctuation). */ +const OPAQUE_TOKEN = /^[A-Za-z0-9+/=_.-]+$/ +/** Version-like leader kept visible (dependency pins, semver). */ +const VERSION_LIKE = /^v?\d+(?:\.\d+)+/ + +/** + * Conservative secret detector and redactor for telemetry content. + * Detection is a pure function of the input; construction only fixes tunables. + */ +export class SecretRedactor { + readonly #placeholder: string + readonly #minTokenLength: number + readonly #entropyThreshold: number + + /** @param options - placeholder text and heuristic thresholds. */ + constructor(options: SecretRedactorOptions = {}) { + this.#placeholder = options.placeholder ?? DEFAULT_REDACTION_PLACEHOLDER + this.#minTokenLength = options.minTokenLength ?? DEFAULT_MIN_TOKEN_LENGTH + this.#entropyThreshold = options.entropyThreshold ?? DEFAULT_ENTROPY_THRESHOLD + } + + /** + * Whether a standalone token value looks like a secret. + * @param value - candidate token, already trimmed of surrounding quotes. + * @returns whether the value should be redacted on its own merits. + */ + isSecretValue(value: string): boolean { + if (KNOWN_SECRET_PATTERNS.some(pattern => pattern.test(value))) return true + if (value.length < this.#minTokenLength) return false + if (!OPAQUE_TOKEN.test(value)) return false + // Git SHAs and integrity digests are hex and public — never a secret we hide. + if (/^[0-9a-fA-F]+$/.test(value)) return false + if (VERSION_LIKE.test(value)) return false + const classes = (/[a-z]/.test(value) ? 1 : 0) + (/[A-Z]/.test(value) ? 1 : 0) + (/[0-9]/.test(value) ? 1 : 0) + return classes >= 3 || shannonEntropy(value) >= this.#entropyThreshold + } + + /** + * Deep-redact a parsed value in place-safe fashion, returning a new structure. + * A secret-named key redacts its string value outright; every other string is + * judged on its own shape. Non-string leaves pass through untouched. + * @param value - parsed JSON-like value (object, array, or primitive). + * @returns a structurally identical value with secret strings replaced. + */ + redactValue(value: T): T { + return this.#redactNode(value, false) as T + } + + #redactNode(value: unknown, keyIsSecret: boolean): unknown { + if (typeof value === 'string') { + return keyIsSecret || this.isSecretValue(value) ? this.#placeholder : value + } + if (Array.isArray(value)) return value.map(item => this.#redactNode(item, false)) + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, this.#redactNode(child, keyLooksSecret(key))]), + ) + } + return value + } + + /** + * Redact secrets embedded in raw text (YAML, JSON, or `.env`-style content), + * preserving every line and key while replacing only secret-shaped values. + * @param text - raw file or message text. + * @returns text with detected secrets replaced by the placeholder. + */ + redactText(text: string): string { + let output = this.#redactPemBlocks(text) + output = this.#redactAssignments(output) + output = this.#redactUrlCredentials(output) + output = this.#redactBearerTokens(output) + return this.#redactStandaloneTokens(output) + } + + #redactPemBlocks(text: string): string { + return text.replace( + /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z ]+ )?PRIVATE KEY-----/g, + this.#placeholder, + ) + } + + #redactAssignments(text: string): string { + // `key: value`, `key = value`, or `"key": "value"` across YAML/JSON/.env. + return text.replace( + /("?)([A-Za-z0-9_.-]+)\1(\s*[:=]\s*)(["']?)([^\n\r"']+)\4/g, + (match, keyQuote: string, key: string, separator: string, valueQuote: string, value: string) => + keyLooksSecret(key) && value.trim().length > 0 + ? `${keyQuote}${key}${keyQuote}${separator}${valueQuote}${this.#placeholder}${valueQuote}` + : match, + ) + } + + #redactUrlCredentials(text: string): string { + // Redact only the password in `scheme://user:password@host`, keeping host visible. + return text.replace( + /([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s/@]+)(@)/gi, + (_match, prefix: string, _password: string, at: string) => `${prefix}${this.#placeholder}${at}`, + ) + } + + #redactBearerTokens(text: string): string { + // The candidate must contain a digit: real bearer credentials are never + // letters-only, while prose like "bearer authentication" is. + return text.replace( + /(bearer\s+)((?=[a-z._-]*[0-9])[a-z0-9._-]{8,})/gi, + (_match, prefix: string) => `${prefix}${this.#placeholder}`, + ) + } + + #redactStandaloneTokens(text: string): string { + // `/` is excluded so package names, file paths, and URLs are never split or + // redacted; a secret containing `/` is still scrubbed piecewise. + return text.replace(/[A-Za-z0-9][A-Za-z0-9+=_.-]{7,}/g, token => + this.isSecretValue(token) ? this.#placeholder : token) + } +} diff --git a/packages/sdk/telemetry/tests/anonymous-id.spec.ts b/packages/sdk/telemetry/tests/anonymous-id.spec.ts new file mode 100644 index 0000000000..df8bcffea2 --- /dev/null +++ b/packages/sdk/telemetry/tests/anonymous-id.spec.ts @@ -0,0 +1,100 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + ANONYMOUS_ID_FILE_NAME, + getOrCreateAnonymousId, + globalConfigDir, +} from '@deepseek-ai/dsh-telemetry' + +const dirs: string[] = [] + +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-anon-')) + dirs.push(dir) + return dir +} + +afterEach(async () => { + await Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) +}) + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +describe('globalConfigDir', () => { + it('prefers an explicit DSH_CONFIG_HOME override', () => { + expect(globalConfigDir({ env: { DSH_CONFIG_HOME: '/custom/dsh' } })).toBe('/custom/dsh') + }) + + it('falls back to XDG_CONFIG_HOME under the harness namespace', () => { + expect(globalConfigDir({ env: { XDG_CONFIG_HOME: '/xdg' } })).toBe(join('/xdg', 'deepseek-harness')) + }) + + it('uses %APPDATA% on Windows', () => { + expect(globalConfigDir({ env: { APPDATA: 'C:/Users/x/AppData/Roaming' }, platform: 'win32' })) + .toBe(join('C:/Users/x/AppData/Roaming', 'deepseek-harness')) + }) + + it('falls back to ~/.config on Windows without APPDATA and on posix', () => { + const home = () => '/home/dev' + expect(globalConfigDir({ env: {}, platform: 'win32', homeDir: home })) + .toBe(join('/home/dev', '.config', 'deepseek-harness')) + expect(globalConfigDir({ env: {}, platform: 'linux', homeDir: home })) + .toBe(join('/home/dev', '.config', 'deepseek-harness')) + }) + + it('reads process.env by default', () => { + // No override supplied: the call must not throw and must return an absolute path. + expect(globalConfigDir()).toContain('deepseek-harness') + }) +}) + +describe('getOrCreateAnonymousId', () => { + it('creates, persists, and returns a UUID on first use', async () => { + const dir = await tempDir() + const id = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } }) + expect(id).toMatch(UUID) + const stored: unknown = JSON.parse(await readFile(join(dir, ANONYMOUS_ID_FILE_NAME), 'utf8')) + expect(stored).toEqual({ anonymousId: id }) + }) + + it('returns the same persisted id on subsequent calls', async () => { + const dir = await tempDir() + const first = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } }) + const second = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } }) + expect(second).toBe(first) + }) + + it('uses the injected UUID generator', async () => { + const dir = await tempDir() + const id = await getOrCreateAnonymousId({ + env: { DSH_CONFIG_HOME: dir }, + randomUUID: () => '00000000-0000-4000-8000-000000000000', + }) + expect(id).toBe('00000000-0000-4000-8000-000000000000') + }) + + it('regenerates when the stored file is corrupt JSON', async () => { + const dir = await tempDir() + await writeFile(join(dir, ANONYMOUS_ID_FILE_NAME), 'not json', 'utf8') + const id = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } }) + expect(id).toMatch(UUID) + }) + + it('regenerates when the stored value is not a valid UUID or object', async () => { + const dir = await tempDir() + await writeFile(join(dir, ANONYMOUS_ID_FILE_NAME), JSON.stringify({ anonymousId: 'nope' }), 'utf8') + expect(await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })).toMatch(UUID) + await writeFile(join(dir, ANONYMOUS_ID_FILE_NAME), '123', 'utf8') + expect(await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })).toMatch(UUID) + }) + + it('returns a usable id even when persistence fails', async () => { + const dir = await tempDir() + // A regular file where a directory is expected makes mkdir/writeFile fail. + await writeFile(join(dir, 'blocker'), 'x', 'utf8') + const id = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: join(dir, 'blocker') } }) + expect(id).toMatch(UUID) + }) +}) diff --git a/packages/sdk/telemetry/tests/consent-resolver.spec.ts b/packages/sdk/telemetry/tests/consent-resolver.spec.ts new file mode 100644 index 0000000000..ca0cec3bbd --- /dev/null +++ b/packages/sdk/telemetry/tests/consent-resolver.spec.ts @@ -0,0 +1,131 @@ +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { ConsentResolver, DEFAULT_TELEMETRY_PLUGIN_NAME, type ConsentDecision } from '@deepseek-ai/dsh-telemetry' + +const dirs: string[] = [] + +async function projectDir(cordisYml?: string): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-consent-')) + dirs.push(dir) + if (cordisYml !== undefined) await writeFile(join(dir, 'cordis.yml'), cordisYml, 'utf8') + return dir +} + +afterEach(async () => { + await Promise.all(dirs.splice(0).map(dir => import('node:fs/promises').then(fs => fs.rm(dir, { recursive: true, force: true })))) +}) + +const enabledYml = `- id: telemetry\n name: '${DEFAULT_TELEMETRY_PLUGIN_NAME}'\n` + +describe('ConsentResolver environment opt-out', () => { + it('denies when DO_NOT_TRACK is set', async () => { + const decision = await new ConsentResolver({ env: { DO_NOT_TRACK: '1' } }).resolve(await projectDir(enabledYml)) + expect(decision).toEqual({ allowed: false, reason: 'do-not-track' }) + }) + + it('denies when CI is set', async () => { + const decision = await new ConsentResolver({ env: { CI: 'true' } }).resolve(await projectDir(enabledYml)) + expect(decision).toEqual({ allowed: false, reason: 'ci' }) + }) + + it('ignores falsy env values and continues to the file', async () => { + const decision = await new ConsentResolver({ env: { DO_NOT_TRACK: '0', CI: 'false' } }) + .resolve(await projectDir(enabledYml)) + expect(decision).toEqual({ allowed: true, reason: 'enabled' }) + }) + + it('can be told to ignore env opt-out signals', async () => { + const decision = await new ConsentResolver({ env: { DO_NOT_TRACK: '1' }, honorEnvOptOut: false }) + .resolve(await projectDir(enabledYml)) + expect(decision).toEqual({ allowed: true, reason: 'enabled' }) + }) + + it('reads process.env by default', async () => { + const saved = { CI: process.env.CI, DO_NOT_TRACK: process.env.DO_NOT_TRACK } + delete process.env.CI + delete process.env.DO_NOT_TRACK + try { + const decision = await new ConsentResolver().resolve(await projectDir(enabledYml)) + expect(decision).toEqual({ allowed: true, reason: 'enabled' }) + } finally { + if (saved.CI !== undefined) process.env.CI = saved.CI + if (saved.DO_NOT_TRACK !== undefined) process.env.DO_NOT_TRACK = saved.DO_NOT_TRACK + } + }) +}) + +describe('ConsentResolver cordis.yml state', () => { + const resolver = new ConsentResolver({ env: {} }) + + it('allows when the telemetry entry is enabled', async () => { + expect(await resolver.resolve(await projectDir(enabledYml))) + .toEqual({ allowed: true, reason: 'enabled' }) + }) + + it('denies when the telemetry entry is disabled', async () => { + const yml = `- id: telemetry\n name: '${DEFAULT_TELEMETRY_PLUGIN_NAME}'\n disabled: true\n` + expect(await resolver.resolve(await projectDir(yml))) + .toEqual({ allowed: false, reason: 'disabled' }) + }) + + it('tolerates !!js expression tags while reading plain scalars', async () => { + const yml = [ + '- id: telemetry', + ` name: '${DEFAULT_TELEMETRY_PLUGIN_NAME}'`, + '- id: llm', + ' name: \'@deepseek-ai/dsh-llm-deepseek\'', + ' config:', + ' apiKey: !!js process.env.DEEPSEEK_API_KEY', + '', + ].join('\n') + expect(await resolver.resolve(await projectDir(yml))) + .toEqual({ allowed: true, reason: 'enabled' }) + }) + + it('reports (allows) when cordis.yml has no telemetry entry', async () => { + const yml = '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n' + expect(await resolver.resolve(await projectDir(yml))) + .toEqual({ allowed: true, reason: 'absent' }) + }) + + it('can be told to deny when the entry is absent', async () => { + const yml = '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n' + const decision = await new ConsentResolver({ env: {}, allowWhenEntryAbsent: false }).resolve(await projectDir(yml)) + expect(decision).toEqual({ allowed: false, reason: 'absent' }) + }) + + it('skips non-object sequence items and a non-sequence root, still reporting absent', async () => { + expect(await resolver.resolve(await projectDir('- just-a-string\n- id: x\n name: y\n'))) + .toEqual({ allowed: true, reason: 'absent' }) + expect(await resolver.resolve(await projectDir('root: not-a-sequence\n'))) + .toEqual({ allowed: true, reason: 'absent' }) + }) + + it('honors a custom telemetry plugin name', async () => { + const yml = '- id: t\n name: \'my-consent-marker\'\n' + const decision = await new ConsentResolver({ env: {}, telemetryPluginName: 'my-consent-marker' }) + .resolve(await projectDir(yml)) + expect(decision).toEqual({ allowed: true, reason: 'enabled' }) + }) +}) + +describe('ConsentResolver missing or unreadable cordis.yml', () => { + it('reports no-config and allows by default on first init', async () => { + expect(await new ConsentResolver({ env: {} }).resolve(await projectDir())) + .toEqual({ allowed: true, reason: 'no-config' }) + }) + + it('can deny on first init', async () => { + const decision = await new ConsentResolver({ env: {}, allowWhenNoConfig: false }).resolve(await projectDir()) + expect(decision).toEqual({ allowed: false, reason: 'no-config' }) + }) + + it('denies with an unreadable reason when cordis.yml is not a regular file', async () => { + const dir = await projectDir() + await mkdir(join(dir, 'cordis.yml')) // a directory where the resolver expects a file + expect(await new ConsentResolver({ env: {} }).resolve(dir)) + .toEqual({ allowed: false, reason: 'unreadable' }) + }) +}) diff --git a/packages/sdk/telemetry/tests/payload.spec.ts b/packages/sdk/telemetry/tests/payload.spec.ts new file mode 100644 index 0000000000..ed3ab2f82f --- /dev/null +++ b/packages/sdk/telemetry/tests/payload.spec.ts @@ -0,0 +1,69 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { SecretRedactor, buildTelemetryPayload } from '@deepseek-ai/dsh-telemetry' + +const dirs: string[] = [] + +async function projectDir(files: Record): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-payload-')) + dirs.push(dir) + await Promise.all(Object.entries(files).map(([name, content]) => writeFile(join(dir, name), content, 'utf8'))) + return dir +} + +afterEach(async () => { + await Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) +}) + +describe('buildTelemetryPayload', () => { + it('carries lifecycle facts and redacted file content', async () => { + const dir = await projectDir({ + 'cordis.yml': '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n config:\n apiKey: sk-abcdefghij1234567890\n', + 'package.json': '{ "name": "my-app", "config": { "token": "sk-abcdefghij1234567890" } }', + }) + const payload = await buildTelemetryPayload({ command: 'build', durationMs: 42, success: true, projectDir: dir }) + expect(payload.command).toBe('build') + expect(payload.durationMs).toBe(42) + expect(payload.success).toBe(true) + expect(payload.cordisYmlContent).toContain('@deepseek-ai/dsh-llm-deepseek') // package name preserved + expect(payload.cordisYmlContent).not.toContain('sk-abcdefghij1234567890') // secret scrubbed + expect(payload.packageJsonContent).toContain('my-app') + expect(payload.packageJsonContent).not.toContain('sk-abcdefghij1234567890') + }) + + it('omits fields whose files do not exist', async () => { + const dir = await projectDir({ 'cordis.yml': '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n' }) + const payload = await buildTelemetryPayload({ command: 'create', durationMs: 1, success: false, projectDir: dir }) + expect(payload.cordisYmlContent).toBeDefined() + expect('packageJsonContent' in payload).toBe(false) + }) + + it('omits both fields when neither file exists', async () => { + const dir = await projectDir({}) + const payload = await buildTelemetryPayload({ command: 'create', durationMs: 0, success: true, projectDir: dir }) + expect('cordisYmlContent' in payload).toBe(false) + expect('packageJsonContent' in payload).toBe(false) + }) + + it('withholds package.json when cordis.yml is absent (not an SDK project)', async () => { + const dir = await projectDir({ 'package.json': '{ "name": "unrelated-repo" }' }) + const payload = await buildTelemetryPayload({ command: 'build', durationMs: 3, success: false, projectDir: dir }) + expect('cordisYmlContent' in payload).toBe(false) + expect('packageJsonContent' in payload).toBe(false) + }) + + it('uses a supplied redactor', async () => { + const dir = await projectDir({ + 'cordis.yml': '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n', + 'package.json': '{ "password": "hunter2" }', + }) + const redactor = new SecretRedactor({ placeholder: '<>' }) + const payload = await buildTelemetryPayload({ + command: 'config', durationMs: 5, success: true, projectDir: dir, redactor, + }) + expect(payload.packageJsonContent).toContain('<>') + expect(payload.packageJsonContent).not.toContain('hunter2') + }) +}) diff --git a/packages/sdk/telemetry/tests/reporter.spec.ts b/packages/sdk/telemetry/tests/reporter.spec.ts new file mode 100644 index 0000000000..5d8a490b9e --- /dev/null +++ b/packages/sdk/telemetry/tests/reporter.spec.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from 'vitest' +import { + DSH_TELEMETRY_ENDPOINT, + SecretRedactor, + TELEMETRY_SCHEMA_VERSION, + TelemetryReporter, + type AnonymousId, + type ConsentDecision, + type TelemetryPayload, +} from '@deepseek-ai/dsh-telemetry' + +const ALLOW: ConsentDecision = { allowed: true, reason: 'enabled' } +const DENY: ConsentDecision = { allowed: false, reason: 'disabled' } +const anon = (value = 'anon-123'): (() => Promise) => async () => value as AnonymousId + +function okResponse(): Response { + return { ok: true } as Response +} + +describe('TelemetryReporter.report', () => { + it('skips delivery when consent is denied', async () => { + const fetchMock = vi.fn(async () => okResponse()) + const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon() }) + reporter.report({ command: 'build', durationMs: 1, success: true }, DENY) + await reporter.flush(50) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('posts a redacted envelope when consent is granted', async () => { + const fetchMock = vi.fn(() => Promise.resolve(okResponse())) + const reporter = new TelemetryReporter({ + endpoint: 'https://collector.test/telemetry', + fetch: fetchMock, + anonymousId: anon('anon-xyz'), + redactor: new SecretRedactor(), + now: () => 0, + timeoutMs: 100, + }) + const payload: TelemetryPayload = { + command: 'config', + durationMs: 7, + success: true, + cordisYmlContent: 'apiKey: sk-abcdefghij1234567890\nname: \'@deepseek-ai/dsh-llm-deepseek\'\n', + packageJsonContent: '{ "name": "app" }', + } + reporter.report(payload, ALLOW) + await reporter.flush(50) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const call = fetchMock.mock.calls[0]! + expect(call[0]).toBe('https://collector.test/telemetry') + const init = call[1]! + expect(init.method).toBe('POST') + const body = JSON.parse(init.body as string) as Record + expect(body.schemaVersion).toBe(TELEMETRY_SCHEMA_VERSION) + expect(body.anonymousId).toBe('anon-xyz') + expect(body.sentAt).toBe('1970-01-01T00:00:00.000Z') + expect(body.command).toBe('config') + expect(body.cordisYmlContent).not.toContain('sk-abcdefghij1234567890') + expect(body.cordisYmlContent).toContain('@deepseek-ai/dsh-llm-deepseek') + expect(body.packageJsonContent).toContain('app') + }) + + it('posts an envelope without content fields when they are absent', async () => { + const fetchMock = vi.fn(() => Promise.resolve(okResponse())) + const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), now: () => 0, timeoutMs: 100 }) + reporter.report({ command: 'start', durationMs: 2, success: true }, ALLOW) + await reporter.flush(50) + const body = JSON.parse(fetchMock.mock.calls[0]![1]!.body as string) as Record + expect('cordisYmlContent' in body).toBe(false) + expect('packageJsonContent' in body).toBe(false) + }) + + it('swallows a non-OK HTTP status', async () => { + const fetchMock = vi.fn(async () => ({ ok: false, status: 503 } as Response)) + const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), timeoutMs: 100 }) + reporter.report({ command: 'dev', durationMs: 3, success: true }, ALLOW) + await expect(reporter.flush(50)).resolves.toBeUndefined() + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('swallows a transport failure', async () => { + const fetchMock = vi.fn(async () => { throw new Error('network down') }) + const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), timeoutMs: 100 }) + reporter.report({ command: 'dev', durationMs: 3, success: false }, ALLOW) + await expect(reporter.flush(50)).resolves.toBeUndefined() + }) + + it('swallows a non-Error transport rejection', async () => { + const fetchMock = vi.fn(async () => { throw 'boom' }) + const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), timeoutMs: 100 }) + reporter.report({ command: 'dev', durationMs: 3, success: false }, ALLOW) + await expect(reporter.flush(50)).resolves.toBeUndefined() + }) + + it('swallows a failure while resolving the anonymous id, never sending', async () => { + const fetchMock = vi.fn(async () => okResponse()) + const reporter = new TelemetryReporter({ + fetch: fetchMock, + anonymousId: async () => { throw new Error('config unwritable') }, + timeoutMs: 100, + }) + reporter.report({ command: 'build', durationMs: 1, success: true }, ALLOW) + await reporter.flush(50) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) + +describe('TelemetryReporter.flush', () => { + it('returns immediately when nothing is in flight', async () => { + const reporter = new TelemetryReporter({ fetch: vi.fn(async () => okResponse()), anonymousId: anon() }) + await expect(reporter.flush()).resolves.toBeUndefined() + }) + + it('resolves on the timeout cap when a send never settles', async () => { + const reporter = new TelemetryReporter({ + fetch: () => new Promise(() => {}), + anonymousId: anon(), + timeoutMs: 10, + }) + reporter.report({ command: 'start', durationMs: 1, success: true }, ALLOW) + const started = Date.now() + await reporter.flush(15) + expect(Date.now() - started).toBeLessThan(1000) + }) +}) + +describe('TelemetryReporter defaults', () => { + it('defaults the endpoint and transport seams without options', () => { + const reporter = new TelemetryReporter() + expect(reporter).toBeInstanceOf(TelemetryReporter) + expect(DSH_TELEMETRY_ENDPOINT).toContain('.invalid') + }) +}) diff --git a/packages/sdk/telemetry/tests/secret-redactor.spec.ts b/packages/sdk/telemetry/tests/secret-redactor.spec.ts new file mode 100644 index 0000000000..77d89d968e --- /dev/null +++ b/packages/sdk/telemetry/tests/secret-redactor.spec.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest' +import { + DEFAULT_ENTROPY_THRESHOLD, + DEFAULT_MIN_TOKEN_LENGTH, + DEFAULT_REDACTION_PLACEHOLDER, + SecretRedactor, + keyLooksSecret, +} from '@deepseek-ai/dsh-telemetry' + +const REDACTED = DEFAULT_REDACTION_PLACEHOLDER + +describe('exported defaults', () => { + it('expose the documented tunable defaults', () => { + expect(DEFAULT_REDACTION_PLACEHOLDER).toBe('[REDACTED]') + expect(DEFAULT_MIN_TOKEN_LENGTH).toBe(24) + expect(DEFAULT_ENTROPY_THRESHOLD).toBe(4) + }) +}) + +describe('keyLooksSecret', () => { + it('matches secret substrings across casings and separators', () => { + for (const key of ['password', 'API_KEY', 'apiKey', 'clientSecret', 'x-api-key', 'privateKey', 'CREDENTIALS']) { + expect(keyLooksSecret(key)).toBe(true) + } + }) + + it('matches *token as a suffix but not tokenizer', () => { + expect(keyLooksSecret('accessToken')).toBe(true) + expect(keyLooksSecret('token')).toBe(true) + expect(keyLooksSecret('tokenizer')).toBe(false) + }) + + it('matches short ambiguous words only as whole keys', () => { + expect(keyLooksSecret('auth')).toBe(true) + expect(keyLooksSecret('authorization')).toBe(true) + expect(keyLooksSecret('cookie')).toBe(true) + expect(keyLooksSecret('author')).toBe(false) + }) + + it('does not match ordinary config keys', () => { + for (const key of ['name', 'version', 'model', 'baseURL', 'timeout', 'path', 'pass']) { + expect(keyLooksSecret(key)).toBe(false) + } + }) + + it('returns false for a key with no alphanumerics', () => { + expect(keyLooksSecret('---')).toBe(false) + }) +}) + +describe('SecretRedactor.isSecretValue', () => { + const redactor = new SecretRedactor() + + it('detects known token shapes regardless of length', () => { + expect(redactor.isSecretValue('sk-abcdefghij1234567890')).toBe(true) + expect(redactor.isSecretValue('sk-ant-abcdefghij1234567890')).toBe(true) + expect(redactor.isSecretValue('ghp_abcdefghijklmnop1234')).toBe(true) + expect(redactor.isSecretValue('github_pat_abcdefghijklmnopqrst')).toBe(true) + expect(redactor.isSecretValue('xoxb-abcdefghij-klmno')).toBe(true) + expect(redactor.isSecretValue('AKIA1234567890ABCDEF')).toBe(true) + expect(redactor.isSecretValue(`AIza${'a'.repeat(35)}`)).toBe(true) + expect(redactor.isSecretValue('eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefghijklmnop')).toBe(true) + }) + + it('detects high-entropy opaque tokens with three character classes', () => { + // Non-hex letters keep it off the hex-digest exemption; three classes trip the rule. + expect(redactor.isSecretValue('zX9zX9zX9zX9zX9zX9zX9zX9')).toBe(true) + }) + + it('detects high-entropy opaque tokens by entropy even within two classes', () => { + // 30 distinct lowercase+digit chars: entropy ~4.9, only two classes. + const token = 'abcdefghijklmnopqrstuvwxyz0123' + expect(token.length).toBeGreaterThanOrEqual(DEFAULT_MIN_TOKEN_LENGTH) + expect(redactor.isSecretValue(token)).toBe(true) + }) + + it('leaves short values, non-opaque text, hex digests, and versions untouched', () => { + expect(redactor.isSecretValue('deepseek-chat')).toBe(false) // short + expect(redactor.isSecretValue('a token with spaces here!!')).toBe(false) // not opaque + expect(redactor.isSecretValue('a'.repeat(40))).toBe(false) // low entropy, one class + expect(redactor.isSecretValue('abcdef0123456789abcdef0123456789abcdef01')).toBe(false) // 40-hex git SHA + expect(redactor.isSecretValue('1.2.3.4.5.6.7.8.9.10.11.12')).toBe(false) // version-like + expect(redactor.isSecretValue('ZXQPZXQPZXQPZXQPZXQPZXQP')).toBe(false) // uppercase only, low entropy + }) + + it('honors a custom entropy threshold', () => { + const strict = new SecretRedactor({ entropyThreshold: 100 }) + // Two-class token can no longer trip the entropy branch under an impossible threshold. + expect(strict.isSecretValue('abcdefghijklmnopqrstuvwxyz0123')).toBe(false) + }) +}) + +describe('SecretRedactor.redactValue', () => { + const redactor = new SecretRedactor() + + it('redacts secret-keyed strings and secret-shaped strings, keeping structure', () => { + const result = redactor.redactValue({ + apiKey: 'short-not-shaped', + name: 'my-package', + token: 'sk-abcdefghij1234567890', + count: 3, + enabled: true, + missing: null, + nested: { password: 'p', note: 'plain text value' }, + list: ['harmless', 'sk-abcdefghij1234567890'], + }) + expect(result).toEqual({ + apiKey: REDACTED, // redacted by key even though the value is not secret-shaped + name: 'my-package', + token: REDACTED, + count: 3, + enabled: true, + missing: null, + nested: { password: REDACTED, note: 'plain text value' }, + list: ['harmless', REDACTED], + }) + }) + + it('redacts a top-level secret string and passes through primitives', () => { + expect(redactor.redactValue('sk-abcdefghij1234567890')).toBe(REDACTED) + expect(redactor.redactValue('plain')).toBe('plain') + expect(redactor.redactValue(42)).toBe(42) + expect(redactor.redactValue(null)).toBeNull() + }) +}) + +describe('SecretRedactor.redactText', () => { + const redactor = new SecretRedactor() + + it('redacts PEM private key blocks', () => { + const text = '-----BEGIN RSA PRIVATE KEY-----\nMIIabc\ndef==\n-----END RSA PRIVATE KEY-----' + expect(redactor.redactText(text)).toBe(REDACTED) + }) + + it('redacts secret-keyed assignments across YAML, JSON, and .env', () => { + expect(redactor.redactText('password: hunter2')).toBe(`password: ${REDACTED}`) + expect(redactor.redactText('apiKey: "sk-abcdefghij1234567890"')).toBe(`apiKey: "${REDACTED}"`) + expect(redactor.redactText('"token": "abcdefgh"')).toBe(`"token": "${REDACTED}"`) + expect(redactor.redactText('API_KEY=sk-abcdefghij1234567890')).toBe(`API_KEY=${REDACTED}`) + }) + + it('keeps non-secret assignments and whitespace-only secret values intact', () => { + expect(redactor.redactText('model: deepseek-chat')).toBe('model: deepseek-chat') + expect(redactor.redactText('password: \n')).toBe('password: \n') + }) + + it('redacts only the password in URL credentials, keeping the host', () => { + expect(redactor.redactText('url: https://user:s3cretPass@api.deepseek.com/v1')) + .toBe(`url: https://user:${REDACTED}@api.deepseek.com/v1`) + }) + + it('redacts bearer tokens embedded in free text', () => { + expect(redactor.redactText('sending Bearer abcdefgh12345678 now')) + .toBe(`sending Bearer ${REDACTED} now`) + }) + + it('keeps letters-only prose after the word bearer intact', () => { + expect(redactor.redactText('uses bearer authentication for requests')) + .toBe('uses bearer authentication for requests') + expect(redactor.redactText('"description": "bearer token-helper middleware"')) + .toBe('"description": "bearer token-helper middleware"') + }) + + it('redacts standalone secret-shaped tokens while keeping package names and paths', () => { + expect(redactor.redactText('key sk-abcdefghij1234567890 end')) + .toBe(`key ${REDACTED} end`) + expect(redactor.redactText('name: @deepseek-ai/dsh-telemetry')).toBe('name: @deepseek-ai/dsh-telemetry') + expect(redactor.redactText('path: ./plugins/local-plugin/src/index.ts')) + .toBe('path: ./plugins/local-plugin/src/index.ts') + }) + + it('is idempotent on already-redacted text', () => { + const once = redactor.redactText('password: hunter2') + expect(redactor.redactText(once)).toBe(once) + }) +}) diff --git a/packages/sdk/telemetry/tsconfig.json b/packages/sdk/telemetry/tsconfig.json new file mode 100644 index 0000000000..8acc8f11c5 --- /dev/null +++ b/packages/sdk/telemetry/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { "path": "../../util/brand" } + ] +} diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index 92a8d290bf..856c933cbc 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -8,7 +8,7 @@ The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-proc ## Config -There are no `cordis.yml` keys. `JsonRpcConfig.input`, `output`, and `exit` are runtime-only transport seams; production uses process stdio and `process.exit`. +`maxTokensAsSuccess` defaults to `false`. Set it to `true` for evaluation hosts that distinguish an accepted, token-limited agent result from an infrastructure failure. `JsonRpcConfig.input`, `output`, and `exit` are runtime-only transport seams; production uses process stdio and `process.exit`. ## stdout is the protocol diff --git a/packages/ui/jsonrpc/src/index.ts b/packages/ui/jsonrpc/src/index.ts index 29fb1aeff8..099ef6008f 100644 --- a/packages/ui/jsonrpc/src/index.ts +++ b/packages/ui/jsonrpc/src/index.ts @@ -22,8 +22,10 @@ export const name = 'jsonrpc' // Only the agent factory is required; initialize reads the optional LLM seam with ctx.get(). export const inject = ['agents'] -/** Runtime-only test seams; no field is configurable from `cordis.yml`. */ +/** JSON-RPC deployment config plus runtime-only test seams. */ export interface JsonRpcConfig { + /** Report max-token turn/subagent termination as a successful SDK result. */ + maxTokensAsSuccess?: boolean /** Transport input override; production uses `process.stdin`. */ input?: Readable /** Transport output override; production uses `process.stdout`. */ @@ -32,7 +34,9 @@ export interface JsonRpcConfig { exit?: (code: number) => void } -export const Config: Schema = Schema.object({}) +export const Config: Schema = Schema.object({ + maxTokensAsSuccess: Schema.boolean().default(false), +}) /** * Serve SDK requests over the configured streams. Effect disposal shuts down @@ -41,6 +45,8 @@ export const Config: Schema = Schema.object({}) * owns root-context disposal for EOF and signals. */ export function apply(ctx: Context, config: JsonRpcConfig): void { + // Cordis applies the schema default before invoking the plugin. + const resolvedConfig = config as JsonRpcConfig & { maxTokensAsSuccess: boolean } // The later transport callback must dispose this plugin's fiber, not its ambient context. const fiber = ctx.fiber /* v8 ignore next -- production stdio wiring; tests always inject the runtime seams */ @@ -51,7 +57,9 @@ export function apply(ctx: Context, config: JsonRpcConfig): void { const exit = config.exit ?? ((code: number): void => { process.exit(code) }) const transport = new JsonRpcLineTransport(input, output) - const server = new HarnessSdkServer(ctx, transport) + const server = new HarnessSdkServer(ctx, transport, { + maxTokensAsSuccess: resolvedConfig.maxTokensAsSuccess, + }) // Share one exit task and attempt flush and disposal independently before exiting. let exitTask: Promise | undefined diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index 8949ccb06a..f3a164340c 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -57,7 +57,22 @@ function subagentParentOf(carrier: Scoped): Agent { return carrierKeyOf(carrier) as Agent } -/** SDK server whose subscriptions and created agents live until {@link shutdown}. */ +/** Deployment-specific status mapping for SDK turn and subagent outcomes. */ +export interface HarnessSdkServerOptions { + /** Report max-token termination as an accepted result instead of an infrastructure error. */ + maxTokensAsSuccess?: boolean +} + +function successStatus(reason: string, options: HarnessSdkServerOptions): 'ok' | 'error' { + if (reason === 'completed') return 'ok' + return reason === 'max-tokens' && options.maxTokensAsSuccess === true ? 'ok' : 'error' +} + +/** + * SDK server over one booted harness context and transport peer. Construction + * subscribes to session, agent, and subagent lifecycle events until shutdown; + * reinitialization is unsupported. + */ export class HarnessSdkServer { private cwd = process.cwd() private provider = 'deepseek' @@ -72,7 +87,9 @@ export class HarnessSdkServer { constructor( private readonly ctx: Context, private readonly transport: JsonRpcTransportPeer, + private readonly options: HarnessSdkServerOptions = {}, ) { + const serverOptions = this.options this.disposers.push(ctx.on('session/event', (session, event) => { if (event.type === 'turn/end') { const rec = this.sessions.get(String(session.id)) @@ -99,7 +116,7 @@ export class HarnessSdkServer { agentId: String(info.id), parentSessionId: String(parent.session.id), childSessionId: String(info.id), - status: info.stopReason === 'completed' ? 'ok' : 'error', + status: successStatus(info.stopReason, serverOptions), stopReason: info.stopReason, ...(info.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: info.lastAssistantMessage }), }) @@ -233,7 +250,7 @@ export class HarnessSdkServer { private finishedStatus(reason: TurnEndReason | undefined): 'ok' | 'error' { if (!reason) return 'error' - return reason.kind === 'completed' ? 'ok' : 'error' + return successStatus(reason.kind, this.options) } private hasAdapterFor(provider: string): boolean { diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 5b9259dd85..034577f105 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -656,7 +656,7 @@ describe('HarnessSdkServer', () => { signal: new AbortController().signal, }) const transport = new FakeTransport() - const server = new HarnessSdkServer(ctx, transport) + const server = new HarnessSdkServer(ctx, transport, { maxTokensAsSuccess: true }) missedStartResult.resolve({ output: [], stopReason: 'max-tokens' }) await missedStartRun.result @@ -692,7 +692,7 @@ describe('HarnessSdkServer', () => { agentId: 'fallback-child-session', parentSessionId: 'fallback-parent', childSessionId: 'fallback-child-session', - status: 'error', + status: 'ok', stopReason: 'max-tokens', lastAssistantMessage: [], }, @@ -782,6 +782,24 @@ describe('HarnessSdkServer', () => { } }) + it('can report max-token turn termination as an accepted evaluation result', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-max-tokens-success-')) + const ctx = await makeHarness(storageDir) + try { + const server = new HarnessSdkServer(ctx, new FakeTransport(), { maxTokensAsSuccess: true }) as unknown as { + finishedStatus(reason: unknown): 'ok' | 'error' + shutdown(): Promise> + } + + expect(server.finishedStatus({ kind: 'max-tokens' })).toBe('ok') + expect(server.finishedStatus({ kind: 'error' })).toBe('error') + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + it('reports no adapter when the LLM service is absent', async () => { const ctx = new Context() try { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28469ff9c6..49cd73a6c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,9 @@ importers: '@deepseek-ai/dsh-acp-demo': specifier: workspace:* version: link:../packages/examples/acp-demo + '@deepseek-ai/dsh-agent-spine-demo': + specifier: workspace:* + version: link:../packages/examples/agent-spine-demo '@deepseek-ai/dsh-bash-local': specifier: workspace:* version: link:../packages/bash/bash-local @@ -134,6 +137,9 @@ importers: '@deepseek-ai/dsh-hooks-codex': specifier: workspace:* version: link:../packages/hooks/hooks-codex + '@deepseek-ai/dsh-jsonrpc': + specifier: workspace:* + version: link:../packages/ui/jsonrpc '@deepseek-ai/dsh-llm': specifier: workspace:* version: link:../packages/llm/llm @@ -152,6 +158,9 @@ importers: '@deepseek-ai/dsh-sandbox-local': specifier: workspace:* version: link:../packages/sandbox/sandbox-local + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:* + version: link:../packages/session-persistence/session-persistence-jsonl '@deepseek-ai/dsh-spill-local': specifier: workspace:* version: link:../packages/spill/spill-local @@ -1389,6 +1398,9 @@ importers: '@deepseek-ai/dsh-helper': specifier: workspace:^ version: link:../helper + '@deepseek-ai/dsh-telemetry': + specifier: workspace:^ + version: link:../telemetry commander: specifier: ^15.0.0 version: 15.0.0 @@ -1409,6 +1421,19 @@ importers: specifier: ^4.22.4 version: 4.22.4 + packages/sdk/telemetry: + dependencies: + yaml: + specifier: ^2.9.0 + version: 2.9.0 + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/session-persistence/session-persistence: devDependencies: '@deepseek-ai/dsh-session': @@ -11107,7 +11132,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 - '@types/node': 25.9.3 + '@types/node': 22.20.0 long: 5.3.2 proxy-addr@2.0.7: diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index bb012a90db..181df4986e 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 80c9d1f50d26fc4f4670800fd7d7f5ea442ad891 -README.zh.md: ffedb5eb30f17388fe589863dbc654b22716b40c +README.md: 5fd1bc7cd89152a28d3da17100fd62eed4f8cb14 +README.zh.md: 247a2ca5ea5c1c3afc19335a6bbcba356c823211 diff --git a/python/sdk/README.md b/python/sdk/README.md index 80c9d1f50d..5fd1bc7cd8 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -27,7 +27,7 @@ from deepseek_harness import DeepSeekHarness with DeepSeekHarness( provider="deepseek", model="deepseek-v4-flash", - cordis="examples/dsbench-coding-agent/cordis.yml", + cordis="examples/jsonrpc-agent/cordis.yml", ) as harness: result = harness.run("Make the requested code change.") ``` diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index ffedb5eb30..247a2ca5ea 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -23,7 +23,7 @@ from deepseek_harness import DeepSeekHarness with DeepSeekHarness( provider="deepseek", model="deepseek-v4-flash", - cordis="examples/dsbench-coding-agent/cordis.yml", + cordis="examples/jsonrpc-agent/cordis.yml", ) as harness: result = harness.run("Make the requested code change.") ``` diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index b9414c6fd1..93f30621c5 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -55,6 +55,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/sdk/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' }, 'packages/sdk/helper': { kind: 'none', reason: 'The project domain edits files and registers no live agent or model surface.' }, 'packages/sdk/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' }, + 'packages/sdk/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or model surface.' }, 'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' }, 'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' }, 'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' }, diff --git a/skills/create-dsh-sdk-project/SKILL.md b/skills/create-dsh-sdk-project/SKILL.md new file mode 100644 index 0000000000..5b984f3b86 --- /dev/null +++ b/skills/create-dsh-sdk-project/SKILL.md @@ -0,0 +1,57 @@ +--- +name: create-dsh-sdk-project +description: Create a DeepSeek Harness SDK project non-interactively (headless), driven by an agent instead of the interactive wizard. Use when asked to scaffold a new DSH SDK project without a terminal. +--- + +# Create a DeepSeek Harness SDK project headlessly + +The `create-sdk` initializer normally runs an interactive wizard. To create a project +**without a terminal**, pass a structured spec and ask for machine-readable events: + +```sh +npm create @deepseek-ai/sdk -- --config-json '' --json +``` + +- `--config-json ''` supplies the whole spec inline (no prompts). Alternatively + `--config ` reads the same spec from a file. +- `--json` makes the command emit one NDJSON lifecycle event per line to stdout. + +## Spec shape + +All fields are optional except those a chosen feature requires. Unsupplied answers that +have a sensible default are taken from it; a *required* answer with no default (a secret, +a custom provider base URL, a required feature option) makes the run fail loud rather than +block. + +```json +{ + "directory": "my-agent", + "description": "A DeepSeek Harness agent", + "provider": "deepseek", + "apiKey": "", + "model": "deepseek-v4-flash", + "interface": "stdio", + "pm": "npm", + "install": false, + "features": [ + { "id": "persistence", "options": ["sqlite"] }, + { "id": "web", "options": ["exa"], "secrets": { "apiKey": "" } } + ] +} +``` + +`features` is the complete set of optional features to enable, each with its chosen +options and any secrets/values it needs. The interactive feature tree and its +recommended-feature prompts are skipped in headless mode. + +## Reacting to events + +Each line of stdout is one JSON object: + +- `{"type":"done"}` — the project was created (and installed, if `install` was true). +- `{"type":"action-required","prompt":""}` — a required answer was missing. + Add the corresponding field to the spec (e.g. an `apiKey`, a feature secret, a custom + `baseURL`) and re-run. +- `{"type":"error","message":""}` — the run failed for another reason. + +Iterate: read `action-required`, fill the named input into the spec, re-run until `done`. diff --git a/tsconfig.build.json b/tsconfig.build.json index 42140ab323..c32520a618 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -103,6 +103,7 @@ { "path": "./packages/mcp/mcp-client" }, { "path": "./packages/sdk/helper" }, { "path": "./packages/sdk/scripts" }, - { "path": "./packages/sdk/create-sdk" } + { "path": "./packages/sdk/create-sdk" }, + { "path": "./packages/sdk/telemetry" } ] } diff --git a/tsconfig.json b/tsconfig.json index fd05bdd929..0e012a0ed2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -114,6 +114,7 @@ { "path": "./packages/mcp/mcp-client" }, { "path": "./packages/sdk/helper" }, { "path": "./packages/sdk/scripts" }, - { "path": "./packages/sdk/create-sdk" } + { "path": "./packages/sdk/create-sdk" }, + { "path": "./packages/sdk/telemetry" } ] } diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index adc8b3f3b2..6a84b13daf 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -2,8 +2,9 @@ import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' // Real-API suite, separate because it spends tokens. Each test self-skips without -// DEEPSEEK_API_KEY for keyless CI; the credentialed workflow preflights the secret. Values may come -// from the environment or gitignored root `.env`, with optional DEEPSEEK_BASE_URL. +// its provider credential for keyless CI; credentialed workflows preflight the +// secrets they require. Values may come from the environment or gitignored root +// `.env`, with provider-specific endpoint overrides where supported. try { // Node >= 21.7 native; throws when the file does not exist. process.loadEnvFile(new URL('.env', import.meta.url).pathname)