dsh-edge 0.3.0-alpha.1 → 0.3.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # last confirmed-consistent state. Both languages carry equal authority.
3
3
  # After editing either side, update both and re-record every pair with:
4
4
  # pnpm run doc-pairs -- --write
5
- README.md: 8f0179678a006d175cbb9207577f78654bc9ca69
6
- README.zh.md: f69246647c123e8633138a5032c5556724ab2d22
5
+ README.md: 50dc27b1d0be05e08f3ffce591a7e33257e1ac1c
6
+ README.zh.md: 698b8511580befa735b51f41a20cf3a51b35cf77
package/README.md CHANGED
@@ -10,7 +10,7 @@ The checked-in Wrangler configuration exposes two deployment targets from the sa
10
10
 
11
11
  The runtime runs persistent conversations through the upstream Cordis-composed `ReactLoopAgent`, `AgentRegistry`, `LlmRuntime`, `ToolRuntime`, `SystemPrompt`, `SessionStore`, and `SessionPersistence`. Edge code only binds a request-scoped DeepSeek adapter and maps one native DSH `bash` tool definition onto Cloudflare Computer. Durable Object SQLite implements the upstream persistence backend contract; `PersistenceCoordinator` still owns write-behind, revisions, resume preparation, and crash recovery. Model history is projected from canonical events rather than persisted separately.
12
12
 
13
- The browser is the upstream Web shell and upstream client-plugin bundles. A build-time assembler derives the browser roster from the upstream base and Web bundle configs, injects the standard `window.__DSH_BOOT__` graph, and publishes the result as Cloudflare static assets. The Durable Object implements the supported upstream `ApiProxy` methods through the standard HTTP carrier and supplies the two upstream downlinks as hibernatable WebSockets. The upstream image composer, gallery, lightbox, attachment wire contract, and DeepSeek serializer are reused unchanged; the storage seam selects private R2 for new permanent deployments and bounded Durable Object storage for temporary deployments. Edge excludes client plugins whose host domains are absent instead of forking their UI code; this includes session-log export until its server endpoint exists. A small Edge-owned login shell protects the upstream UI and protocol without changing either one. Optional local-host plugins remain unavailable.
13
+ The browser is the upstream Web shell and upstream client-plugin bundles. A build-time assembler derives the browser roster from the upstream base and Web bundle configs, injects the standard `window.__DSH_BOOT__` graph, and publishes the result as Cloudflare static assets. The Durable Object implements the supported upstream `ApiProxy` methods through the standard HTTP carrier and supplies the two upstream downlinks as hibernatable WebSockets. The upstream image composer, gallery, lightbox, attachment wire contract, and DeepSeek serializer are reused unchanged; the storage seam selects private R2 for new permanent deployments, bounded Durable Object storage for temporary deployments, and a one-time owner choice for pre-0.3 Workers. Edge excludes client plugins whose host domains are absent instead of forking their UI code; this includes session-log export until its server endpoint exists. A small Edge-owned login shell protects the upstream UI and protocol without changing either one. Optional local-host plugins remain unavailable.
14
14
 
15
15
  ## Run locally
16
16
 
@@ -93,13 +93,13 @@ This reference separates code that runs natively in Workers, code adapted at an
93
93
  | Bash tool | Node subprocess, sandbox, terminal, and job services | Adapted at the native tool seam | Register an upstream `ToolDefinition`, but execute its body through the configured Computer workspace backend and just-bash. The default direct backend runs inside the owner Durable Object with hardened interpreter limits and no network command; adding a `LOADER` binding selects Computer's isolated Worker Shell backend. Native tool cancellation sends `SIGINT` through the Computer execution handle. Deployment configuration supplies an explicit default timeout and caller-selectable ceiling, while `timedOut` reports the deadline independently from exit and cancellation status. Native binaries, background processes, PTYs, and arbitrary Linux behavior are unavailable. |
94
94
  | Workspace filesystem | Local filesystem services and host paths | Adapted | Store `/workspace` in the owner's SQLite-backed Durable Object VFS. |
95
95
  | Session persistence | `SessionPersistence` service, `PersistenceCoordinator`, and local JSONL/SQLite backends | Native backend adaptation | Reuse the upstream service and coordinator ownership. Implement storage primitives over Durable Object SQL with the upstream header/event mapping. One Edge-only table retains empty session headers across transparent hibernation and is removed when canonical rows materialize; no Edge turn or message schema exists. Internal coordinator helpers validate the bounded replay loader and abandon a failed unmaterialized creation before disposal. |
96
- | Settings and credentials | File-backed settings, launch environment, and credential services | Read-only edge projection | Resolve the Worker secret per operation; never persist or return the literal key. Blank secrets are unconfigured, while surrounding whitespace is removed before use. `credentials.describe` reports only whether `DEEPSEEK_API_KEY` is configured and that its read-only source is `worker-secret`. The built-in `dsh-edge` preset projects its effective release, shell/VFS, model, limits, credential state, prompt, and tools through the upstream read-only composition viewer. Writable settings and authenticated per-user secret storage remain open. |
96
+ | Settings and credentials | File-backed settings, launch environment, and credential services | Read-only edge projection | Resolve the Worker secret per operation; never persist or return the literal key. Blank secrets are unconfigured, while surrounding whitespace is removed before use. `credentials.describe` reports only whether `DEEPSEEK_API_KEY` is configured and that its read-only source is `worker-secret`. The built-in `dsh-edge` preset projects its effective release, shell/VFS, deployment-default model, runtime-derived upstream model catalog, per-session selection scope, limits, credential state, prompt, and tools through the upstream read-only composition viewer. Writable settings and authenticated per-user secret storage remain open. |
97
97
  | Host boot and plugins | Node command line, Cordis profile loading, package resolution, and HMR | Explicit Edge composition | Keep the local boot profile out of Workerd. Build immutable client bundles ahead of deployment; exclude HMR and host domains that the Edge `ApiProxy` does not expose. |
98
98
  | DSH transport | Typed HTTP RPC plus mux and host WebSocket downlinks | Reused with an Edge server implementation | Use the upstream fetch carrier for unary methods and preserve its envelopes, schemas, projections, lazy blank-session behavior, bounded content search, prompt and queue mutations, workspace mutations, queue snapshots, and event frames. Durable Object WebSocket hibernation owns both downlinks; mux reconnects replay pending live inbox state, while REST/SSE routes remain a diagnostic compatibility path. |
99
99
  | Workspace registry | Storage-domain global state plus `WorkspaceRecord` rows | Native backend adaptation | Keep the upstream global and record value shapes, including manual session order and archive membership, but map their physical keys and atomic writes to Durable Object storage. Edge constrains the registry to the one native `/workspace` VFS; rename, delete, recreation, and session reordering retain the upstream RPC and Host-frame semantics. |
100
100
  | Existing Web UI | Runtime-loaded shell and `dsh.client` plugin graph | Reused with generic composition fallbacks | Assemble the upstream shell and supported upstream client bundles as Worker assets. Shared slot-occupancy rules hide actions whose provider is absent; Cloudflare serves ordinary assets directly, while `/`, `/login`, and `/api/*` enter the Worker for owner access control. The assembled asset policy prevents every direct or SPA-fallback shell alias from being framed. |
