mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
chore(repo): enforce dsh as the only Node application launcher
Add verify-application-entrypoints to the top-level gate graph. It inventories executable sources and package bins across apps, packages, and examples; rejects unclassified launchers including root-level js/mjs/cjs/ts files; and permits only the dsh CLI plus the explicitly private Python runtime carrier exception. Update repository, architecture, CLI, and naming records to state the same rule: Node consumers select a dsh profile instead of invoking application-package bins, and no compatibility aliases remain. Fixtures prove both allowed classifications and representative escape attempts, making the architectural rule mechanically enforceable.
This commit is contained in:
1 parent
a6447db01c
commit
3b33ca058f
21 files changed
+380
-39
No files matched your search
+2
-2
@@ -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 .agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.md
|
||||
2026-08-11-repository-naming-contract-and-rename-ledger.md: bf69f40884d8fee69cede839cd9ceb7c0d926c38
|
||||
2026-08-11-repository-naming-contract-and-rename-ledger.zh.md: 373bafaaa5590ccac7d33ba9ac27ce29f6dbefe5
|
||||
2026-08-11-repository-naming-contract-and-rename-ledger.md: 2de88df5f1a5037d32daa9a8ee9cd1edfc60528a
|
||||
2026-08-11-repository-naming-contract-and-rename-ledger.zh.md: 0cbbbee561358614da0b20e49c610fbee1d4e537
|
||||
+6
-2
@@ -274,10 +274,14 @@ Keep MCP, Todo, and the Plan Mode package, key, events, and tool names. This dec
|
||||
| `E2BSandboxService` | `E2BRuntime` | The class creates, reuses, and disposes the E2B execution environment used by filesystem and subprocess adapters. It is broader than one sandbox handle and narrower than a generic owner. Keep `@deepseek-ai/dsh-e2b`, `ctx.e2b`, and the `e2b/` group. |
|
||||
| `@deepseek-ai/dsh-frontend-static` | `@deepseek-ai/dsh-host-frontend-static` | The package is the Host plugin that serves the frontend assets. The prefix distinguishes it from frontend application code. |
|
||||
| `PluginInventoryService` | `PluginInventoryGateway` | The class is a Remote-only adapter from the live Loader tree to the `pluginInventory/list` RPC. It owns no same-process service, cache, history, or mutation path. `Gateway` states the role that exists. |
|
||||
| `@deepseek-ai/dsh-jsonrpc-demo` | `@deepseek-ai/dsh-sdk-jsonrpc-demo` | The example demonstrates the runtime SDK over JSON-RPC. It belongs to the one SDK meaning. |
|
||||
| `@deepseek-ai/dsh-jsonrpc-demo`, `@deepseek-ai/dsh-sdk-jsonrpc-demo` | `@deepseek-ai/dsh-sdk-python-runtime` | The private package carries only the temporarily separate Python SDK runtime; the [single dsh launcher decision](2026-08-22-single-dsh-application-launcher.md) owns its application-boundary change. |
|
||||
| `packages/examples/jsonrpc-demo/` | `packages/sdk/python-runtime/` | The carrier is production packaging infrastructure for the Python SDK, not a demo bundle. |
|
||||
| `examples/jsonrpc-agent/` | `examples/python-sdk-agent/` | The direct-config runnable example belongs specifically to the Python SDK exception. |
|
||||
| `@deepseek-ai/dsh-acp-demo` | `@deepseek-ai/dsh-acp-app` | The package is the ACP profile's application bundle, not a standalone demo bin. |
|
||||
| Deploy-root manifest `dsh-jsonrpc-agent-pkg` | `dsh-sdk-python-runtime-closure` | The manifest defines the private Python runtime dependency closure. The Python-visible executable basename remains fixed until its documented profile migration. |
|
||||
| `@deepseek-ai/dsh-frontend` | `@deepseek-ai/dsh-web-frontend` | The application is the web frontend. Keep its physical `apps/web/` folder. |
|
||||
|
||||
Keep atomic-write, brand, native-command, timeout utility, directory-picker, `dsh-base`, `dsh-web-app`, app boot, CLI names, and the `headless` package, bundle, and example identity. `headless` is the intended product essence and may later support more than one-shot execution.
|
||||
Keep atomic-write, brand, native-command, timeout utility, directory-picker, `dsh-base`, `dsh-web-app`, `dsh-sdk-app`, `dsh-acp-app`, app boot, CLI names, and the `headless` package, bundle, and example identity. `headless` is the intended product essence and may later support more than one-shot execution.
|
||||
|
||||
### Client runtime and UI
|
||||
|
||||
|
||||
+6
-2
@@ -274,10 +274,14 @@ PascalCase 标识符中的首字母缩略词使用首字母大写格式:`Ui`
|
||||
| `E2BSandboxService` | `E2BRuntime` | 该类创建、复用和释放文件系统与子进程适配器所使用的 E2B 执行环境。它比单个沙箱句柄的职责更广,又比通用所有者更具体。保留 `@deepseek-ai/dsh-e2b`、`ctx.e2b` 和 `e2b/` 组。 |
|
||||
| `@deepseek-ai/dsh-frontend-static` | `@deepseek-ai/dsh-host-frontend-static` | 该包是提供前端资源的 Host 插件。此前缀可将它与前端应用代码区分开。 |
|
||||
| `PluginInventoryService` | `PluginInventoryGateway` | 该类只负责把实时 Loader 树适配到 `pluginInventory/list` RPC。它不拥有同进程服务、缓存、历史或修改路径。`Gateway` 准确说明现有角色。 |
|
||||
| `@deepseek-ai/dsh-jsonrpc-demo` | `@deepseek-ai/dsh-sdk-jsonrpc-demo` | 该示例演示通过 JSON-RPC 使用运行时 SDK,属于 SDK 的唯一含义。 |
|
||||
| `@deepseek-ai/dsh-jsonrpc-demo`、`@deepseek-ai/dsh-sdk-jsonrpc-demo` | `@deepseek-ai/dsh-sdk-python-runtime` | 该私有包只承载暂时独立的 Python SDK 运行时;其应用边界变更由[单一 dsh 启动器决策](2026-08-22-single-dsh-application-launcher.zh.md)负责。 |
|
||||
| `packages/examples/jsonrpc-demo/` | `packages/sdk/python-runtime/` | 该载体是 Python SDK 的生产打包基础设施,不是演示组合包。 |
|
||||
| `examples/jsonrpc-agent/` | `examples/python-sdk-agent/` | 直读配置的可运行示例专属于 Python SDK 例外。 |
|
||||
| `@deepseek-ai/dsh-acp-demo` | `@deepseek-ai/dsh-acp-app` | 该包是 ACP profile 的应用组合包,不是独立 demo bin。 |
|
||||
| 部署根 manifest `dsh-jsonrpc-agent-pkg` | `dsh-sdk-python-runtime-closure` | 该 manifest 定义私有 Python 运行时依赖闭包。面向 Python 的可执行文件基本名称保持不变,直至完成已记录的 profile 迁移。 |
|
||||
| `@deepseek-ai/dsh-frontend` | `@deepseek-ai/dsh-web-frontend` | 该应用是 Web 前端。保留其物理目录 `apps/web/`。 |
|
||||
|
||||
保留 atomic-write、brand、native-command、timeout 实用工具、目录选择器、`dsh-base`、`dsh-web-app`、应用启动、CLI(命令行界面)名称,以及 `headless` 包、组合包和示例身份。`headless` 是预期的产品本质,未来也可以支持不止一次性执行。
|
||||
保留 atomic-write、brand、native-command、timeout 实用工具、目录选择器、`dsh-base`、`dsh-web-app`、`dsh-sdk-app`、`dsh-acp-app`、应用启动、CLI(命令行界面)名称,以及 `headless` 包、组合包和示例身份。`headless` 是预期的产品本质,未来也可以支持不止一次性执行。
|
||||
|
||||
### 客户端运行时与 UI
|
||||
|
||||
|
||||
@@ -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 .agents/notes/implemented/feature/2026-07-06-approval-seam.md
|
||||
2026-07-06-approval-seam.md: ace41ebbb94cc24c2fdd3e7ae2d3a69b28b169af
|
||||
2026-07-06-approval-seam.zh.md: 63b7478b0903c149fc87fd944c95e94cc8de2359
|
||||
2026-07-06-approval-seam.md: d7f8d90d408bb85c20f6a4dd0373de1aff9b180b
|
||||
2026-07-06-approval-seam.zh.md: 8f2481ff0e25193e91118d5b8181cb4dd3cf40e5
|
||||
@@ -25,7 +25,7 @@ One `cordis.yml` entry mounts the seam. Not loading it is the fail-closed opt-ou
|
||||
# policy: never # deployment default for sessions without an override; 'ask' when omitted
|
||||
```
|
||||
|
||||
The entry alone provides mechanism, not a channel: with no answerer composed, every ask resolves `unavailable` and the asking tool call denies — fail-closed needs no configuration. Composing the ACP app (`@deepseek-ai/dsh-acp-demo`, as in [the acp-agent example's default tree](../../../../examples/acp-agent/README.md)) completes the loop: its [automation-only bridge](../simplification/2026-07-23-acp-automation-only-protocol.md) registers an answerer that sends `session/request_permission` to the owning client with the exact tool-call id and one-shot allow/reject options. `policy: never` is the unattended stance — every ask auto-rejects deterministically, and the current value joins the runtime-context snapshot. `policy` is validated against the closed list at plugin load; anything else throws.
|
||||
The entry alone provides mechanism, not a channel: with no answerer composed, every ask resolves `unavailable` and the asking tool call denies — fail-closed needs no configuration. Composing the ACP profile app (`@deepseek-ai/dsh-acp-app`, as in [the acp-agent example](../../../../examples/acp-agent/README.md)) completes the loop: its [automation-only bridge](../simplification/2026-07-23-acp-automation-only-protocol.md) registers an answerer that sends `session/request_permission` to the owning client with the exact tool-call id and one-shot allow/reject options. `policy: never` is the unattended stance — every ask auto-rejects deterministically, and the current value joins the runtime-context snapshot. `policy` is validated against the closed list at plugin load; anything else throws.
|
||||
|
||||
What a composed deployment observes: `allowed-once` lets exactly that call proceed; rejection, dismissal, and channel absence deny with three distinct reasons the model can tell apart; a successful in-turn request lands a durable `approval/asked`/`approval/decided` pair on the asking agent's session log; nothing about a grant persists past the call that asked. An idle request or audit append failure rejects instead of returning an unaudited decision.
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ Status: implemented
|
||||
# policy: never # deployment default for sessions without an override; 'ask' when omitted
|
||||
```
|
||||
|
||||
仅有这条条目只提供机制,不提供通道:没有组合应答者时,每次 ask 都解析为 `unavailable`,发起请求的工具调用会被拒绝——无需配置即可做到故障时默认拒绝。组合 ACP 应用(`@deepseek-ai/dsh-acp-demo`,如 [acp-agent 示例的默认树](../../../../examples/acp-agent/README.zh.md))即可闭环:其[仅面向自动化的桥接层](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)注册一个应答者,向拥有该会话的客户端发送 `session/request_permission`,携带精确的工具调用 id 和一次性 allow/reject 选项。`policy: never` 是无人值守姿态:每次 ask 都会被确定性地自动拒绝,当前值也会加入运行时上下文快照。`policy` 在插件加载时对照封闭列表校验;非法值直接抛异常。
|
||||
仅有这条条目只提供机制,不提供通道:没有组合应答者时,每次 ask 都解析为 `unavailable`,发起请求的工具调用会被拒绝——无需配置即可做到故障时默认拒绝。组合 ACP profile 应用(`@deepseek-ai/dsh-acp-app`,如 [acp-agent 示例](../../../../examples/acp-agent/README.zh.md))即可闭环:其[仅面向自动化的桥接层](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)注册一个应答者,向拥有该会话的客户端发送 `session/request_permission`,携带精确的工具调用 id 和一次性 allow/reject 选项。`policy: never` 是无人值守姿态:每次 ask 都会被确定性地自动拒绝,当前值也会加入运行时上下文快照。`policy` 在插件加载时对照封闭列表校验;非法值直接抛异常。
|
||||
|
||||
组合部署的可观测行为:`allowed-once` 仅允许该次调用继续;拒绝、关闭和通道缺失以三种不同原因拒绝,模型可以区分;轮次内成功的请求会在发起请求的 agent 的会话日志上落一对持久化的 `approval/asked`/`approval/decided` 事件;授权不会在发起请求的调用结束后继续存在。空闲时的请求或审计追加失败会拒绝,而不会返回未经审计的决策。
|
||||
|
||||
|
||||
@@ -4,7 +4,11 @@ DeepSeek Harness is an all-plugin agent harness on vendored Cordis. Read [docs/a
|
||||
|
||||
## Pre-release stance: foundation over blast radius
|
||||
|
||||
**Remove this section at the first tagged release.** With no external consumers, prefer the correct foundation over compatibility shims: rename or repackage freely and update every reference together. Backends reject old on-disk formats. SQLite uses monotonic `SCHEMA_VERSION`; `dsh-session` keeps `SESSION_FORMAT_VERSION` at `0` with no compatibility promise.
|
||||
**Remove at the first tagged release.** Until then, prefer correct foundations over compatibility shims and update every reference together. Backends reject old disk formats; SQLite increments `SCHEMA_VERSION`, while `dsh-session` holds `SESSION_FORMAT_VERSION` at `0` without a compatibility promise.
|
||||
|
||||
## Application launch
|
||||
|
||||
Node apps launch only through `dsh` profiles; application-package bins, demos, and SDK argv escape hatches are forbidden. The private Python runtime is the sole temporary exception. [Architecture](docs/architecture.md#application-launch) owns scope and deferred artifact rename; `pnpm run verify-application-entrypoints` enforces it.
|
||||
|
||||
## Repository layout
|
||||
|
||||
@@ -41,9 +45,9 @@ packages/ @deepseek-ai/dsh-<pkg> workspaces at packages/<group>/<pkg>/
|
||||
credentials/ credential/authorization capabilities + env/.env provider
|
||||
acp/ automation-only Agent Client Protocol server
|
||||
interaction/ approval/interaction capabilities, permission, commands, ask-user
|
||||
boot/ shared app-bin glue
|
||||
sdk/ JSON-RPC protocol, server, and TypeScript client
|
||||
examples/ demo bundles (agent-spine + CLI/ACP/JSON-RPC bins)
|
||||
boot/ shared profile/application boot glue
|
||||
sdk/ JSON-RPC protocol, server, TypeScript client, and private Python carrier
|
||||
examples/ reusable demo bundles (agent-spine)
|
||||
experimental/ private prototypes excluded from official releases
|
||||
support/ dev/test infrastructure
|
||||
util/ zero-dependency utilities
|
||||
@@ -83,15 +87,11 @@ pnpm run demo:acp # ACP automation server (needs DEEPSEEK_API_KEY)
|
||||
|
||||
### Host sandbox failures
|
||||
|
||||
When required `gh`, `pnpm`, build, test, or generator commands fail because the agent sandbox blocks credentials, network, IPC, file watching, or nested `sandbox-exec`, retry unchanged with the narrowest host escalation before diagnosing authentication or project failure. Require sandbox evidence; never bypass genuine test failures or the product sandbox under test.
|
||||
If a required `gh`, `pnpm`, build, test, or generator command fails because the sandbox blocks credentials, network, IPC, watching, or nested `sandbox-exec`, retry unchanged with the narrowest host escalation. Require sandbox evidence; never bypass test failures or the product sandbox.
|
||||
|
||||
### Run relevant checks locally
|
||||
|
||||
Run checks before pushes via [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/SKILL.md); report only commands run. After `gh stack sync`, validate immediately; do not merge before checks pass.
|
||||
|
||||
- Match evidence to the surface: focused tests for behavior, snapshots for model or user output, `doc-sync` for docs, build/hygiene and built smokes for published paths, and real-API e2e for provider behavior.
|
||||
- Never default to the full suite or repeat a passing check for commit or push. CI owns exhaustive coverage and the platform matrix; rehearse all locally only by explicit request, for CI diagnosis, or for an irreducibly repository-wide change.
|
||||
- `test:coverage`, not `test`, is the CI coverage gate ([why](docs/testing.md)).
|
||||
Before pushes, use [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/SKILL.md) to choose the smallest diff-covering checks; after `gh stack sync`, validate immediately and never merge before they pass. Report commands only. Match evidence to its surface: focused behavior tests, model/user snapshots, `doc-sync`, build/hygiene plus built smokes for published paths, and real-API e2e for provider behavior. CI owns exhaustive coverage and the platform matrix; run them locally only by request, for CI diagnosis, or for an irreducibly repository-wide change. `test:coverage`, not `test`, is the CI coverage gate ([why](docs/testing.md)).
|
||||
|
||||
## Secrets / .env
|
||||
|
||||
|
||||
@@ -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 apps/cli/README.md
|
||||
README.md: 9d34c374aa6f2de69e9c2b0ca838b6ddb9dd4054
|
||||
README.zh.md: ee6096d365a102d7107baf79be2fb35316c55c1a
|
||||
README.md: d59b8264093dafb0160893c942371a4daa942c14
|
||||
README.zh.md: 6a209f5ad64b38c138ae1712a05ed9e840d9a74f
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The `dsh` command is the product launcher for profiles: ordered stacks of plugin-bundle patch layers under the user's own overrides. [`src/args.ts`](src/args.ts) owns the command grammar, and [`src/bin.ts`](src/bin.ts) loads only the selected runner. Invalid commands, options from another mode, configuration errors, and boot failures exit nonzero.
|
||||
The `dsh` command is the sole supported Node application launcher: profiles are ordered stacks of plugin-bundle patch layers under the user's own overrides. SDK and ACP are profiles, not separate public bins. [`src/args.ts`](src/args.ts) owns the command grammar, and [`src/bin.ts`](src/bin.ts) loads only the selected runner. Invalid commands, options from another mode, configuration errors, and boot failures exit nonzero.
|
||||
|
||||
## Entry modes
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
`dsh` 是 DeepSeek Harness 中用于启动 profile 的命令;profile 由多个插件组合包 patch 层按顺序叠加而成,其上再应用用户自己的覆盖配置。[`src/args.ts`](src/args.ts) 负责命令语法,[`src/bin.ts`](src/bin.ts) 只加载选中的运行器。无效命令、来自其他模式的选项、配置错误和启动失败都会以非零状态退出。
|
||||
`dsh` 是唯一受支持的 Node 应用启动器;profile 由多个插件组合包 patch 层按顺序叠加而成,其上再应用用户自己的覆盖配置。SDK 与 ACP 都是 profile,而不是独立的公开 bin。[`src/args.ts`](src/args.ts) 负责命令语法,[`src/bin.ts`](src/bin.ts) 只加载选中的运行器。无效命令、来自其他模式的选项、配置错误和启动失败都会以非零状态退出。
|
||||
|
||||
## 入口模式
|
||||
|
||||
|
||||
@@ -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 apps/cli/reference/README.md
|
||||
README.md: cac27cbe7d7a9b1fb3a47488d40f8bf82543c34e
|
||||
README.zh.md: d2f1754da0f98af6a565afacf7724f69f84574da
|
||||
README.md: 0f407afa3b06d144681550d5096bf96c498e6451
|
||||
README.zh.md: bf4dc4ca9f49c1801d108234411123de459c0444
|
||||
@@ -80,7 +80,7 @@ The production Web runner needs built package and frontend artifacts (`pnpm run
|
||||
|
||||
Process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain — `SIGTERM` is a supervisor's ordinary stop request and exits 0 on every surface, `SIGINT` reports 130; a second signal forces immediate exit. If one-shot normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed.
|
||||
|
||||
All modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Every profile boot watches valid edits of both `cordis.patch.yml` layers (profile and home) and reapplies them transactionally; a one-shot surface exits through its bounded shutdown, which disposes the watchers.
|
||||
All modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. A `patchReload: live` profile watches valid edits of both `cordis.patch.yml` layers (profile and home) and reapplies them transactionally; a `startup` profile applies them once. A one-shot surface exits through its bounded shutdown, which disposes any live watchers.
|
||||
|
||||
New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads and network access are not confined, while process visibility depends on the selected sandbox backend — bwrap runs commands in a private PID namespace that hides host processes, and Landlock and Seatbelt leave host process visibility unchanged. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one.
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ dsh web --help
|
||||
|
||||
进程关闭时,插件树最多有 5 秒完成 dispose。首次收到 `SIGINT` 或 `SIGTERM` 时会开始优雅排空:`SIGTERM` 是监督进程发出的常规停止请求,在所有运行模式下都以 0 退出;`SIGINT` 则报告 130。第二次收到信号时会立即强制退出。如果一次性运行在正常结束时已经卡在 dispose 阶段,第一次按下 `Ctrl+C` 就会直接升级为强制退出,而不会被忽略。
|
||||
|
||||
所有模式都将运行命令时所在的目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。每次启动 profile 时,系统都会监视 profile 与 home 两个 `cordis.patch.yml` 配置层的有效变更,并以事务方式重新应用;一次性运行模式通过有界关闭流程退出,该流程会先 dispose 监视器。
|
||||
所有模式都将运行命令时所在的目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。`patchReload: live` profile 会监视 profile 与 home 两个 `cordis.patch.yml` 配置层的有效变更,并以事务方式重新应用;`startup` profile 则只应用一次。一次性运行模式通过有界关闭流程退出,该流程会 dispose(资源释放)所有实时监视器。
|
||||
|
||||
新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取和网络访问不受限制,进程可见性则取决于所选沙箱后端——bwrap 在私有 PID 命名空间中运行命令并隐藏宿主进程,Landlock 与 Seatbelt 保持宿主进程可见性不变。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。
|
||||
|
||||
|
||||
@@ -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 docs/architecture.md
|
||||
architecture.md: 6f5a0475f1b5b6818cd831968816d4c630bab3a2
|
||||
architecture.zh.md: a681fc17354150d86acf6ee311f9c9d73ee2b980
|
||||
architecture.md: 06d5163566816eb732b51ce784cd2ab92218d4fd
|
||||
architecture.zh.md: 55162553b62300e5e1bb34bab468cb3956877e56
|
||||
+12
-2
@@ -16,16 +16,18 @@ There is no privileged core to patch: you extend dsh by mounting a plugin beside
|
||||
|
||||
A running `dsh` is a plugin tree composed at boot from ordered layers.
|
||||
|
||||
A **profile** is a named composition stored in the Harness home. It lists the bundles it stacks, holds any out-of-tree plugins it installs, and keeps the user's own `cordis.patch.yml`. `web` and `headless` ship as templates.
|
||||
A **profile** is a named composition stored in the Harness home. It lists the bundles it stacks, holds any out-of-tree plugins it installs, and keeps the user's own `cordis.patch.yml`. `web`, `headless`, `sdk`, and `acp` ship as templates.
|
||||
|
||||
A **bundle** is a distribution format for Cordis config rows and the code they mount, so whatever it inserts stays patchable by the layers above it.
|
||||
|
||||
Each declares itself in its own `package.json` under a `dsh` field: `dsh.profile` lists a profile's bundles, and `dsh.bundle` points at a bundle's patch file.
|
||||
|
||||
[`dsh-base`](../packages/bundle/base/README.md) is the first layer of every profile: model adapters, tools, persistence, sandbox and approval policy, settings, credentials, telemetry. [`dsh-web-app`](../packages/bundle/web-app/README.md) adds the browser application; [`dsh-headless`](../packages/bundle/headless/README.md) adds a one-shot runner with no server at all.
|
||||
[`dsh-base`](../packages/bundle/base/README.md) is the first layer of every profile: model adapters, tools, persistence, sandbox and approval policy, settings, credentials, telemetry. [`dsh-web-app`](../packages/bundle/web-app/README.md) adds the browser application, [`dsh-headless`](../packages/bundle/headless/README.md) adds a one-shot runner with no server, [`dsh-sdk-app`](../packages/bundle/sdk-app/README.md) adds the SDK JSON-RPC server, and [`dsh-acp-app`](../packages/bundle/acp-app/README.md) adds the automation-only ACP server.
|
||||
|
||||
Layers apply to an empty entry list in this order: each bundle in the profile's listed order, then the profile's `cordis.patch.yml`, then the home-level one, then any `--patch` overlay. A patch targets a row by id and replaces its whole config, or inserts new rows.
|
||||
|
||||
Custom profiles default to live patch reload. The shipped `web` profile is live; `headless`, `sdk`, and `acp` apply all layers once at startup because replacing a one-shot or stdio application's dependencies after it owns work would invalidate that lifecycle.
|
||||
|
||||
To see the tree your machine actually boots:
|
||||
|
||||
```sh
|
||||
@@ -36,6 +38,14 @@ Any row it prints can be replaced by a patch of your own.
|
||||
|
||||
Composition mechanics are in [app-boot](../packages/boot/app-boot/README.md#profiles); config fields are in the generated [config catalog](config-catalog.md).
|
||||
|
||||
## Application launch
|
||||
|
||||
Every supported Node application starts at the `dsh` CLI with a named profile. The shipped applications are `dsh web` (the deliberate alias for `--profile web`), `dsh --profile headless`, `dsh --profile sdk`, and `dsh --profile acp`. The TypeScript SDK resolves its same-version `dsh` dependency and selects `sdk`; custom plugin composition remains a profile plus ordered patch files, not another executable or inline application tree.
|
||||
|
||||
Vendored CLIs, build-only and test-only executables, direct in-process plugin mounting, and the private browser WebWorker preview are not Harness application launchers. [`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts) keeps every package bin, executable source, and root demo in an explicit class and rejects a Node application path that bypasses `dsh`.
|
||||
|
||||
The packaged Python SDK runtime is the sole temporary application exception. Its private [`dsh-sdk-python-runtime`](../packages/sdk/python-runtime/README.md) carrier and `dsh-sdk-python-runtime-closure` deploy manifest preserve the current Python API, wire, default `cordis.yml`, environment variables, wheel names, `dsh-jsonrpc-agent-pkg-<platform>-<arch>` executables, sidecars, and platform set. A later Python migration will launch `dsh --profile sdk`, delete the private direct-config carrier, and then rename that executable family to `deepseek-harness-sdk-runtime-<platform>-<arch>`.
|
||||
|
||||
## Core packages
|
||||
|
||||
Here are some core packages that contribute to the Cordis tree.
|
||||
|
||||
+12
-2
@@ -16,16 +16,18 @@
|
||||
|
||||
运行中的 `dsh` 是一棵插件树,由启动时按序叠加的各层组合而成。
|
||||
|
||||
**profile** 是存放在 Harness home 中的具名组装。它列出自己叠放的组合包,存放自己安装的树外插件,并保存用户自己的 `cordis.patch.yml`。`web` 和 `headless` 作为模板随发行版交付。
|
||||
**profile** 是存放在 Harness home 中的具名组装。它列出自己叠放的组合包,存放自己安装的树外插件,并保存用户自己的 `cordis.patch.yml`。`web`、`headless`、`sdk` 和 `acp` 作为模板随发行版交付。
|
||||
|
||||
**组合包**是 Cordis 配置项及其挂载代码的分发格式,因此它插入的内容始终可被其上各层 patch。
|
||||
|
||||
两者都在各自的 `package.json` 中通过 `dsh` 字段声明自己:`dsh.profile` 列出一个 profile 的组合包,`dsh.bundle` 指向一个组合包的 patch 文件。
|
||||
|
||||
[`dsh-base`](../packages/bundle/base/README.zh.md) 是每个 profile 的第一层:模型适配器、工具、持久化、沙箱与审批策略、设置、凭据、遥测。[`dsh-web-app`](../packages/bundle/web-app/README.zh.md) 增加浏览器应用;[`dsh-headless`](../packages/bundle/headless/README.zh.md) 增加一次性运行器,且完全不带服务器。
|
||||
[`dsh-base`](../packages/bundle/base/README.zh.md) 是每个 profile 的第一层:模型适配器、工具、持久化、沙箱与审批策略、设置、凭据、遥测。[`dsh-web-app`](../packages/bundle/web-app/README.zh.md) 增加浏览器应用,[`dsh-headless`](../packages/bundle/headless/README.zh.md) 增加不带服务器的一次性运行器,[`dsh-sdk-app`](../packages/bundle/sdk-app/README.zh.md) 增加 SDK JSON-RPC 服务器,[`dsh-acp-app`](../packages/bundle/acp-app/README.zh.md) 增加仅用于自动化的 ACP 服务器。
|
||||
|
||||
各层按此顺序应用在空条目列表之上:先按 profile 列出的顺序应用每个组合包,然后是 profile 的 `cordis.patch.yml`,然后是 home 级的那份,最后是任意 `--patch` overlay。一条 patch 按 id 定位某个条目并替换其整个 config,或插入新条目。
|
||||
|
||||
自定义 profile 默认实时重载 patch。随附的 `web` profile 使用实时重载;`headless`、`sdk` 和 `acp` 则只在启动时应用一次所有配置层,因为一次性应用或 stdio 应用拥有工作之后,替换其依赖会破坏该生命周期。
|
||||
|
||||
要查看你的机器实际启动的配置树:
|
||||
|
||||
```sh
|
||||
@@ -36,6 +38,14 @@ dsh --profile web --dump-config
|
||||
|
||||
组装机制见 [app-boot](../packages/boot/app-boot/README.zh.md#profiles);配置字段见生成的[配置目录](config-catalog.zh.md)。
|
||||
|
||||
## 应用启动
|
||||
|
||||
所有受支持的 Node 应用都从 `dsh` CLI 与具名 profile 启动。随附应用是 `dsh web`(刻意为 `--profile web` 保留的别名)、`dsh --profile headless`、`dsh --profile sdk` 与 `dsh --profile acp`。TypeScript SDK 会解析其同版本 `dsh` 依赖并选择 `sdk`;自定义插件组合继续由 profile 与有序 patch 文件表达,而不是另一个可执行文件或内联应用树。
|
||||
|
||||
Vendored CLI、仅用于构建和测试的可执行文件、进程内直接挂载插件以及私有浏览器 WebWorker 预览都不属于 Harness 应用启动器。[`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts)将每个包 bin、可执行源码与根 demo 归入显式类别,并拒绝任何绕过 `dsh` 的 Node 应用路径。
|
||||
|
||||
打包后的 Python SDK 运行时是唯一的临时应用例外。其私有 [`dsh-sdk-python-runtime`](../packages/sdk/python-runtime/README.zh.md) 载体与 `dsh-sdk-python-runtime-closure` 部署 manifest 保持当前 Python API、协议格式、默认 `cordis.yml`、环境变量、wheel 包名称、`dsh-jsonrpc-agent-pkg-<platform>-<arch>` 可执行文件、伴随文件及平台集合不变。后续 Python 迁移会改为启动 `dsh --profile sdk`、删除私有直读配置载体,然后把该可执行文件族重命名为 `deepseek-harness-sdk-runtime-<platform>-<arch>`。
|
||||
|
||||
## 核心包
|
||||
|
||||
以下是向 Cordis 树贡献内容的部分核心包。
|
||||
|
||||
+2
-2
@@ -100,6 +100,7 @@
|
||||
"verify-node-next-types": "tsx scripts/verify-node-next-types.ts",
|
||||
"verify-optional-dependency-imports": "tsx scripts/verify-optional-dependency-imports.ts",
|
||||
"verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts",
|
||||
"verify-application-entrypoints": "tsx scripts/verify-application-entrypoints.ts",
|
||||
"verify-client-packages": "tsx scripts/verify-client-packages.ts",
|
||||
"verify-vendored-links": "tsx scripts/verify-vendored-links.ts",
|
||||
"verify-cordis-config": "tsx scripts/verify-cordis-config.ts",
|
||||
@@ -141,13 +142,12 @@
|
||||
"dsh": "node --import tsx/esm apps/cli/src/bin.ts",
|
||||
"demo:code-mode": "node scripts/demo-code-mode.mjs",
|
||||
"demo:cordis": "node scripts/demo-cordis.mjs",
|
||||
"demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml",
|
||||
"demo:acp": "node --import tsx/esm apps/cli/src/bin.ts --profile acp --patch examples/acp-agent/cordis.yml",
|
||||
"mock:llm": "node --import tsx packages/test-support/llm-mock-server/src/bin.ts",
|
||||
"dev:web": "tsx scripts/dev-web.ts --poll",
|
||||
"postinstall": "node scripts/install-lefthook.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@agentclientprotocol/sdk": "1.4.0",
|
||||
"@deepseek-ai/dsh-tool-session-query": "workspace:^",
|
||||
"@stylistic/eslint-plugin": "^5.10.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
|
||||
@@ -88,8 +88,8 @@ describe('gate graph validation', () => {
|
||||
const ids = withPnpmEntrypoint(() => gatesForMode('hygiene').map(subject => subject.id))
|
||||
|
||||
expect(ids).toEqual([
|
||||
'rescope-vendor', 'knip', 'publint', 'constraints', 'dsh-package-licenses',
|
||||
'package-invariants', 'built-package-invariants', 'node-next-types',
|
||||
'rescope-vendor', 'knip', 'publint', 'constraints', 'application-entrypoints',
|
||||
'dsh-package-licenses', 'package-invariants', 'built-package-invariants', 'node-next-types',
|
||||
'optional-dependency-imports', 'client-packages', 'cordis-config',
|
||||
'runtime-closure', 'vendored-links',
|
||||
])
|
||||
@@ -136,6 +136,15 @@ describe('gate graph validation', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
|
||||
'keeps application entrypoint enforcement in %s',
|
||||
(mode) => {
|
||||
const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
|
||||
|
||||
expect(ids).toContain('application-entrypoints')
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps native Windows coverage blocking while retaining the observational inventory', () => {
|
||||
const complete = withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))
|
||||
const observational = withPnpmEntrypoint(() => gatesForMode('ci-windows-observational'))
|
||||
|
||||
@@ -266,6 +266,7 @@ export function gatesForMode(selected: Mode): Gate[] {
|
||||
function ciSharedStaticGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
|
||||
pnpmScript('application-entrypoints', 'verify-application-entrypoints', { label: 'application entrypoints' }),
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
pnpmScript('dsh-package-licenses', 'verify-dsh-package-licenses', { label: 'DSH package licenses' }),
|
||||
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
|
||||
@@ -627,6 +628,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
|
||||
pnpmScript('knip', 'knip'),
|
||||
pnpmScript('publint', 'publint', artifactOptions),
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
pnpmScript('application-entrypoints', 'verify-application-entrypoints', { label: 'application entrypoints' }),
|
||||
pnpmScript('dsh-package-licenses', 'verify-dsh-package-licenses', { label: 'DSH package licenses' }),
|
||||
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
|
||||
builtPackageInvariantsGate(options.artifactNeeds),
|
||||
@@ -696,7 +698,6 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate {
|
||||
'vitest.e2e.config.ts',
|
||||
'examples/headless-agent/tests/keyless-smoke.e2e.ts',
|
||||
'apps/cli/tests/built-bin.e2e.ts',
|
||||
'packages/examples/acp-demo/tests/built-bin.e2e.ts',
|
||||
'packages/host/directory-picker-native/tests/built-worker.e2e.ts',
|
||||
'packages/sdk/server/tests/built-scope-carrier.e2e.ts',
|
||||
'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts',
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/** Application-entrypoint classification and dsh-launch enforcement. */
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { applicationEntrypointViolations } from './verify-application-entrypoints.ts'
|
||||
|
||||
const cleanups: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const path of cleanups.splice(0)) rmSync(path, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function fixture(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-application-entrypoints-'))
|
||||
cleanups.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
function write(root: string, path: string, content: string): void {
|
||||
const target = resolve(root, path)
|
||||
mkdirSync(dirname(target), { recursive: true })
|
||||
writeFileSync(target, content)
|
||||
}
|
||||
|
||||
describe('application entrypoints', () => {
|
||||
it('accepts the repository launcher inventory', () => {
|
||||
expect(applicationEntrypointViolations(resolve(import.meta.dirname, '..'))).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a package-level application bin', () => {
|
||||
const root = fixture()
|
||||
write(root, 'packages/example/app/package.json', JSON.stringify({ bin: { app: 'lib/bin.js' } }))
|
||||
|
||||
expect(applicationEntrypointViolations(root)).toEqual([
|
||||
'packages/example/app/package.json: package bin bypasses the dsh launcher; applications use apps/cli profiles',
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects an unclassified executable source', () => {
|
||||
const root = fixture()
|
||||
write(root, 'packages/example/app/src/bin.ts', '#!/usr/bin/env node\n')
|
||||
|
||||
expect(applicationEntrypointViolations(root)).toEqual([
|
||||
'packages/example/app/src/bin.ts: executable source has no application/build/test classification',
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects an executable at an application package root', () => {
|
||||
const root = fixture()
|
||||
write(root, 'apps/example/rogue.mjs', '#!/usr/bin/env node\n')
|
||||
|
||||
expect(applicationEntrypointViolations(root)).toEqual([
|
||||
'apps/example/rogue.mjs: executable source has no application/build/test classification',
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects an executable at the repository root', () => {
|
||||
const root = fixture()
|
||||
write(root, 'rogue.mjs', '#!/usr/bin/env node\n')
|
||||
|
||||
expect(applicationEntrypointViolations(root)).toEqual([
|
||||
'rogue.mjs: executable source has no application/build/test classification',
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects an unclassified executable in an example workspace', () => {
|
||||
const root = fixture()
|
||||
write(root, 'examples/rogue/src/bin.ts', '#!/usr/bin/env node\n')
|
||||
|
||||
expect(applicationEntrypointViolations(root)).toEqual([
|
||||
'examples/rogue/src/bin.ts: executable source has no application/build/test classification',
|
||||
])
|
||||
})
|
||||
|
||||
it('accepts the temporary private Python carrier source without an npm bin', () => {
|
||||
const root = fixture()
|
||||
write(root, 'packages/sdk/python-runtime/package.json', JSON.stringify({ private: true }))
|
||||
write(root, 'packages/sdk/python-runtime/src/packaged-bin.ts', '#!/usr/bin/env node\n')
|
||||
|
||||
expect(applicationEntrypointViolations(root)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a classified demo wrapper that launches a package entry', () => {
|
||||
const root = fixture()
|
||||
write(root, 'package.json', JSON.stringify({ scripts: { 'demo:code-mode': 'node scripts/demo-code-mode.mjs' } }))
|
||||
write(root, 'scripts/demo-code-mode.mjs', "spawn('node', ['packages/example/app/src/bin.ts'])\n")
|
||||
|
||||
expect(applicationEntrypointViolations(root)).toEqual([
|
||||
'scripts/demo-code-mode.mjs: application demo wrapper must launch apps/cli/src/bin.ts',
|
||||
'scripts/demo-code-mode.mjs: application demo wrapper must not launch a package entry directly',
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects a new root demo until its launch role is classified', () => {
|
||||
const root = fixture()
|
||||
write(root, 'package.json', JSON.stringify({ scripts: { 'demo:new-app': 'dsh --profile new-app' } }))
|
||||
|
||||
expect(applicationEntrypointViolations(root)).toEqual([
|
||||
'package.json scripts.demo:new-app: demo launcher has no explicit dsh or in-process classification',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Enforce dsh profiles as the only supported Node application launcher.
|
||||
* Vendor CLIs, build tools, test tools, and the temporary private Python
|
||||
* runtime carrier are explicit classifications rather than implicit holes.
|
||||
*/
|
||||
|
||||
import { existsSync, globSync, readFileSync } from 'node:fs'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
type ManifestBin = string | Record<string, string>
|
||||
|
||||
interface PackageManifest {
|
||||
readonly bin?: unknown
|
||||
}
|
||||
|
||||
interface RootManifest {
|
||||
readonly scripts?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface DemoPolicy {
|
||||
readonly kind: 'dsh-direct' | 'dsh-wrapper'
|
||||
readonly wrapper?: string
|
||||
}
|
||||
|
||||
/** Public product launcher plus the private build-only WebWorker packer. */
|
||||
const MANIFEST_BIN_ALLOWLIST = new Map<string, ManifestBin>([
|
||||
['apps/cli/package.json', { dsh: 'lib/bin.js' }],
|
||||
['packages/experimental/webworker-packer/package.json', { 'dsh-pack-vfs-image': './bin.js' }],
|
||||
])
|
||||
|
||||
/** Every executable in a Node application workspace has one explicit role. */
|
||||
const EXECUTABLE_SOURCE_ALLOWLIST = new Map<string, string>([
|
||||
['apps/cli/src/bin.ts', 'supported dsh application launcher'],
|
||||
['examples/acp-agent/tests/fixtures/shell/tool-pwsh/driver.ts', 'test-only subprocess driver'],
|
||||
['examples/acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts', 'test-only subprocess driver'],
|
||||
['examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts', 'test-only subprocess driver'],
|
||||
['examples/acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts', 'test-only subprocess driver'],
|
||||
['examples/headless-agent/tests/fixtures/headless-driver.ts', 'test-only subprocess driver'],
|
||||
['examples/headless-agent/tests/fixtures/session-telemetry-otel-driver.ts', 'test-only subprocess driver'],
|
||||
['examples/headless-agent/tests/fixtures/time-context-driver.ts', 'test-only subprocess driver'],
|
||||
['examples/python-sdk-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts', 'test-only subprocess driver'],
|
||||
['packages/experimental/webworker-packer/bin.js', 'private build-only wrapper'],
|
||||
['packages/experimental/webworker-packer/src/bin.ts', 'private build-only implementation'],
|
||||
['packages/sdk/client/tests/fake-runtime.ts', 'test-only SDK runtime peer'],
|
||||
['packages/sdk/python-runtime/src/packaged-bin.ts', 'temporary private Python runtime carrier'],
|
||||
['packages/test-support/llm-mock-server/src/bin.ts', 'test-only model server'],
|
||||
])
|
||||
|
||||
/** Root demos are application wrappers and therefore must visibly select dsh. */
|
||||
const ROOT_DEMO_POLICIES = new Map<string, DemoPolicy>([
|
||||
['demo:acp', { kind: 'dsh-direct' }],
|
||||
['demo:code-mode', { kind: 'dsh-wrapper', wrapper: 'scripts/demo-code-mode.mjs' }],
|
||||
['demo:cordis', { kind: 'dsh-wrapper', wrapper: 'scripts/demo-cordis.mjs' }],
|
||||
])
|
||||
|
||||
const SOURCE_PATTERNS = [
|
||||
'*.ts',
|
||||
'*.js',
|
||||
'*.mjs',
|
||||
'*.cjs',
|
||||
'apps/**/*.ts',
|
||||
'apps/**/*.js',
|
||||
'apps/**/*.mjs',
|
||||
'apps/**/*.cjs',
|
||||
'examples/**/*.ts',
|
||||
'examples/**/*.js',
|
||||
'examples/**/*.mjs',
|
||||
'examples/**/*.cjs',
|
||||
'packages/**/*.ts',
|
||||
'packages/**/*.js',
|
||||
'packages/**/*.mjs',
|
||||
'packages/**/*.cjs',
|
||||
]
|
||||
|
||||
const SOURCE_EXCLUDES = [
|
||||
'**/node_modules/**',
|
||||
'**/lib/**',
|
||||
'**/dist/**',
|
||||
'**/coverage/**',
|
||||
]
|
||||
|
||||
/** Convert a host path from glob output to the repository's slash form. */
|
||||
function repositoryPath(path: string): string {
|
||||
return path.split(sep).join('/')
|
||||
}
|
||||
|
||||
/** Stable comparison for string and object npm `bin` declarations. */
|
||||
function normalizedBin(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') return JSON.stringify(value)
|
||||
if (!isRecord(value)) return undefined
|
||||
const entries = Object.entries(value)
|
||||
if (!entries.every(([, target]) => typeof target === 'string')) return undefined
|
||||
return JSON.stringify(Object.fromEntries(entries.sort(([left], [right]) => left.localeCompare(right))))
|
||||
}
|
||||
|
||||
function manifestBinViolations(root: string): string[] {
|
||||
const failures: string[] = []
|
||||
const manifests = globSync(['apps/*/package.json', 'packages/*/*/package.json'], { cwd: root }).sort()
|
||||
for (const rawPath of manifests) {
|
||||
const path = repositoryPath(rawPath)
|
||||
const manifest = JSON.parse(readFileSync(resolve(root, path), 'utf8')) as PackageManifest
|
||||
if (manifest.bin === undefined) continue
|
||||
const expected = MANIFEST_BIN_ALLOWLIST.get(path)
|
||||
if (expected === undefined) {
|
||||
failures.push(`${path}: package bin bypasses the dsh launcher; applications use apps/cli profiles`)
|
||||
continue
|
||||
}
|
||||
if (normalizedBin(manifest.bin) !== normalizedBin(expected)) {
|
||||
failures.push(`${path}: classified bin must remain ${JSON.stringify(expected)}, got ${JSON.stringify(manifest.bin)}`)
|
||||
}
|
||||
}
|
||||
return failures
|
||||
}
|
||||
|
||||
function executableSourceViolations(root: string): string[] {
|
||||
const failures: string[] = []
|
||||
for (const rawPath of globSync(SOURCE_PATTERNS, { cwd: root, exclude: SOURCE_EXCLUDES }).sort()) {
|
||||
const path = repositoryPath(rawPath)
|
||||
const source = readFileSync(resolve(root, path), 'utf8')
|
||||
if (!source.startsWith('#!')) continue
|
||||
if (!EXECUTABLE_SOURCE_ALLOWLIST.has(path)) {
|
||||
failures.push(`${path}: executable source has no application/build/test classification`)
|
||||
}
|
||||
}
|
||||
return failures
|
||||
}
|
||||
|
||||
function referencesDshCli(source: string): boolean {
|
||||
return source.includes('apps/cli/src/bin.ts')
|
||||
}
|
||||
|
||||
function referencesPackageEntry(source: string): boolean {
|
||||
return /packages\/[^/\s'"`]+\/[^/\s'"`]+\/(?:src|lib)\/[^\s'"`]+/.test(source)
|
||||
}
|
||||
|
||||
function rootDemoViolations(root: string): string[] {
|
||||
const manifestPath = resolve(root, 'package.json')
|
||||
if (!existsSync(manifestPath)) return []
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as RootManifest
|
||||
const failures: string[] = []
|
||||
for (const [name, commandValue] of Object.entries(manifest.scripts ?? {}).sort(([left], [right]) => left.localeCompare(right))) {
|
||||
if (!name.startsWith('demo:')) continue
|
||||
const command = typeof commandValue === 'string' ? commandValue : ''
|
||||
const policy = ROOT_DEMO_POLICIES.get(name)
|
||||
if (policy === undefined) {
|
||||
failures.push(`package.json scripts.${name}: demo launcher has no explicit dsh or in-process classification`)
|
||||
continue
|
||||
}
|
||||
if (policy.kind === 'dsh-direct') {
|
||||
if (!referencesDshCli(command)) failures.push(`package.json scripts.${name}: application demo must launch apps/cli/src/bin.ts`)
|
||||
if (referencesPackageEntry(command)) failures.push(`package.json scripts.${name}: application demo must not launch a package entry directly`)
|
||||
continue
|
||||
}
|
||||
const wrapper = policy.wrapper
|
||||
if (wrapper === undefined || !command.includes(wrapper)) {
|
||||
failures.push(`package.json scripts.${name}: classified wrapper must be ${String(wrapper)}`)
|
||||
continue
|
||||
}
|
||||
const wrapperPath = resolve(root, wrapper)
|
||||
if (!existsSync(wrapperPath)) {
|
||||
failures.push(`${wrapper}: classified demo wrapper is missing`)
|
||||
continue
|
||||
}
|
||||
const source = readFileSync(wrapperPath, 'utf8')
|
||||
if (!referencesDshCli(source)) failures.push(`${wrapper}: application demo wrapper must launch apps/cli/src/bin.ts`)
|
||||
if (referencesPackageEntry(source)) failures.push(`${wrapper}: application demo wrapper must not launch a package entry directly`)
|
||||
}
|
||||
return failures
|
||||
}
|
||||
|
||||
/**
|
||||
* Find unsupported application entrypoints below a repository root.
|
||||
* @param root - repository or test-fixture root.
|
||||
* @returns deterministic path-qualified violations.
|
||||
*/
|
||||
export function applicationEntrypointViolations(root: string): string[] {
|
||||
return [
|
||||
...manifestBinViolations(root),
|
||||
...executableSourceViolations(root),
|
||||
...rootDemoViolations(root),
|
||||
]
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const failures = applicationEntrypointViolations(root)
|
||||
if (failures.length > 0) {
|
||||
console.error('verify-application-entrypoints: unsupported launcher(s):')
|
||||
for (const failure of failures) console.error(` ${failure}`)
|
||||
process.exitCode = 1
|
||||
} else {
|
||||
console.log('verify-application-entrypoints: dsh is the only supported Node application launcher; the private Python carrier remains the temporary exception.')
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user