101
101
  | Other tools | Web Search, filesystem editor tools, MCP, skills, workflows, jobs, and subagents | Search ported; others not ported | Reuse upstream DeepSeek Web Search with its 30-second tool-call timeout. Add the remaining tools individually against Worker-compatible capabilities; do not advertise unavailable host behavior. |
102
- | Attachments | Local attachment storage, upstream image references, composer, gallery, lightbox, and provider conversion | Adapted at the native storage seam | Reuse upstream `AttachmentStore`, admission, protocol, authorization, UI, and DeepSeek conversion unchanged. Store immutable PNG/JPEG bytes under their SHA-256 identities in private R2 for new permanent deployments or in a 64 MiB, 512 KiB-chunked DO fallback for temporary deployments; session events retain only upstream refs. The first backend is pinned per owner instance so claiming or upgrading cannot strand existing references. |
102
+ | Attachments | Local attachment storage, upstream image references, composer, gallery, lightbox, and provider conversion | Adapted at the native storage seam | Reuse upstream `AttachmentStore`, admission, protocol, authorization, UI, and DeepSeek conversion unchanged. Store immutable PNG/JPEG bytes under their SHA-256 identities in private R2 for new permanent deployments or in a 64 MiB, 512 KiB-chunked DO backend for temporary deployments and owners who select it while upgrading a pre-attachment Worker; session events retain only upstream refs. The first backend is pinned per owner instance so claiming or upgrading cannot strand existing references. |
103
103
  | Authentication and tenancy | Local trusted-user boundary | Single-owner adaptation | Require one high-entropy Worker secret, exchange it for a signed 30-day HttpOnly `SameSite=Strict` cookie, and route every accepted request to one fixed owner object. This intentionally provides no registration, user database, roles, or multi-tenant routing. |
104
104
 
105
105
  The browser request path is:
@@ -109,7 +109,7 @@ Cloudflare static assets -> upstream Web shell + client plugin graph
109
109
  -> POST /api/session.create through the upstream HTTP carrier
110
110
  -> host/workspace-changed + session/subscribed over Durable Object WebSockets
111
111
  -> POST /api/session.prompt with the client rpcId
112
- -> upstream image admission validates and stores immutable bytes in private R2
112
+ -> upstream image admission validates and stores immutable bytes in the selected R2 or DO backend
113
113
  -> canonical session events retain upstream sha256 attachment refs only
114
114
  -> AgentRegistry live lookup or resume
115
115
  -> sessionPersistence.prepare through PersistenceCoordinator on cold resume
@@ -135,7 +135,7 @@ The local integration check uses an SSE stand-in and the real Wrangler, Durable
135
135
 
136
136
  ## API-key boundary
137
137
 
138
- `DEEPSEEK_API_KEY` from `.dev.vars` is the local credential source. A read-only Edge provider exposes that Worker secret through the upstream `ctx.credentials` service for each chat or search operation without writing it to Durable Object storage, the VFS, session events, or responses. It removes surrounding whitespace and treats a blank value as unconfigured. `DEEPSEEK_BASE_URL` controls chat and must be an HTTP(S) URL without URL userinfo; its read-only browser projection omits query and fragment components that may carry gateway credentials. `DEEPSEEK_SEARCH_BASE_URL` independently controls the Anthropic-compatible Messages endpoint used by DeepSeek native search, defaults to `https://api.deepseek.com/anthropic/v1`, and must be an HTTP(S) URL without userinfo, query, or fragment. Edge mounts the upstream `web_search` tool, its 30-second tool-call timeout policy, and structured Web result presentation; `web_fetch` remains disabled because the runtime has no arbitrary-URL network policy. Search requests do not follow redirects. `DEEPSEEK_MODEL` selects a validated chat model id and defaults to `deepseek-v4-flash`. `DEEPSEEK_REASONING_EFFORT` accepts `off`, `low`, `high`, or `max` and defaults to `off`. `DEEPSEEK_MAX_OUTPUT_TOKENS` optionally overrides the 8,192-token chat default and must be a positive safe integer. `DEEPSEEK_STREAM_IDLE_TIMEOUT_MS` optionally overrides the 120,000 ms chat default and must be a positive integer no greater than 2,147,483,647. Invalid deployment configuration fails before session lookup or the SSE response opens.
138
+ `DEEPSEEK_API_KEY` from `.dev.vars` is the local credential source. A read-only Edge provider exposes that Worker secret through the upstream `ctx.credentials` service for each chat or search operation without writing it to Durable Object storage, the VFS, session events, or responses. It removes surrounding whitespace and treats a blank value as unconfigured. `DEEPSEEK_BASE_URL` controls chat and must be an HTTP(S) URL without URL userinfo; its read-only browser projection omits query and fragment components that may carry gateway credentials. `DEEPSEEK_SEARCH_BASE_URL` independently controls the Anthropic-compatible Messages endpoint used by DeepSeek native search, defaults to `https://api.deepseek.com/anthropic/v1`, and must be an HTTP(S) URL without userinfo, query, or fragment. Edge mounts the upstream `web_search` tool, its 30-second tool-call timeout policy, and structured Web result presentation; `web_fetch` remains disabled because the runtime has no arbitrary-URL network policy. Search requests do not follow redirects. `DEEPSEEK_MODEL` selects the validated deployment default and defaults to `deepseek-v4-flash`; each session can choose another entry from the upstream provider catalog. `DEEPSEEK_REASONING_EFFORT` accepts `off`, `low`, `high`, or `max` and defaults to `off`. `DEEPSEEK_MAX_OUTPUT_TOKENS` optionally overrides the 8,192-token chat default and must be a positive safe integer. `DEEPSEEK_STREAM_IDLE_TIMEOUT_MS` optionally overrides the 120,000 ms chat default and must be a positive integer no greater than 2,147,483,647. Invalid deployment configuration fails before session lookup or the SSE response opens.
139
139
 
140
140
  `DSH_EDGE_DEFAULT_COMMAND_TIMEOUT_MS` applies to every Computer command that omits a caller timeout, and `DSH_EDGE_MAX_COMMAND_TIMEOUT_MS` limits caller-selected values. Both default to 120,000 ms, must be positive integers no greater than 2,147,483,647, and the default cannot exceed the maximum.
141
141
 
@@ -167,7 +167,7 @@ npx dsh-edge upgrade
167
167
 
168
168
  If the installed version is a 0.2 alpha, promote it to the stable channel once with `npx dsh-edge@latest upgrade`; prerelease deployments otherwise remain on `next`. The Edge settings page derives the channel from the installed version and copies the matching command.
169
169
 
170
- The installer asks for the runtime before the account. The recommended `Free — Direct Shell` mode works on Workers Free and can use a detected Cloudflare account, open Cloudflare sign-in or registration, or create a temporary account without login. `Isolated — Dynamic Worker` requires Workers Paid and therefore offers only a detected or newly authenticated account. Cloudflare does not expose a reliable local entitlement check for Worker Loader, so an isolated install lets Cloudflare authorize the upload and turns a rejection into a choice between enabling Workers Paid and using direct mode. For a new permanent account installation, the installer creates or reuses a private `<worker-name>-attachments` R2 bucket and writes only its binding to the generated private Wrangler config. It never deletes the bucket on deployment failure. R2 must be enabled for the selected account; otherwise the installer provides the activation/retry path. Temporary accounts use a 64 MiB Durable Object attachment backend and support the same upstream image UI. Claiming preserves that backend and its existing image history; automatic migration to R2 is not implemented. Each new deployment records an explicit attachment-storage marker. Before updating an existing Worker, the installer inspects every active version, preserves the marked R2-or-DO choice, initializes an unmarked pre-attachment release on private R2, and refuses a mixed rollout rather than guessing.
170
+ The installer asks for the runtime before the account. The recommended `Free — Direct Shell` mode works on Workers Free and can use a detected Cloudflare account, open Cloudflare sign-in or registration, or create a temporary account without login. `Isolated — Dynamic Worker` requires Workers Paid and therefore offers only a detected or newly authenticated account. Cloudflare does not expose a reliable local entitlement check for Worker Loader, so an isolated install lets Cloudflare authorize the upload and turns a rejection into a choice between enabling Workers Paid and using direct mode. For a new permanent account installation, the installer creates or reuses a private `<worker-name>-attachments` R2 bucket and writes only its binding to the generated private Wrangler config. It never deletes the bucket on deployment failure. R2 Standard has an included monthly free tier, but Cloudflare requires the selected account to enable its separate usage-based R2 subscription through the Dashboard checkout. The installer checks R2 before collecting Worker secrets. Cloudflare error `10042` opens an account-specific recovery choice: retry after activation, cancel, or safely switch an unmarked pre-attachment Worker to DO storage. A new or already R2-pinned deployment cannot switch backends because doing so could violate the documented storage decision or strand references. Temporary accounts use a 64 MiB Durable Object attachment backend and support the same upstream image UI. Claiming preserves that backend and its existing image history; automatic migration to R2 is not implemented. Each new deployment records an explicit attachment-storage marker. Before updating an existing Worker, the installer inspects every active version and preserves a marked or bound R2-or-DO choice. A Worker from before image support has neither marker nor attachment binding and cannot contain image references, so its first 0.3 upgrade asks once between no-setup 64 MiB DO storage and private R2. The chosen backend is then pinned. Mixed active rollouts are refused rather than guessed.
171
171
 
172
172
  The remaining prompts select a Worker name, generate or accept the owner access key, collect the DeepSeek API key through hidden input, and show a final cost summary. A temporary-account install also asks the user to accept Cloudflare's Terms of Service and Privacy Policy explicitly. An existing Worker is never overwritten without confirmation. The installer passes both credentials through a mode-`0600` temporary secrets file and gives Wrangler only an allowlisted runtime environment plus the Cloudflare authentication selected for that command; unrelated ambient keys, tokens, passwords, secrets, and Node injection options do not reach the child. It removes the secret file after the command and discovers the resulting URL from Wrangler's structured output. Deployment output is hidden behind one progress indicator by default; add `--verbose` to either command to inspect Wrangler diagnostics.
173
173
 
@@ -187,7 +187,7 @@ pnpm --filter dsh-edge example:install
187
187
  - `GET /login` renders the Edge-owned owner form; `POST /api/auth/login` exchanges the configured access key for a signed cookie, `GET /api/auth/session` reports cookie validity, and `POST /api/auth/logout` clears it.
188
188
  - `GET /api/events.mux` and `GET /api/events.host` upgrade to the upstream downlink WebSockets. The Durable Object serializes each socket's channel and verified owner-session expiry as its hibernation attachment, closes it at that expiry through an alarm, and reconstructs canonical sessions plus retained blank headers from Durable Object SQL. The mux stream publishes a complete `session/queue` snapshot after each committed inbox splice and sends pending live inbox baselines when a client reconnects.
189
189
  - `POST /api/commands/list` implements the upstream generated-Remote envelope with an empty catalog because the Edge preset registers no human commands.
190
- - `GET /api/health` returns the public package-and-mode release identifier and configured attachment default (`private-r2` or `temporary-do`), and validates owner authentication, the deployment-scoped DeepSeek credential, model and transport choices, and the command-timeout policy before reporting the runtime components as ready. It does not call the provider, Durable Object, R2, VFS, or shell. The authenticated agent-preset projection reports the actual backend pinned by the owner Durable Object and the temporary storage cap.
190
+ - `GET /api/health` returns the public package-and-mode release identifier and configured attachment default (`private-r2` or `temporary-do`), and validates owner authentication, the deployment-scoped DeepSeek credential, model and transport choices, and the command-timeout policy before reporting the runtime components as ready. It does not call the provider, Durable Object, R2, VFS, or shell. The authenticated agent-preset projection reports the actual backend pinned by the owner Durable Object, the temporary storage cap, the deployment-default model, and the runtime-derived upstream model catalog with session selection scope.
191
191
  - `PUT /api/workspace/file?path=/workspace/...` writes a UTF-8 file.
192
192
  - `GET /api/workspace/file?path=/workspace/...` reads a UTF-8 file.
193
193
  - `DELETE /api/workspace/file?path=/workspace/...` removes a file.
package/README.zh.md CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  该运行时通过上游 Cordis 组合的 `ReactLoopAgent`、`AgentRegistry`、`LlmRuntime`、`ToolRuntime`、`SystemPrompt`、`SessionStore` 和 `SessionPersistence` 运行持久对话。Edge 代码只绑定请求作用域的 DeepSeek 适配器,并把一个原生 DSH `bash` 工具定义映射到 Cloudflare Computer。Durable Object SQLite 实现上游持久化后端约定,write-behind、revision、恢复准备和崩溃恢复仍由 `PersistenceCoordinator` 负责。模型历史从 canonical 事件投影,不再单独持久化。
12
12
 
13
- 浏览器直接使用上游 Web shell 和上游客户端插件包。构建期 assembler 根据上游 base 与 Web 组合包配置推导浏览器 roster,注入标准 `window.__DSH_BOOT__` graph,并把结果发布为 Cloudflare 静态资源。Durable Object 通过标准 HTTP carrier 实现受支持的上游 `ApiProxy` 方法,并以支持休眠的 WebSocket 提供两条上游 downlink。上游图片 composer、gallery、lightbox、attachment wire contract 与 DeepSeek serializer 保持原样复用;storage seam 会为新的永久部署选择私有 R2,为临时部署选择有界 Durable Object storageEdge 会排除缺少对应 host domain 的客户端插件,而不会 fork 其 UI 代码;在服务端 endpoint 可用前,session log export 也属于排除项。一个很小的 Edge 登录外壳会保护上游 UI 与协议,不修改两者本身。可选的本地 host 插件仍不可用。
13
+ 浏览器直接使用上游 Web shell 和上游客户端插件包。构建期 assembler 根据上游 base 与 Web 组合包配置推导浏览器 roster,注入标准 `window.__DSH_BOOT__` graph,并把结果发布为 Cloudflare 静态资源。Durable Object 通过标准 HTTP carrier 实现受支持的上游 `ApiProxy` 方法,并以支持休眠的 WebSocket 提供两条上游 downlink。上游图片 composer、gallery、lightbox、attachment wire contract 与 DeepSeek serializer 保持原样复用;storage seam 会为新的永久部署选择私有 R2,为临时部署选择有界 Durable Object storage,并让 0.3 之前的 Worker 在升级时由 owner 做一次选择。Edge 会排除缺少对应 host domain 的客户端插件,而不会 fork 其 UI 代码;在服务端 endpoint 可用前,session log export 也属于排除项。一个很小的 Edge 登录外壳会保护上游 UI 与协议,不修改两者本身。可选的本地 host 插件仍不可用。
14
14
 
15
15
  ## 本地运行
16
16
 
@@ -93,13 +93,13 @@ Session listing 同样有界:`GET /api/sessions?after=SESSION_ID&limit=COUNT`
93
93
  | Bash tool | Node subprocess、sandbox、terminal 和 job services | 在原生 tool seam 上适配 | 注册上游 `ToolDefinition`,但通过配置的 Computer workspace backend 和 just-bash 执行其 body。默认 direct backend 在 owner Durable Object 内运行,启用 hardened interpreter limits 且不提供网络命令;添加 `LOADER` binding 后会选择 Computer 的 isolated Worker Shell backend。原生 tool cancellation 会通过 Computer execution handle 发送 `SIGINT`。部署配置提供明确的默认 timeout 与调用方可选值上限,`timedOut` 则独立于 exit 与 cancellation status 报告 deadline。不支持原生二进制、后台进程、PTY 和任意 Linux 行为。 |
94
94
  | Workspace filesystem | 本地 filesystem services 和 host paths | 适配 | 在 owner 基于 SQLite 的 Durable Object VFS 中保存 `/workspace`。 |
95
95
  | Session persistence | `SessionPersistence` service、`PersistenceCoordinator` 及本地 JSONL/SQLite backends | 原生 backend 适配 | 复用上游 service 及 coordinator 的职责划分,在 Durable Object SQL 上实现存储原语,并使用上游 header/event 映射。一个 Edge 独有表会在透明休眠期间保留 empty session header,并在 canonical rows 物化时删除;Edge 不定义 turn 或 message schema。内部 coordinator helper 负责校验有界 replay loader,并在 disposal 前放弃失败且尚未物化的创建。 |
96
- | Settings and credentials | 基于文件的 settings、launch environment 和 credential services | Edge 只读投影 | 为每次操作解析 Worker secret,绝不持久化或返回 literal key。空白 secret 会被视为未配置,使用前会移除首尾空白。`credentials.describe` 只报告 `DEEPSEEK_API_KEY` 是否已配置,以及其只读来源为 `worker-secret`。内置 `dsh-edge` preset 会通过上游只读 composition viewer 投影实际 release、shell/VFSmodel、limits、credential state、prompt 与 tools。可写 settings 和经过身份认证的用户级 secret storage 尚未实现。 |
96
+ | Settings and credentials | 基于文件的 settings、launch environment 和 credential services | Edge 只读投影 | 为每次操作解析 Worker secret,绝不持久化或返回 literal key。空白 secret 会被视为未配置,使用前会移除首尾空白。`credentials.describe` 只报告 `DEEPSEEK_API_KEY` 是否已配置,以及其只读来源为 `worker-secret`。内置 `dsh-edge` preset 会通过上游只读 composition viewer 投影实际 release、shell/VFS、部署默认模型、runtime 实际读取的上游 model catalogper-session 选择范围、limits、credential state、prompt 与 tools。可写 settings 和经过身份认证的用户级 secret storage 尚未实现。 |
97
97
  | Host boot and plugins | Node 命令行、Cordis profile loading、package resolution 和 HMR | 显式 Edge composition | 不在 Workerd 中运行本地 boot profile。部署前构建 immutable 客户端包,并排除 HMR 及 Edge `ApiProxy` 未暴露的 host domain。 |
98
98
  | DSH transport | Typed HTTP RPC 加 mux/host WebSocket downlink | 复用并提供 Edge 服务端实现 | 对 unary method 使用上游 fetch carrier,并保留其 envelope、schema、projection、lazy blank-session 行为、有界内容搜索、prompt 与 queue mutation、workspace mutation、queue snapshot 和 event frame。两条 downlink 都由 Durable Object WebSocket 休眠机制持有;mux 重连会重放 live inbox 的待处理状态,REST/SSE 路由则保留为诊断兼容路径。 |
99
99
  | Workspace registry | Storage-domain global state 加 `WorkspaceRecord` rows | 原生 backend 适配 | 保持上游 global 和 record value shape,包括手动 session 顺序与 archive membership;仅把物理 key 和原子写入映射到 Durable Object storage。Edge 把 registry 限制为一个原生 `/workspace` VFS;rename、delete、recreate 与 session reorder 保持上游 RPC 和 Host-frame 语义。 |
100
100
  | Existing Web UI | 运行时加载的 shell 和 `dsh.client` 插件 graph | 复用并采用通用 composition fallback | 把上游 shell 和受支持的上游客户端包组装成 Worker 静态资源;共享的 slot occupancy 规则会隐藏缺少 provider 的 action。Cloudflare 直接提供普通资源,`/`、`/login` 与 `/api/*` 则进入 Worker 执行 owner access control。组装后的 asset policy 会阻止所有直接或 SPA-fallback shell alias 被嵌入 frame。 |
101
101
  | Other tools | Web Search、filesystem editor tools、MCP、skills、workflows、jobs 和 subagents | Search 已移植;其他未移植 | 复用上游 DeepSeek Web Search 及其 30 秒 tool-call timeout。逐个针对 Worker-compatible capabilities 增加其余工具,不宣称不可用的 host 行为。 |
102
- | Attachments | 本地 attachment storage、上游 image reference、composer、gallery、lightbox 与 provider conversion | 在原生 storage seam 上适配 | 原样复用上游 `AttachmentStore`、admission、协议、授权、UI 与 DeepSeek conversion。PNG/JPEG 不可变字节按 SHA-256 identity 存入新永久部署的私有 R2,或存入临时部署 64 MiB、按 512 KiB 分块的 DO fallback;session event 只保留上游 ref。每个 owner instance 首次选择的 backend 会被固定,认领或升级不会让既有引用失联。 |
102
+ | Attachments | 本地 attachment storage、上游 image reference、composer、gallery、lightbox 与 provider conversion | 在原生 storage seam 上适配 | 原样复用上游 `AttachmentStore`、admission、协议、授权、UI 与 DeepSeek conversion。PNG/JPEG 不可变字节按 SHA-256 identity 存入新永久部署的私有 R2,或存入临时部署以及升级旧版 Worker 时由 owner 选择的 64 MiB、按 512 KiB 分块的 DO backend;session event 只保留上游 ref。每个 owner instance 首次选择的 backend 会被固定,认领或升级不会让既有引用失联。 |
103
103
  | Authentication and tenancy | 本地 trusted-user boundary | 单 owner 适配 | 要求一个高熵 Worker secret,把它交换为带签名、有效期 30 天的 HttpOnly `SameSite=Strict` cookie,并把所有已接纳请求路由到一个固定 owner object。这里刻意不提供注册、用户数据库、角色或多租户路由。 |
104
104
 
105
105
  浏览器请求路径是:
@@ -109,7 +109,7 @@ Cloudflare static assets -> upstream Web shell + client plugin graph
109
109
  -> POST /api/session.create through the upstream HTTP carrier
110
110
  -> host/workspace-changed + session/subscribed over Durable Object WebSockets
111
111
  -> POST /api/session.prompt with the client rpcId
112
- -> 上游图片 admission 校验图片,并把不可变字节存入私有 R2
112
+ -> 上游图片 admission 校验图片,并把不可变字节存入所选 R2 或 DO backend
113
113
  -> canonical session event 只保留上游 sha256 attachment ref
114
114
  -> AgentRegistry live lookup or resume
115
115
  -> sessionPersistence.prepare through PersistenceCoordinator on cold resume
@@ -135,7 +135,7 @@ Cloudflare static assets -> upstream Web shell + client plugin graph
135
135
 
136
136
  ## API key 边界
137
137
 
138
- `.dev.vars` 中的 `DEEPSEEK_API_KEY` 是本地 credential source。只读 Edge provider 会通过上游 `ctx.credentials` service 为每次 chat 或 search 操作提供该 Worker secret,但不会将它写入 Durable Object storage、VFS、session event 或 response。Provider 会移除首尾空白,并把空白值视为未配置。`DEEPSEEK_BASE_URL` 控制 chat,必须是不含 URL userinfo 的 HTTP(S) URL;它的只读 browser 投影会省略可能携带 gateway credential 的 query 与 fragment。`DEEPSEEK_SEARCH_BASE_URL` 独立控制 DeepSeek native search 使用的 Anthropic-compatible Messages endpoint,默认为 `https://api.deepseek.com/anthropic/v1`,且必须是不含 userinfo、query 与 fragment 的 HTTP(S) URL。Edge 会挂载上游 `web_search` tool、它的 30 秒 tool-call timeout policy 与结构化 Web result presentation;由于 runtime 尚无 arbitrary-URL network policy,`web_fetch` 保持禁用。Search request 不会跟随 redirect。`DEEPSEEK_MODEL` 选择经过校验的 chat model id,默认为 `deepseek-v4-flash`。`DEEPSEEK_REASONING_EFFORT` 接受 `off`、`low`、`high` 或 `max`,默认为 `off`。`DEEPSEEK_MAX_OUTPUT_TOKENS` 可以覆盖默认的 8,192-token chat 上限,且必须是正安全整数。`DEEPSEEK_STREAM_IDLE_TIMEOUT_MS` 可以覆盖默认的 120,000 ms chat 超时,且必须是小于等于 2,147,483,647 的正整数。部署配置无效时,会在查询 session 或打开 SSE response 前失败。
138
+ `.dev.vars` 中的 `DEEPSEEK_API_KEY` 是本地 credential source。只读 Edge provider 会通过上游 `ctx.credentials` service 为每次 chat 或 search 操作提供该 Worker secret,但不会将它写入 Durable Object storage、VFS、session event 或 response。Provider 会移除首尾空白,并把空白值视为未配置。`DEEPSEEK_BASE_URL` 控制 chat,必须是不含 URL userinfo 的 HTTP(S) URL;它的只读 browser 投影会省略可能携带 gateway credential 的 query 与 fragment。`DEEPSEEK_SEARCH_BASE_URL` 独立控制 DeepSeek native search 使用的 Anthropic-compatible Messages endpoint,默认为 `https://api.deepseek.com/anthropic/v1`,且必须是不含 userinfo、query 与 fragment 的 HTTP(S) URL。Edge 会挂载上游 `web_search` tool、它的 30 秒 tool-call timeout policy 与结构化 Web result presentation;由于 runtime 尚无 arbitrary-URL network policy,`web_fetch` 保持禁用。Search request 不会跟随 redirect。`DEEPSEEK_MODEL` 选择经过校验的部署默认模型,默认为 `deepseek-v4-flash`;每个 session 可以从上游 provider catalog 选择其他条目。`DEEPSEEK_REASONING_EFFORT` 接受 `off`、`low`、`high` 或 `max`,默认为 `off`。`DEEPSEEK_MAX_OUTPUT_TOKENS` 可以覆盖默认的 8,192-token chat 上限,且必须是正安全整数。`DEEPSEEK_STREAM_IDLE_TIMEOUT_MS` 可以覆盖默认的 120,000 ms chat 超时,且必须是小于等于 2,147,483,647 的正整数。部署配置无效时,会在查询 session 或打开 SSE response 前失败。
139
139
 
140
140
  `DSH_EDGE_DEFAULT_COMMAND_TIMEOUT_MS` 会应用到每个未指定调用方 timeout 的 Computer 命令,`DSH_EDGE_MAX_COMMAND_TIMEOUT_MS` 则限制调用方选择的值。两者都默认为 120,000 ms,必须是小于等于 2,147,483,647 的正整数,且默认值不能超过最大值。
141
141
 
@@ -167,7 +167,7 @@ npx dsh-edge upgrade
167
167
 
168
168
  如果当前安装的是 0.2 alpha,需要执行一次 `npx dsh-edge@latest upgrade` 晋级到稳定渠道;其他预发布部署仍跟随 `next`。Edge 设置页会根据已安装版本推导渠道,并复制匹配的命令。
169
169
 
170
- 安装器会先询问运行时,再询问账户。推荐的 `Free — Direct Shell` 模式可在 Workers Free 上运行,并可使用检测到的 Cloudflare 账户、打开 Cloudflare 登录或注册,也可在不登录的情况下创建临时账户。`Isolated — Dynamic Worker` 需要 Workers Paid,因此只提供已检测到或新认证的账户。Cloudflare 没有提供可靠的本地 Worker Loader entitlement 检查;isolated 安装会由 Cloudflare 对上传进行授权,并在被拒绝时提示启用 Workers Paid 或改用 direct 模式。对于新的永久账户安装,安装器会创建或复用私有 `<worker-name>-attachments` R2 bucket,并只把 binding 写入生成的私有 Wrangler 配置;部署失败时绝不会删除 bucket。所选账户必须已启用 R2,否则安装器会给出启用与重试路径。临时账户使用 64 MiB Durable Object attachment backend,并支持相同的上游图片 UI。认领会保留这个 backend 与已有图片历史;自动迁移到 R2 尚未实现。每个新部署都会记录明确的 attachment-storage marker。在更新已有 Worker 前,安装器会检查所有 active version,保留 marker 指定的 R2 DO 选择;没有 marker 的旧版 pre-attachment 部署会按永久 R2 初始化;若 rollout 混用两种 backend,则会拒绝猜测并要求先完成 rollout。
170
+ 安装器会先询问运行时,再询问账户。推荐的 `Free — Direct Shell` 模式可在 Workers Free 上运行,并可使用检测到的 Cloudflare 账户、打开 Cloudflare 登录或注册,也可在不登录的情况下创建临时账户。`Isolated — Dynamic Worker` 需要 Workers Paid,因此只提供已检测到或新认证的账户。Cloudflare 没有提供可靠的本地 Worker Loader entitlement 检查;isolated 安装会由 Cloudflare 对上传进行授权,并在被拒绝时提示启用 Workers Paid 或改用 direct 模式。对于新的永久账户安装,安装器会创建或复用私有 `<worker-name>-attachments` R2 bucket,并只把 binding 写入生成的私有 Wrangler 配置;部署失败时绝不会删除 bucket。R2 Standard 提供月度免费额度,但 Cloudflare 要求所选账户先在 Dashboard checkout 中启用独立的按量 R2 subscription。安装器会在收集 Worker secret 前检查 R2;Cloudflare 返回 `10042` 时,会提供当前账户专属的恢复选择:启用后重试、取消,或者把无 marker 的 pre-attachment Worker 安全切换到 DO storage。新的部署或已固定为 R2 的部署不能切换 backend,因为这会违背已确认的 storage 决策或导致引用失联。临时账户使用 64 MiB Durable Object attachment backend,并支持相同的上游图片 UI。认领会保留这个 backend 与已有图片历史;自动迁移到 R2 尚未实现。每个新部署都会记录明确的 attachment-storage marker。在更新已有 Worker 前,安装器会检查所有 active version,并保留 marker 或 binding 指定的 R2/DO 选择。图片功能出现前创建的 Worker 既没有 marker、也没有 attachment binding,不可能包含图片引用,因此首次升级到 0.3 时会询问一次:选择无需额外开通的 64 MiB DO storage,或选择私有 R2。此后固定所选 backend。若 active rollout 混用两种 backend,则会拒绝猜测并要求先完成 rollout。
171
171
 
172
172
  后续提示会选择 Worker 名称、生成或接收 owner access key、通过隐藏输入收集 DeepSeek API key,并显示最终费用摘要。临时账户安装还会要求用户明确接受 Cloudflare 服务条款与隐私政策。安装器绝不会在未经确认时覆盖现有 Worker。两项 credential 会通过权限模式为 `0600` 的临时 secret 文件传给 Wrangler;Wrangler 子进程只会收到 allowlist 内的运行时环境变量和当前命令选中的 Cloudflare authentication,其他 ambient key、token、password、secret 与 Node 注入选项不会进入子进程。命令结束后临时 secret 文件会被删除,安装器从 Wrangler 结构化输出中取得最终 URL。默认情况下,部署输出会收敛到一个进度提示;在任一命令后添加 `--verbose` 可以查看 Wrangler 诊断。
173
173
 
@@ -187,7 +187,7 @@ pnpm --filter dsh-edge example:install
187
187
  - `GET /login` 渲染 Edge 持有的 owner form;`POST /api/auth/login` 用已配置的 access key 换取 signed cookie,`GET /api/auth/session` 报告 cookie 是否有效,`POST /api/auth/logout` 清除 cookie。
188
188
  - `GET /api/events.mux` 和 `GET /api/events.host` 会升级为上游 downlink WebSocket。Durable Object 会把每个 socket 的 channel 与已验证 owner session 过期时间序列化为 hibernation attachment,通过 alarm 在该时间关闭连接,并从 Durable Object SQL 重建 canonical session 与 retained blank header。每次 inbox splice 提交后,mux stream 都会发布完整的 `session/queue` snapshot;客户端重连时还会发送 live inbox 的待处理 baseline。
189
189
  - `POST /api/commands/list` 使用上游 generated-Remote envelope 返回空 catalog,因为 Edge preset 没有注册 human command。
190
- - `GET /api/health` 会返回公开的 package-and-mode release identifier 与配置的附件默认值(`private-r2` 或 `temporary-do`),并先验证 owner authentication、部署级 DeepSeek 凭据、模型与传输配置,以及命令超时策略,再报告运行时组件已就绪。它不会调用提供方、Durable Object、R2、VFS 或 shell。认证后的 agent-preset projection 会报告 owner Durable Object 实际固定的 backend 与临时存储上限。
190
+ - `GET /api/health` 会返回公开的 package-and-mode release identifier 与配置的附件默认值(`private-r2` 或 `temporary-do`),并先验证 owner authentication、部署级 DeepSeek 凭据、模型与传输配置,以及命令超时策略,再报告运行时组件已就绪。它不会调用提供方、Durable Object、R2、VFS 或 shell。认证后的 agent-preset projection 会报告 owner Durable Object 实际固定的 backend、临时存储上限、部署默认模型,以及 runtime 实际读取的上游 model catalog 与 session 选择范围。
191
191
  - `PUT /api/workspace/file?path=/workspace/...` 写入 UTF-8 文件。
192
192
  - `GET /api/workspace/file?path=/workspace/...` 读取 UTF-8 文件。
193
193
  - `DELETE /api/workspace/file?path=/workspace/...` 删除文件。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-edge",
3
- "version": "0.3.0-alpha.1",
3
+ "version": "0.3.0-alpha.2",
4
4
  "description": "Run DeepSeek Harness on Cloudflare Workers as an independent community project",
5
5
  "author": "pawaca",
6
6
  "license": "MIT",
package/scripts/cli.mjs CHANGED
@@ -133,6 +133,58 @@ export function createInstallerUi(
133
133
  ],
134
134
  })))
135
135
  },
136
+ async selectInitialAttachmentStorage() {
137
+ note([
138
+ 'This Worker predates image attachments, so no existing image references need migration.',
139
+ 'The selected backend is pinned for future upgrades and is not changed automatically.',
140
+ ].join('\n'), 'Choose image storage once')
141
+ return await requireAnswer(await clack.select(withOutput({
142
+ message: 'Where should this Worker store new images?',
143
+ initialValue: 'temporary-do',
144
+ signal,
145
+ options: [
146
+ {
147
+ value: 'temporary-do',
148
+ label: 'Durable Object — no R2 setup',
149
+ hint: 'recommended for Workers Free; 64 MiB per instance',
150
+ },
151
+ {
152
+ value: 'private-r2',
153
+ label: 'Private R2 bucket',
154
+ hint: 'requires an enabled R2 subscription; includes a free tier',
155
+ },
156
+ ],
157
+ })))
158
+ },
159
+ async r2SubscriptionUnavailable({ activationUrl, canSwitchToDurableObject }) {
160
+ note([
161
+ 'Cloudflare requires R2 to be enabled before dsh-edge can create a private bucket.',
162
+ `Enable R2: ${activationUrl}`,
163
+ 'R2 Standard includes monthly free usage, but activation requires Dashboard checkout.',
164
+ 'After checkout completes, return here and retry.',
165
+ ].join('\n'), 'R2 is not enabled for this account')
166
+ const options = [
167
+ ...(canSwitchToDurableObject
168
+ ? [{
169
+ value: 'temporary-do',
170
+ label: 'Use Durable Object storage',
171
+ hint: 'continue now without R2; 64 MiB per instance',
172
+ }]
173
+ : []),
174
+ {
175
+ value: 'retry',
176
+ label: 'Retry R2',
177
+ hint: 'choose this after enabling R2 in the Dashboard',
178
+ },
179
+ { value: 'cancel', label: 'Cancel installation' },
180
+ ]
181
+ return await requireAnswer(await clack.select(withOutput({
182
+ message: 'How should dsh-edge continue?',
183
+ initialValue: canSwitchToDurableObject ? 'temporary-do' : 'retry',
184
+ signal,
185
+ options,
186
+ })))
187
+ },
136
188
  async selectOwnerSecretMode() {
137
189
  return await requireAnswer(await clack.select(withOutput({
138
190
  message: 'Set the owner access key',
@@ -169,7 +221,7 @@ export function createInstallerUi(
169
221
  `Images: ${summary.attachmentStorage === 'temporary-do'
170
222
  ? 'stored in this instance (64 MiB limit)'
171
223
  : 'stored privately in Cloudflare R2'}`,
172
- ...(command === 'upgrade' ? ['Existing Durable Object data is preserved.', 'The entered secrets replace the active values.'] : []),
224
+ ...(command === 'upgrade' ? ['Existing Durable Object data is preserved.', 'You will re-enter the two Worker secrets after confirming.'] : []),
173
225
  ].join('\n'), command === 'upgrade' ? 'Upgrade summary' : 'Installation summary')
174
226
  return await requireAnswer(await clack.confirm(withOutput({
175
227
  message: command === 'upgrade' ? 'Upgrade this instance?' : 'Install this instance?',
@@ -1,5 +1,6 @@
1
1
  export type RuntimeMode = 'direct' | 'isolated'
2
2
  export type InstallerCommand = 'install' | 'upgrade'
3
+ export type AttachmentStorage = 'private-r2' | 'temporary-do'
3
4
 
4
5
  export interface CloudflareAccount {
5
6
  id: string
@@ -21,6 +22,11 @@ export interface InstallerUi {
21
22
  selectAccount(choices: Array<{ value: string; label: string; hint?: string }>): Promise<string>
22
23
  workerName(initialValue: string, validate: (value: string) => string | undefined): Promise<string>
23
24
  workerConflict(workerName: string): Promise<'rename' | 'update' | 'cancel'>
25
+ selectInitialAttachmentStorage(): Promise<AttachmentStorage>
26
+ r2SubscriptionUnavailable(options: {
27
+ activationUrl: string
28
+ canSwitchToDurableObject: boolean
29
+ }): Promise<'retry' | 'temporary-do' | 'cancel'>
24
30
  selectOwnerSecretMode(): Promise<'generate' | 'custom'>
25
31
  ownerSecret(validate: (value: string) => string | undefined): Promise<string>
26
32
  deepSeekKey(validate: (value: string) => string | undefined): Promise<string>
@@ -31,7 +37,7 @@ export interface InstallerUi {
31
37
  workerName: string
32
38
  paid: boolean
33
39
  temporary: boolean
34
- attachmentStorage: 'private-r2' | 'temporary-do'
40
+ attachmentStorage: AttachmentStorage
35
41
  }): Promise<boolean>
36
42
  acceptTemporaryTerms(): Promise<boolean>
37
43
  deploymentStart?(message: string): void
@@ -60,7 +66,7 @@ export interface InstallResult {
60
66
  publicUrl: string
61
67
  versionId?: string
62
68
  account?: CloudflareAccount
63
- attachmentStorage: 'private-r2' | 'temporary-do'
69
+ attachmentStorage: AttachmentStorage
64
70
  claimUrl?: string
65
71
  mode: RuntimeMode
66
72
  ownerSecret: string
@@ -123,7 +129,7 @@ export function detectExistingAttachmentStorage(options: {
123
129
  environment?: NodeJS.ProcessEnv
124
130
  profile?: string
125
131
  signal?: AbortSignal
126
- }): Promise<'private-r2' | 'temporary-do'>
132
+ }): Promise<AttachmentStorage | undefined>
127
133
  export function truncateUtf8Tail(value: string, maxBytes: number): string
128
134
  export function createOutputForwarder(
129
135
  source: NodeJS.ReadableStream,
@@ -152,6 +152,13 @@ export class InstallerOutputError extends Error {
152
152
  }
153
153
  }
154
154
 
155
+ class R2SubscriptionUnavailableError extends Error {
156
+ constructor(message) {
157
+ super(message)
158
+ this.name = 'R2SubscriptionUnavailableError'
159
+ }
160
+ }
161
+
155
162
  /** Return account choices permitted by the selected runtime. */
156
163
  export function accountChoices(mode, accounts, command = 'install') {
157
164
  requireRuntimeMode(mode)
@@ -243,6 +250,10 @@ export function attachmentBucketName(workerName) {
243
250
  return `${prefix}-${digest}${suffix}`
244
251
  }
245
252
 
253
+ function r2ActivationUrl(accountId) {
254
+ return `https://dash.cloudflare.com/${encodeURIComponent(accountId)}/r2/overview`
255
+ }
256
+
246
257
  /** Create or reuse the permanent deployment's private R2 attachment bucket. */
247
258
  export async function ensureR2Bucket({
248
259
  bucketName,
@@ -253,6 +264,7 @@ export async function ensureR2Bucket({
253
264
  }) {
254
265
  const infoArgs = ['r2', 'bucket', 'info', bucketName, '--json', ...profileArgs(profile)]
255
266
  const initial = await runWrangler(infoArgs, { environment, signal })
267
+ throwIfR2SubscriptionUnavailable(initial)
256
268
  if (initial.status === 0) {
257
269
  requireR2BucketInfo(initial.stdout, bucketName)
258
270
  return { bucketName, created: false }
@@ -260,19 +272,38 @@ export async function ensureR2Bucket({
260
272
  const created = await runWrangler([
261
273
  'r2', 'bucket', 'create', bucketName, ...profileArgs(profile),
262
274
  ], { environment, signal })
275
+ throwIfR2SubscriptionUnavailable(created)
263
276
  if (created.status === 0) return { bucketName, created: true }
264
277
  // A concurrent installer may have won the create race; prove exact existence.
265
278
  const recovered = await runWrangler(infoArgs, { environment, signal })
279
+ throwIfR2SubscriptionUnavailable(recovered)
266
280
  if (recovered.status === 0) {
267
281
  requireR2BucketInfo(recovered.stdout, bucketName)
268
282
  return { bucketName, created: false }
269
283
  }
270
284
  throw new Error(commandFailure(
271
- `Could not create or access private R2 bucket "${bucketName}". Enable R2 for this Cloudflare account (or use a temporary preview), then retry`,
285
+ `Could not create or access private R2 bucket "${bucketName}". Check this account's R2 access and permissions, then retry`,
272
286
  created,
273
287
  ))
274
288
  }
275
289
 
290
+ /** Verify R2 availability without creating a bucket or collecting deployment credentials. */
291
+ async function verifyR2Subscription({ runWrangler, environment, profile, signal }) {
292
+ const result = await runWrangler([
293
+ 'r2', 'bucket', 'list', ...profileArgs(profile),
294
+ ], { environment, signal })
295
+ throwIfR2SubscriptionUnavailable(result)
296
+ requireSuccess(result, 'Could not check Cloudflare R2 availability')
297
+ }
298
+
299
+ function throwIfR2SubscriptionUnavailable(result) {
300
+ if (!/\[code:\s*10042\]/u.test(`${result.stdout}\n${result.stderr}`)) return
301
+ throw new R2SubscriptionUnavailableError(commandFailure(
302
+ 'Cloudflare R2 is not enabled for this account',
303
+ result,
304
+ ))
305
+ }
306
+
276
307
  function requireR2BucketInfo(source, expectedName) {
277
308
  let info
278
309
  try {
@@ -455,9 +486,11 @@ function versionAttachmentStorage(source) {
455
486
  || (marker === 'private-r2' && attachment.length !== 1)) {
456
487
  throw new Error('The existing Worker attachment marker does not match its binding.')
457
488
  }
458
- // Releases before attachment support had neither binding nor marker. An
459
- // authenticated upgrade can safely initialize those instances on private R2.
460
- return marker ?? 'private-r2'
489
+ if (marker !== undefined) return marker
490
+ // An R2 binding predating the explicit marker is still authoritative. A
491
+ // release with neither binding nor marker predates image attachments, so it
492
+ // has no image references to strand and can ask the owner to choose once.
493
+ return attachment.length === 1 ? 'private-r2' : undefined
461
494
  }
462
495
 
463
496
  /** Run the complete guided install with UI and Wrangler supplied as replaceable boundaries. */
@@ -552,7 +585,7 @@ export async function installEdge({
552
585
  const commandEnvironment = temporary
553
586
  ? unauthenticatedEnvironment(environment)
554
587
  : accountEnvironment(profileEnvironment ?? environment, account.id)
555
- const attachmentStorage = updatingExisting
588
+ const existingAttachmentStorage = updatingExisting
556
589
  ? await detectExistingAttachmentStorage({
557
590
  workerName,
558
591
  mode,
@@ -561,17 +594,34 @@ export async function installEdge({
561
594
  profile,
562
595
  signal,
563
596
  })
597
+ : undefined
598
+ let attachmentStorage = updatingExisting
599
+ ? existingAttachmentStorage ?? await ui.selectInitialAttachmentStorage()
564
600
  : temporary ? 'temporary-do' : 'private-r2'
601
+ requireAttachmentStorage(attachmentStorage)
565
602
 
566
- const secretMode = await ui.selectOwnerSecretMode()
567
- const ownerSecret = secretMode === 'generate'
568
- ? generateOwnerSecret()
569
- : await ui.ownerSecret(validateOwnerSecret)
570
- const secretError = validateOwnerSecret(ownerSecret)
571
- if (secretError !== undefined) throw new Error(secretError)
572
- const deepSeekKey = await ui.deepSeekKey(validateDeepSeekKey)
573
- const deepSeekError = validateDeepSeekKey(deepSeekKey)
574
- if (deepSeekError !== undefined) throw new Error(deepSeekError)
603
+ const canSwitchToDurableObject = updatingExisting
604
+ && existingAttachmentStorage === undefined
605
+ while (attachmentStorage === 'private-r2') {
606
+ ui.step('Checking Cloudflare R2 availability…')
607
+ try {
608
+ await verifyR2Subscription({
609
+ runWrangler,
610
+ environment: commandEnvironment,
611
+ profile,
612
+ signal,
613
+ })
614
+ break
615
+ } catch (error) {
616
+ if (!(error instanceof R2SubscriptionUnavailableError)) throw error
617
+ const resolution = await promptR2Recovery(
618
+ ui,
619
+ account.id,
620
+ canSwitchToDurableObject,
621
+ )
622
+ if (resolution === 'temporary-do') attachmentStorage = 'temporary-do'
623
+ }
624
+ }
575
625
 
576
626
  const confirmed = await ui.confirm({
577
627
  mode,
@@ -587,17 +637,44 @@ export async function installEdge({
587
637
  throw new InstallCancelledError()
588
638
  }
589
639
 
640
+ const secretMode = await ui.selectOwnerSecretMode()
641
+ const ownerSecret = secretMode === 'generate'
642
+ ? generateOwnerSecret()
643
+ : await ui.ownerSecret(validateOwnerSecret)
644
+ const secretError = validateOwnerSecret(ownerSecret)
645
+ if (secretError !== undefined) throw new Error(secretError)
646
+ const deepSeekKey = await ui.deepSeekKey(validateDeepSeekKey)
647
+ const deepSeekError = validateDeepSeekKey(deepSeekKey)
648
+ if (deepSeekError !== undefined) throw new Error(deepSeekError)
649
+
590
650
  let bucketName
591
651
  if (attachmentStorage === 'private-r2') {
592
652
  bucketName = attachmentBucketName(workerName)
593
653
  ui.step(`Preparing private image storage (${bucketName})…`)
594
- await ensureR2Bucket({
595
- bucketName,
596
- runWrangler,
597
- environment: commandEnvironment,
598
- profile,
599
- signal,
600
- })
654
+ while (true) {
655
+ try {
656
+ await ensureR2Bucket({
657
+ bucketName,
658
+ runWrangler,
659
+ environment: commandEnvironment,
660
+ profile,
661
+ signal,
662
+ })
663
+ break
664
+ } catch (error) {
665
+ if (!(error instanceof R2SubscriptionUnavailableError)) throw error
666
+ const resolution = await promptR2Recovery(
667
+ ui,
668
+ account.id,
669
+ canSwitchToDurableObject,
670
+ )
671
+ if (resolution === 'temporary-do') {
672
+ attachmentStorage = 'temporary-do'
673
+ bucketName = undefined
674
+ break
675
+ }
676
+ }
677
+ }
601
678
  }
602
679
 
603
680
  temporaryDirectory = await createTemporaryDirectory()
@@ -1162,16 +1239,48 @@ function requireRuntimeMode(mode) {
1162
1239
  }
1163
1240
  }
1164
1241
 
1242
+ function requireAttachmentStorage(storage) {
1243
+ if (storage !== 'temporary-do' && storage !== 'private-r2') {
1244
+ throw new Error(`Unsupported attachment storage: ${String(storage)}`)
1245
+ }
1246
+ }
1247
+
1248
+ function requireR2RecoveryAction(action, canSwitchToDurableObject) {
1249
+ if (action === 'cancel') throw new InstallCancelledError()
1250
+ if (action === 'retry') return action
1251
+ if (action === 'temporary-do' && canSwitchToDurableObject) return action
1252
+ throw new Error(`Unsupported R2 recovery action: ${String(action)}`)
1253
+ }
1254
+
1255
+ async function promptR2Recovery(ui, accountId, canSwitchToDurableObject) {
1256
+ return requireR2RecoveryAction(
1257
+ await ui.r2SubscriptionUnavailable({
1258
+ activationUrl: r2ActivationUrl(accountId),
1259
+ canSwitchToDurableObject,
1260
+ }),
1261
+ canSwitchToDurableObject,
1262
+ )
1263
+ }
1264
+
1165
1265
  function requireSuccess(result, prefix) {
1166
1266
  if (result.outputFailure !== undefined) throw result.outputFailure
1167
1267
  if (result.status !== 0) throw new Error(commandFailure(prefix, result))
1168
1268
  }
1169
1269
 
1170
1270
  function commandFailure(prefix, result) {
1171
- const detail = stripAnsi(result.stderr || result.stdout).trim()
1271
+ const detail = conciseDiagnostic(result.stderr || result.stdout)
1172
1272
  return detail === '' ? `${prefix}.` : `${prefix}: ${detail}`
1173
1273
  }
1174
1274
 
1275
+ /** Remove known Node dependency noise from concise errors while verbose output stays unchanged. */
1276
+ function conciseDiagnostic(value) {
1277
+ return stripAnsi(value).split(/\r?\n/u)
1278
+ .filter(line => !/^\(node:\d+\) \[DEP0040\] DeprecationWarning: The `punycode` module is deprecated\./u.test(line.trim()))
1279
+ .filter(line => !/^\(Use `node --trace-deprecation \.\.\.` to show where the warning was created\)$/u.test(line.trim()))
1280
+ .join('\n')
1281
+ .trim()
1282
+ }
1283
+
1175
1284
  function describeError(error) {
1176
1285
  return error instanceof Error ? error.message : String(error)
1177
1286
  }