From bf328c38beb571045543e4bca0ab1cbe24caabdc Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Thu, 20 Aug 2026 01:08:31 +0800 Subject: [PATCH 1/2] Harden CLI safety and release checks Require confirmation for version deletion and reject unsafe credential-store reads. Tighten Control URL, local HTTP, tail event, Workflow pagination, and Wrangler version handling. Pin release actions and fail stable tags before publish when changelog notes are missing. Keep diagnostic fallbacks and user documentation aligned with the resulting behavior. Signed-off-by: Lu Zhang --- .claude/skills/wdl-deploy/SKILL.md | 11 +- .github/workflows/ci.yml | 10 +- .github/workflows/release.yml | 40 +++++--- AGENTS.md | 7 +- CHANGELOG.md | 37 +++++++ CONTRIBUTING.md | 5 +- GUIDE-zh.md | 16 ++- GUIDE.md | 66 ++++++++---- bin/wdl.js | 3 + commands/config.js | 11 +- commands/delete.js | 14 ++- commands/deploy.js | 10 +- commands/doctor.js | 55 ++++------ commands/r2.js | 27 ++--- commands/tail.js | 14 ++- commands/workflows.js | 16 ++- docs/deploy-zh.md | 7 +- docs/deploy.md | 14 ++- docs/r2-zh.md | 2 + docs/r2.md | 5 +- docs/token-zh.md | 6 +- docs/token.md | 25 +++-- docs/workflows-zh.md | 2 + docs/workflows.md | 4 + eslint.config.js | 2 +- lib/common.js | 15 +++ lib/config-state.js | 44 +++++++- lib/credentials.js | 59 ++++++++--- lib/token-store.js | 77 +++++++++++--- lib/wrangler/command.js | 48 +++++++-- scripts/changelog-section.js | 27 +++++ tests/integration/cli-live.test.js | 2 +- tests/unit/cli-common.test.js | 2 +- tests/unit/cli-config-doctor.test.js | 70 +++++++++++-- tests/unit/cli-credentials.test.js | 130 ++++++++++++++++++++++-- tests/unit/cli-delete.test.js | 74 +++++++++++++- tests/unit/cli-deploy.test.js | 2 +- tests/unit/cli-r2.test.js | 39 ++++--- tests/unit/cli-release.test.js | 31 ++++++ tests/unit/cli-tail.test.js | 20 ++++ tests/unit/cli-token-store.test.js | 68 ++++++++++++- tests/unit/cli-workflows.test.js | 32 +++++- tests/unit/cli-wrangler-command.test.js | 70 ++++++++++++- tests/unit/helpers.js | 2 + tsconfig.json | 2 +- 45 files changed, 1016 insertions(+), 207 deletions(-) create mode 100644 scripts/changelog-section.js create mode 100644 tests/unit/cli-release.test.js diff --git a/.claude/skills/wdl-deploy/SKILL.md b/.claude/skills/wdl-deploy/SKILL.md index 4818e0c..2a9a5ac 100644 --- a/.claude/skills/wdl-deploy/SKILL.md +++ b/.claude/skills/wdl-deploy/SKILL.md @@ -1,6 +1,6 @@ --- name: wdl-deploy -description: Deploy and manage Cloudflare Workers-style projects on the WDL platform via the `wdl` CLI (init, deploy, config explain, whoami, doctor, tail, secret, workers, delete, d1, r2, ai, workflows). Trigger when the user asks to scaffold or deploy a Worker, inspect resolved CLI configuration, identify the active control token/principal, run diagnostics, tail live logs, configure KV / Queues / Durable Objects / Workflows / AI bindings, manage D1 / R2 / AI providers / secrets through `wdl`, or troubleshoot wdl CLI output. Works with `wrangler.json` / `wrangler.jsonc` / `wrangler.toml` projects pinned to wrangler@^4. +description: Deploy and manage Cloudflare Workers-style projects on the WDL platform via the `wdl` CLI (init, deploy, config explain, whoami, doctor, tail, secret, token, workers, delete, d1, r2, ai, workflows). Trigger when the user asks to scaffold or deploy a Worker, inspect resolved CLI configuration, identify the active control token/principal, manage the local WDL token store, run diagnostics, tail live logs, configure KV / Queues / Durable Objects / Workflows / AI bindings, manage D1 / R2 / AI providers / secrets through `wdl`, or troubleshoot wdl CLI output. Works with `wrangler.json` / `wrangler.jsonc` / `wrangler.toml` projects pinned to wrangler@^4. --- # WDL CLI deploy skill @@ -93,6 +93,11 @@ stubs as opaque capabilities, but the receiver cannot rewrite their host-authored caller properties. Keep delegated stubs in memory; long-term irrevocable stub storage is unsupported. +`CONTROL_URL` may include a path prefix, but query strings and fragments are +rejected. A bare `.local` host defaults to HTTPS because mDNS is not loopback, +except that the existing bare `:8080` rule still selects HTTP on any host. Every +HTTP `.local` target emits the plaintext-token warning. + Never recommend setting `CONTROL_CONNECT_HOST` outside local development: it overrides the TCP target the admin token connects to (Host header + TLS SNI still track `CONTROL_URL`), and a stale value in a CI or production shell could @@ -138,6 +143,10 @@ to confirm the resolved namespace, inspect the target with `wdl ai providers get --ns `, and use the same explicit `--ns` for deletion. Never add `--yes` without user confirmation. +`wdl delete version` also requires confirmation and has no dry-run endpoint. +Inspect the retained version first; never add `--yes` without a separate safety +check and user confirmation. + `templates/AGENTS.md` is the generic agent entrypoint that `wdl init` copies into every new project. It points at the same `docs/` through `node_modules/@wdl-dev/cli/docs/.md` paths. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a8658d..596fa18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,9 +20,9 @@ jobs: group: ci-node-${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: "22" cache: npm @@ -43,7 +43,7 @@ jobs: group: ci-hygiene-${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2 @@ -65,9 +65,9 @@ jobs: exit 1 fi - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: "22" cache: npm diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 37f8a8e..c190b8f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,9 +15,9 @@ jobs: outputs: dist-tag: ${{ steps.version.outputs.dist-tag }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: "22" cache: npm @@ -32,7 +32,7 @@ jobs: # Pre-release versions (1.2.3-rc.1) publish under the `next` dist-tag # so `npm i -g @wdl-dev/cli` keeps resolving to the last stable release. - - name: Tag must match package.json version + - name: Tag and stable changelog must match package.json version id: version run: | version="$(node -p 'require("./package.json").version')" @@ -42,7 +42,10 @@ jobs: fi case "$version" in *-*) echo "dist-tag=next" >> "$GITHUB_OUTPUT" ;; - *) echo "dist-tag=latest" >> "$GITHUB_OUTPUT" ;; + *) + echo "dist-tag=latest" >> "$GITHUB_OUTPUT" + node scripts/changelog-section.js "$version" > /dev/null + ;; esac - run: npm pack --dry-run @@ -55,9 +58,9 @@ jobs: contents: read id-token: write # OIDC: trusted-publishing auth + provenance attestation steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: "22" registry-url: https://registry.npmjs.org @@ -83,9 +86,9 @@ jobs: contents: read packages: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: "22" registry-url: https://npm.pkg.github.com @@ -103,7 +106,11 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "22" # Final releases take their notes from the matching CHANGELOG.md # section; pre-releases (x.y.z-rc.N) have no section and fall back to @@ -113,16 +120,17 @@ jobs: GH_TOKEN: ${{ github.token }} run: | version="${GITHUB_REF_NAME#v}" - awk -v ver="$version" ' - $0 == "## " ver { found=1; next } - /^## / && found { exit } - found { print } - ' CHANGELOG.md > /tmp/notes.md args=(--verify-tag) - if [ -s /tmp/notes.md ]; then + if node scripts/changelog-section.js "$version" > /tmp/notes.md 2>/tmp/changelog-error; then args+=(--notes-file /tmp/notes.md) else - args+=(--generate-notes) + status=$? + if [[ "$version" == *-* && "$status" -eq 3 ]]; then + args+=(--generate-notes) + else + cat /tmp/changelog-error >&2 + exit "$status" + fi fi case "$version" in *-*) args+=(--prerelease) ;; diff --git a/AGENTS.md b/AGENTS.md index 9efa7aa..d724a70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -129,9 +129,10 @@ Credential resolution layers, highest precedence first: CLI flags, shell/CI env, the project `./.env` (sectioned by namespace, with a cross-origin guard that drops a `.env`-supplied endpoint when the effective token is not from the same `.env`), then the global token store (`~/.config/wdl/credentials`, managed by -`wdl token`). The store is trusted (home directory, same-source token + -endpoint) and not subject to the guard; a project `.env` is not. The namespace -itself follows the same shape — +`wdl token`). A store that passes the directory/file type checks and POSIX +permission checks is trusted (protected per-user config location, same-source +token + endpoint) and not subject to the guard; a project `.env` is not. The +namespace itself follows the same shape — `--ns > shell WDL_NS > project .env WDL_NS > store default (base WDL_NS)` — so the store's default namespace is the lowest selector, materialized into `env.WDL_NS` before the per-key gap-fill. Keep that ordering and the guard diff --git a/CHANGELOG.md b/CHANGELOG.md index c21acf6..6f82def 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,43 @@ ## Unreleased +### Changed + +- Accept only Wrangler v4. +- Validate Workflow page limits as integers in `1..1000` before contacting + Control. +- List `wdl token` in deploy-skill discovery so agent token-store tasks load the + repository guidance. +- Document that R2 `--out` accepts arbitrary filesystem destinations and uses + normal overwrite and symlink-following semantics. + +### Fixed + +- Reject Control base URLs containing query strings or fragments instead of + appending command paths into the wrong URL component. +- Keep `wdl config explain` usable when credential resolution needs the token + store but finds it malformed, unreadable, or unsafe by reporting + `tokenStore.error` alongside the remaining provenance; fully covered + higher-precedence values continue to leave the store unread. +- Make the opt-in `WDL_ALLOW_NPX_WRANGLER=1` fallback runnable on Windows by + using `npx.exe` or npm's `npx-cli.js` instead of the blocked `npx.cmd` shim. +- Make stable release tags fail before publishing when their changelog section + is missing. + +### Security + +- Require confirmation for `wdl delete version` and reject its unsupported + `--dry-run` flag instead of silently performing the deletion. +- Reject non-file or symlink credential paths on read, and fail closed when + POSIX token-store directory or file permissions are unsafe. +- Tighten local HTTP classification: recognize case-insensitive `localhost` and + `.test` hosts plus the full `127.0.0.0/8` range as local targets, but treat + `.local` as a network/mDNS host. Bare `.local` hosts default to HTTPS except + for the existing `:8080` rule, and every HTTP `.local` target warns before + sending the token. +- Fail `wdl tail` when cumulative SSE event data exceeds 4 MiB. +- Pin official GitHub Actions to fixed commit SHAs in CI and release jobs. + ## 1.8.0 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c93e30..9e725c6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,8 +30,11 @@ parameters and returns (no implicit `any`). | `bin/wdl.js` | Dispatcher. A `REGISTRY` of command modules derives both routing and the `wdl help` table from each command's `meta`; pre-scans argv so the `.env` namespace overlay sees the same `--ns` the command will. | | `commands/*.js` | One file per command: an option schema plus a `run` body. | | `lib/command.js` | The `defineCommand` framework: flag presets, `--help` short-circuit, dependency injection, and a `context` with `resolveControl` / `nsUrl` / `fetchJson` / `fetchStream`. | -| `lib/common.js` | Control-URL/namespace/token resolution, sectioned `.env` loading with the endpoint trust guard, `CliError`, help formatting, terminal-escape choke point. | +| `lib/common.js` | `CliError`, help formatting, shared CLI options, and compact HTTP/JSON error handling. | +| `lib/credentials.js`, `lib/dotenv.js` | Control URL / namespace / token resolution, sectioned `.env` loading, and the endpoint trust guard. | +| `lib/token-store.js` | The per-user credential store, its read/write trust checks, and serialized mutations. | | `lib/control-fetch.js` | HTTP client for the control plane: timeouts, body caps, streaming, Host/IPv6 handling. | +| `lib/output.js` | Human and JSON output primitives, diagnostic formatting, token masking, and terminal escaping. | | `lib/wrangler-pack.js`, `lib/wrangler/` | Wrangler config parsing (TOML/JSONC), deploy manifest assembly, asset collection with `.assetsignore`, local bundling via `wrangler deploy --dry-run`. | | `lib/*-format.js` | Per-command output formatters. | diff --git a/GUIDE-zh.md b/GUIDE-zh.md index b9ca04d..e9097e7 100644 --- a/GUIDE-zh.md +++ b/GUIDE-zh.md @@ -85,17 +85,17 @@ ADMIN_TOKEN= ADMIN_TOKEN= ``` -CLI 只会从 `.env` 读取 WDL 平台变量:`ADMIN_TOKEN`、`CONTROL_URL`、`CONTROL_CONNECT_HOST`、`WDL_NS`。优先级是 `CLI flag > shell/CI env > [resolved-ns] section > base .env > wdl token store`,都没有提供时命令直接报错——没有内置默认值。namespace 解析顺序是 `--ns`,然后是 shell 或 base `.env` 里的 `WDL_NS`,再然后是 token store 的默认 namespace。section 名可以是 `[acme]` 这类 tenant namespace,也可以是 `[__name__]` 这种运维保留的不透明 section。Tenant Wrangler 配置默认仍使用普通 tenant namespace 语法,除非运维方明确给了这种 namespace token;否则不要把 `__name__` 形态写进 `[[services]].ns`、`allowed_callers` 或命令示例。如果没有解析出 namespace,section 会全部跳过;后续命令如果需要 namespace 或 token,会按正常校验报错。只有临时切换 namespace 时才需要显式传 `--ns`。不带 scheme 的生产 control host(例如 `api.wdl.dev`)默认补 `https://`;`localhost:8080` 或 `*.test:8080` 这类本地开发地址默认补 `http://`。任何不带 scheme 的 `:8080` control URL 都会按本地 HTTP 处理。需要强制使用其它协议时,显式写 scheme。 +CLI 只会从 `.env` 读取 WDL 平台变量:`ADMIN_TOKEN`、`CONTROL_URL`、`CONTROL_CONNECT_HOST`、`WDL_NS`。优先级是 `CLI flag > shell/CI env > [resolved-ns] section > base .env > wdl token store`,都没有提供时命令直接报错——没有内置默认值。namespace 解析顺序是 `--ns`,然后是 shell 或 base `.env` 里的 `WDL_NS`,再然后是 token store 的默认 namespace。section 名可以是 `[acme]` 这类 tenant namespace,也可以是 `[__name__]` 这种运维保留的不透明 section。Tenant Wrangler 配置默认仍使用普通 tenant namespace 语法,除非运维方明确给了这种 namespace token;否则不要把 `__name__` 形态写进 `[[services]].ns`、`allowed_callers` 或命令示例。如果没有解析出 namespace,section 会全部跳过;后续命令如果需要 namespace 或 token,会按正常校验报错。只有临时切换 namespace 时才需要显式传 `--ns`。不带 scheme 的生产 control host(例如 `api.wdl.dev`)默认补 `https://`;loopback 和保留的 `*.test` host 默认补 `http://`。既有的裸 `:8080` 例外对任何 host(包括 `.local`)仍默认使用 HTTP。除此之外,`.local` 是局域网 / mDNS 后缀而不是 loopback,因此裸 host 默认 HTTPS;所有 HTTP `.local` 目标都会显示明文 token 告警。需要强制使用其它协议时,请显式写 scheme。Control URL 可以包含 path prefix,但不能包含 query string 或 fragment,因为命令会在这个 base URL 后追加 endpoint path。 `CONTROL_CONNECT_HOST` 是本地开发 / 调试用的覆盖开关:它改变请求实际连接的 TCP 目标,而 HTTP Host header 和 TLS SNI 仍跟随 `CONTROL_URL`(所以 HTTPS 下控制面证书仍会拒绝被重定向的连接;纯 http 没有这层保护)。只在本地开发用 —— 不要在 CI 或生产 shell 中持久设置,残留值可能把 admin token 路由到非预期目标。覆盖值写成 URL 时,scheme 只决定默认 TCP 端口(`http` 为 80,`https` 为 443);请求使用 HTTP 还是 HTTPS、Host 和 SNI 仍由 `CONTROL_URL` 决定。 -推荐的做法是把这些凭证放进托管存储,而不是 shell export 或项目 `.env`:`wdl token set --ns --control-url ` 用隐藏输入读取 token、调 `/whoami` 校验后按 namespace 存入 `~/.config/wdl/credentials`(不进 shell 历史、也不落在项目文件里)。存储是优先级最低的层——命令行标志、shell env、项目 `.env` 仍然胜出——`wdl token list` / `wdl token rm` 管理它。第一个存入的 namespace 成为默认(一行 base `WDL_NS`,和项目 `.env` 一样),命令不带 `--ns` 也能跑;`wdl token use ` 切换默认。详见 [token-zh.md](./docs/token-zh.md)。 +推荐的做法是把这些凭证放进托管存储,而不是 shell export 或项目 `.env`:`wdl token set --ns --control-url ` 用隐藏输入读取 token、调 `/whoami` 校验后按 namespace 存入 `~/.config/wdl/credentials`(不进 shell 历史、也不落在项目文件里)。存储是优先级最低的层——命令行标志、shell env、项目 `.env` 仍然胜出——`wdl token list` / `wdl token rm` 管理它。第一个存入的 namespace 成为默认(一行 base `WDL_NS`,和项目 `.env` 一样),命令不带 `--ns` 也能跑;`wdl token use ` 切换默认。CLI 会拒绝 symlink / 非普通文件形式的 credentials 路径;在 POSIX 上,如果 store 目录可被 group/other 写或文件能被 group/other 访问,也会拒绝读取。详见 [token-zh.md](./docs/token-zh.md)。 `wdl ai`、`wdl secret` 和 `wdl token` 都可能接收凭据,因此会脱敏无效参数的细节。如果完整子命令路径前的 string option 使用分离式值,而该值本身也是命令词,请把子命令放到前面,或改用 `--flag=value` 消歧。例如写 `wdl secret list --worker put` 或 `wdl secret --worker=put list`,不要写 `wdl secret --worker put list`。 `wdl deploy` 在上传前会以你的 OS 用户身份运行项目本地的 Wrangler dry-run 和 build 钩子,这些代码能读到磁盘上的 store(env scrub 只把 WDL 变量挡在 Wrangler 子进程的环境外,挡不住文件),所以只部署你信任的项目。`--no-token-store`(或 `WDL_TOKEN_STORE=off`)让 CLI 只从 flag / shell / `.env` 解析凭据、完全不读 store —— 这是给不太信任的项目或 CI 用的解析 opt-out,不是对文件本身的保护。 -用 `wdl config explain` 查看最终 namespace、control URL、脱敏 token 以及每个值的来源。用 `wdl whoami` 调 control-plane `/whoami`,查看当前 authenticated principal、token id、platform version、最低支持 CLI version 和 URL hints。用 `wdl doctor` 做本地可用性检查,包括 Node.js、wdl-cli、Wrangler、配置文件是否存在、凭据是否能解析,以及 `/whoami` 是否可达;在 CI 里可加 `--strict`,命令仍会打印检查结果,但只要任一检查失败就以非零退出。当 control plane 暴露 `/whoami` 时,`doctor` 可以发现 token 是否有效、principal namespace、platform version 和 CLI compatibility;更细的 capability 检查仍需要额外的 control endpoint。运维方没有配置公开 platform domain 时,namespace URL 可能显示为 `(unavailable)`;认证和其它 `/whoami` 字段仍然有效。 +用 `wdl config explain` 查看最终 namespace、control URL、脱敏 token 以及每个值的来源。如果解析需要读取 token store,而该次读取发现 store 损坏、无法读取或未通过安全检查,这个诊断命令仍会排除 store 后成功退出,展示剩余 flag / shell / `.env` 来源,并在人类可读的 `tokenStore` block 或 JSON `tokenStore.error` 中报告故障;实际操作命令需要该 store 时仍会 fail closed。如果更高优先级来源已经覆盖 namespace、control URL 和 token,CLI 不会读取或诊断 store。用 `wdl whoami` 调 control-plane `/whoami`,查看当前 authenticated principal、token id、platform version、最低支持 CLI version 和 URL hints。用 `wdl doctor` 做本地可用性检查,包括 Node.js、wdl-cli、Wrangler、配置文件是否存在、凭据是否能解析,以及 `/whoami` 是否可达;在 CI 里可加 `--strict`,命令仍会打印检查结果,但只要任一检查失败就以非零退出。当 control plane 暴露 `/whoami` 时,`doctor` 可以发现 token 是否有效、principal namespace、platform version 和 CLI compatibility;更细的 capability 检查仍需要额外的 control endpoint。运维方没有配置公开 platform domain 时,namespace URL 可能显示为 `(unavailable)`;认证和其它 `/whoami` 字段仍然有效。 ## 脚手架新 Worker @@ -207,7 +207,7 @@ wdl tail hello --max-reconnects 0 # 不限制自动重连次数 `wdl tail` 会显示 fetch 请求 start/finish(包含 method、对应浏览器访问形态的 pathname(worker 内部路径加上 worker 名前缀,不含 host / query string)、status/outcome、duration),worker 在 fetch 请求路径里产生的 `console.log` / `console.info` / `console.warn` / `console.error`,以及 fetch handler 抛出的未捕获异常。它是 live-only 调试工具:首次连接不会回放历史日志;同一个 CLI 进程的单 worker 网络重连会尽量自动续读,但你按 `Ctrl+C` 退出后再重新运行是一个新进程,会从“现在以后”的日志开始,除非你显式传 `--since `。多 worker 会话在重连期间可能漏事件;如果需要对某个 worker 尽量不丢日志,单独开一个 `wdl tail ` 终端。 -`wdl tail` 是 best-effort 实时调试工具,不是审计历史。高流量 worker 或终端连接消费太慢时,可能跳过中间事件。过大的 console 或 exception 事件会整条丢弃,并以较小的 warning 事件报告,而不是截断后输出。事故复盘和完整 payload 请使用管理方提供的常规日志平台。 +`wdl tail` 是 best-effort 实时调试工具,不是审计历史。高流量 worker 或终端连接消费太慢时,可能跳过中间事件。control 侧过大的 console 或 exception 事件会整条丢弃,并以较小的 warning 事件报告,而不是截断后输出;作为独立的客户端防线,如果超大 SSE event 拼接后的 data 超过 4 MiB,CLI 会终止当前 tail 会话。事故复盘和完整 payload 请使用管理方提供的常规日志平台。 control 可能主动回收长时间运行的 tail 会话:客户端约 15s 不读会收到 `session_idle`,会话达到运维方配置的最大时长(默认 15 分钟)会收到 `session_expired`。CLI 会打印 warning 并自动重连;如果反复出现,通常说明终端或外层 wrapper 没有及时消费输出。 @@ -362,6 +362,8 @@ wdl r2 objects get uploads images/logo.png --out logo.png wdl r2 objects delete uploads images/logo.png --yes ``` +`--out` 接受项目目录外的显式文件系统路径。当前沿用普通覆盖语义:已有文件会被替换,symlink 会跟随到目标。下载前请核对目标路径。 + `examples/inspection-demo` 展示了 R2 + D1 + KV + Assets 组合使用。 ### AI @@ -548,6 +550,8 @@ wdl workflows restart api orders order-123 --yes wdl workflows terminate api orders order-123 --yes ``` +`--limit` 和 `--step-limit` 接受 1..1000 的整数,超出范围时会在请求 Control 前本地拒绝。`--step-limit` 只能和 `--include-steps` 一起使用。 + `wdl workflows list` 会把 active Worker version 不再导出的定义标为 `retired=yes`。既有实例仍可查看和 terminate,但 restart 会返回 `workflow_not_exported`;需要先部署一个重新导出该 workflow name 的 active version。 这是 WDL Workflows 支持,不是完整 Cloudflare Workflows parity。 `script_name`、跨 worker workflow、跨 worker callback、service-binding callback 和 Cloudflare source-AST visualizer 不支持。same-worker DO progress callback 和 runtime-observed parallel/DAG `step.do` execution 可用。 @@ -868,6 +872,8 @@ wdl workers wdl delete version hello v1 ``` +删除版本会要求确认,而且控制面没有对应的 dry-run endpoint。请先检查保留版本;自动化只能在另有独立安全检查后传 `--yes`。传入 `--dry-run` 会被明确拒绝,而不会被静默忽略。 + 删除整个 Worker 前先预览: ```bash @@ -880,7 +886,7 @@ wdl delete worker hello --dry-run wdl delete worker hello ``` -`wdl delete worker` 默认会要求确认。建议先用 `--dry-run` 预览受影响的线上版本、保留版本、路由、worker secrets、workflow definitions、queue consumers 和资产清理。即使没有 deployed version,`wdl workers` 也会用 `workflow-defs=yes` 显示仍有 workflow definitions 的 entry;旧 control 未上报该字段时,CLI 显示 `workflow-defs=unknown`,这不表示没有 workflow definitions。自动化脚本里只有在已有独立安全检查后,才建议传 `--yes`。 +`wdl delete worker` 同样默认要求确认。建议先用 `--dry-run` 预览受影响的线上版本、保留版本、路由、worker secrets、workflow definitions、queue consumers 和资产清理。即使没有 deployed version,`wdl workers` 也会用 `workflow-defs=yes` 显示仍有 workflow definitions 的 entry;旧 control 未上报该字段时,CLI 显示 `workflow-defs=unknown`,这不表示没有 workflow definitions。自动化脚本里只有在已有独立安全检查后,才建议传 `--yes`。 确认后删除 D1 数据库: diff --git a/GUIDE.md b/GUIDE.md index e139d1a..51ed72c 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -110,13 +110,17 @@ namespaces, such as `[acme]`, or opaque operator-reserved sections shaped like `[__name__]`. Tenant Wrangler config still uses normal tenant namespace grammar unless your operator explicitly gave you such a namespace token. Do not put `__name__`-shaped names in `[[services]].ns`, `allowed_callers`, or command -examples without that operator instruction. Bare production control hosts such -as `api.wdl.dev` default to `https://`; bare local-dev hosts such as -`localhost:8080` or `*.test:8080` default to `http://`. Any bare `:8080` control -URL is treated as local HTTP. Include an explicit scheme when you need to force -a different protocol. If no namespace resolves, section values are skipped and -the command will fail normally if it needs a namespace or token. Pass `--ns` -when you want to override the default for one command. +examples without that operator instruction. A bare production host such as +`api.wdl.dev` defaults to `https://`; loopback and reserved `*.test` hosts +default to `http://`. The existing bare `:8080` exception also defaults to HTTP +on any host, including `.local`. Outside that exception, `.local` is a +network/mDNS suffix rather than loopback, so its bare hosts default to HTTPS. +Every HTTP `.local` target emits the plaintext-token warning. Include an +explicit scheme when you need to force a different protocol. A control URL may +include a path prefix, but query strings and fragments are rejected because +commands append endpoint paths to this base. If no namespace resolves, section +values are skipped and the command will fail normally if it needs a namespace or +token. Pass `--ns` when you want to override the default for one command. `CONTROL_CONNECT_HOST` is a local-dev / debug override: it changes the TCP target the request connects to while the HTTP Host header and TLS SNI keep @@ -135,7 +139,9 @@ history or a project file). The store is the lowest-precedence layer — flags, shell env, and a project `.env` still win — and `wdl token list` / `wdl token rm` manage it. The first stored namespace becomes the default (a base `WDL_NS`, like a project `.env`'s), so commands run without `--ns`; -`wdl token use ` switches it. See [token.md](./docs/token.md). +`wdl token use ` switches it. The CLI rejects a symlink/non-file credentials +path. On POSIX, it also rejects a store in a group/world-writable directory or a +file accessible to group/other users. See [token.md](./docs/token.md). Because `wdl ai`, `wdl secret`, and `wdl token` can receive credentials, they redact invalid argument details. If a string option appears before the complete @@ -153,10 +159,16 @@ never reads the store — a resolution opt-out for less-trusted projects or CI, not protection for the file itself. Use `wdl config explain` to inspect the final namespace, control URL, masked -token, and where each value came from. Use `wdl whoami` to call control-plane -`/whoami` and display the authenticated principal, token id, platform version, -minimum supported CLI version, and URL hints. Use `wdl doctor` for local -readiness checks covering Node.js, wdl-cli, Wrangler, config presence, resolved +token, and where each value came from. If resolution needs the token store and +that read finds it malformed, unreadable, or unsafe, this diagnostic still exits +successfully with the remaining flag/shell/`.env` provenance and reports the +failure in a human `tokenStore` block or JSON `tokenStore.error`; operating +commands still fail closed when they need that store. When higher-precedence +sources already cover the namespace, control URL, and token, the store remains +unread and is not diagnosed. Use `wdl whoami` to call control-plane `/whoami` +and display the authenticated principal, token id, platform version, minimum +supported CLI version, and URL hints. Use `wdl doctor` for local readiness +checks covering Node.js, wdl-cli, Wrangler, config presence, resolved credentials, and `/whoami` reachability. Add `--strict` when using it as a CI gate; the command still prints the checks, then exits non-zero if any check fails. `doctor` can detect token validity, principal namespace, platform @@ -311,9 +323,11 @@ and trigger the request after the tail is connected. The tail stream is best-effort live debugging, not audit history. Under high traffic or a slow terminal connection, some middle events can be skipped. -Oversized console or exception events are dropped whole and reported as small -warning events instead of being truncated. Use the normal log platform your -operator provides for incident reconstruction and full payloads. +Control-side oversized console or exception events are dropped whole and +reported as small warning events instead of being truncated. Independently, the +CLI terminates the tail session if an oversized SSE event's assembled data +exceeds 4 MiB. Use the normal log platform your operator provides for incident +reconstruction and full payloads. Control may close long-running tail sessions when the client stops reading (`session_idle`, about 15s) or when the session reaches its maximum lifetime @@ -558,6 +572,10 @@ wdl r2 objects get uploads images/logo.png --out logo.png wdl r2 objects delete uploads images/logo.png --yes ``` +`--out` accepts an explicit filesystem path outside the project. It currently +uses normal overwrite semantics: an existing file is replaced, and a symlink +target is followed. Verify the destination before downloading. + See `examples/inspection-demo` for a combined R2 + D1 + KV + Assets example. ### AI @@ -836,6 +854,10 @@ wdl workflows restart api orders order-123 --yes wdl workflows terminate api orders order-123 --yes ``` +`--limit` and `--step-limit` accept integers from 1 through 1000 and are +rejected locally outside that range. `--step-limit` may be used only with +`--include-steps`. + `wdl workflows list` marks definitions absent from the active Worker version as `retired=yes`. Existing instances remain inspectable and may be terminated, but restart returns `workflow_not_exported` until an active version exports that @@ -1264,6 +1286,10 @@ Delete a non-live version: wdl delete version hello v1 ``` +Version deletion asks for confirmation and has no dry-run endpoint. Inspect the +retained version first; automation may pass `--yes` only after an independent +safety check. Passing `--dry-run` is rejected rather than ignored. + Preview deleting a whole Worker: ```bash @@ -1276,11 +1302,11 @@ Delete after confirming: wdl delete worker hello ``` -`wdl delete worker` asks for confirmation by default. Use `--dry-run` first to -preview the affected active version, retained versions, routes, worker secrets, -workflow definitions, queue consumers, and asset cleanup. `wdl workers` reports -`workflow-defs=yes` even for entries that have no deployed version. When an -older control does not report this field, the CLI displays +`wdl delete worker` also asks for confirmation by default. Use `--dry-run` first +to preview the affected active version, retained versions, routes, worker +secrets, workflow definitions, queue consumers, and asset cleanup. `wdl workers` +reports `workflow-defs=yes` even for entries that have no deployed version. When +an older control does not report this field, the CLI displays `workflow-defs=unknown`; that does not mean no definitions exist. In automation, pass `--yes` only after a separate safety check. diff --git a/bin/wdl.js b/bin/wdl.js index f71f7e8..e6c43a0 100755 --- a/bin/wdl.js +++ b/bin/wdl.js @@ -116,6 +116,9 @@ export async function main(argv = process.argv.slice(2), deps = {}) { try { loadCliControlEnv(env, { nsFromFlag: scanned.ns, + // Every dispatcher-autoloaded command currently requires a namespace; + // namespace-optional diagnostics opt out of dispatcher autoloading. + requireNamespace: true, tokenFromFlag: scanned.tokenFromFlag, controlUrlFromFlag: scanned.controlUrlFromFlag, loadEnv: loadEnvOverride, diff --git a/commands/config.js b/commands/config.js index 8a3735c..f981294 100644 --- a/commands/config.js +++ b/commands/config.js @@ -1,7 +1,7 @@ import { defineCommand } from "../lib/command.js"; import { CliError, formatHelp, isMain, optionHelp } from "../lib/common.js"; import { writeResult } from "../lib/output.js"; -import { resolveCliConfigState } from "../lib/config-state.js"; +import { resolveDiagnosticConfigState } from "../lib/config-state.js"; const CONFIG_OPTIONS = ["ns", "control", "json", "help"]; @@ -23,7 +23,7 @@ async function runConfig({ values, positionals, context }) { const [subcommand, extra] = positionals; if (subcommand !== "explain" || extra) throw new CliError(usageText()); - const state = resolveCliConfigState({ + const { state, tokenStoreError } = resolveDiagnosticConfigState({ values, env: context.env, cwd: context.cwd, @@ -33,6 +33,7 @@ async function runConfig({ values, positionals, context }) { namespace: publicEntry(state.namespace), controlUrl: publicEntry(state.controlUrl), token: publicEntry(state.token), + ...(tokenStoreError ? { tokenStore: { error: tokenStoreError } } : {}), }; writeResult(values.json === true, body, () => formatConfigExplain(body), context.stdout); } @@ -59,16 +60,18 @@ function publicEntry(entry) { } /** - * @param {{ namespace: PublicConfigEntry, controlUrl: PublicConfigEntry, token: PublicConfigEntry }} body + * @param {{ namespace: PublicConfigEntry, controlUrl: PublicConfigEntry, token: PublicConfigEntry, tokenStore?: { error: string } }} body */ function formatConfigExplain(body) { - return [ + const lines = [ ...formatBlock("namespace", body.namespace), "", ...formatBlock("controlUrl", body.controlUrl), "", ...formatBlock("token", body.token), ]; + if (body.tokenStore) lines.push("", "tokenStore:", ` error: ${body.tokenStore.error}`); + return lines; } /** diff --git a/commands/delete.js b/commands/delete.js index 6895a86..cb9e505 100644 --- a/commands/delete.js +++ b/commands/delete.js @@ -16,7 +16,7 @@ const DELETE_OPTIONS = [ defineHiddenCliOption("worker", { type: "string" }), defineHiddenCliOption("version", { type: "string" }), defineCliOption("dry-run", { type: "boolean" }, "--dry-run", "Preview worker delete without changing state."), - defineCliOption("yes", { type: "boolean" }, "--yes", "Skip worker delete confirmation."), + defineCliOption("yes", { type: "boolean" }, "--yes", "Skip delete confirmation."), "ns", "control", "json", @@ -58,7 +58,19 @@ async function runDelete({ values, positionals, context }) { throw new CliError("version delete requires or --worker/--version"); } if (extraArg) throw unexpectedArgument("delete version", extraArg); + if (values["dry-run"] === true) { + throw new CliError( + "delete version does not support --dry-run; inspect the retained version first, then rerun without --dry-run" + ); + } const { headers } = context.resolveControl(); + await confirmAction({ + yes: values.yes === true, + stdin, + stderr, + prompt: `Are you sure you want to delete version "${ns}/${worker}@${version}"? [y/N] `, + action: `delete version "${ns}/${worker}@${version}"`, + }); const body = await context.fetchJson( context.nsUrl("worker", worker, "versions", version), { method: "DELETE", headers }, diff --git a/commands/deploy.js b/commands/deploy.js index 63bdce6..9af5f83 100644 --- a/commands/deploy.js +++ b/commands/deploy.js @@ -200,6 +200,14 @@ function promotedWorkerUrlHints(raw, includePlatform) { return { platform, routes }; } +/** @param {string} hostname */ +function usesLocalWorkerUrlOrigin(hostname) { + // `.local` is not trusted as loopback for credential warnings, but a WDL + // control plane reached on the local network still shares its public + // scheme/port with the Worker gateway in development environments. + return isLocalDevHost(hostname) || hostname.endsWith(".local"); +} + /** * @param {string} rawUrl * @param {URL} controlUrl @@ -407,7 +415,7 @@ async function runDeploy({ values, positionals, context: baseContext }) { }); const parsedControlUrl = new URL(controlUrl); - const isLocal = isLocalDevHost(parsedControlUrl.hostname); + const isLocal = usesLocalWorkerUrlOrigin(parsedControlUrl.hostname); const reportedUrlHints = promotedWorkerUrlHints(urls, workersDev !== false); const invalidUrlHints = []; const displayedPlatformUrl = diff --git a/commands/doctor.js b/commands/doctor.js index 17bf304..5bf8753 100644 --- a/commands/doctor.js +++ b/commands/doctor.js @@ -6,7 +6,7 @@ import { CliError, defineCliOption, formatHelp, isMain, isNonEmptyString, option import { warnIfInsecureControlUrl } from "../lib/credentials.js"; import { writeResult } from "../lib/output.js"; import { readTokenStore, tokenStorePath } from "../lib/token-store.js"; -import { TokenStoreConfigError, resolveCliConfigState } from "../lib/config-state.js"; +import { resolveDiagnosticConfigState } from "../lib/config-state.js"; import { CLI_ROOT, currentCliVersion, readCliPackageJson } from "../lib/package-info.js"; import { ensureControlContextFromConfigState, @@ -14,7 +14,7 @@ import { namespaceFromPrincipal, summarizeWhoami, } from "../lib/whoami.js"; -import { MIN_WRANGLER_MAJOR, probeWranglerVersion, resolveWranglerCommand } from "../lib/wrangler/command.js"; +import { checkWranglerVersion, resolveWranglerCommand } from "../lib/wrangler/command.js"; import { selectWranglerConfigFiles } from "../lib/wrangler/config.js"; const DOCTOR_OPTIONS = [ @@ -50,26 +50,12 @@ async function runDoctor({ values, positionals, context: baseContext }) { if (positionals.length > 0) throw new CliError(usageText()); const context = /** @type {DoctorContext} */ (baseContext); - let tokenStoreError = null; - let state; - try { - state = resolveCliConfigState({ - values, - env: context.env, - cwd: context.cwd, - warn: context.warn, - }); - } catch (err) { - if (!(err instanceof TokenStoreConfigError)) throw err; - tokenStoreError = err.message; - state = resolveCliConfigState({ - values, - env: context.env, - cwd: context.cwd, - readStore: () => ({}), - warn: context.warn, - }); - } + const { state, tokenStoreError } = resolveDiagnosticConfigState({ + values, + env: context.env, + cwd: context.cwd, + warn: context.warn, + }); const checks = [ checkNode(), checkCliVersion(), @@ -96,7 +82,7 @@ async function runDoctor({ values, positionals, context: baseContext }) { /** * The resolved CLI config state doctor inspects. - * @typedef {ReturnType} ConfigState + * @typedef {ReturnType["state"]} ConfigState */ /** @@ -138,28 +124,24 @@ function checkWrangler({ cwd, env, execFile }) { }); } try { - const { version, major } = probeWranglerVersion({ + const { version } = checkWranglerVersion({ execFile, cwd, env, wrangler, fallbackVersion: () => readInstalledWranglerVersion(cwd), }); - // Mirror the gate `wdl deploy` enforces so doctor can't green-light a - // Wrangler major that deploy will reject. - const meetsMinimum = major >= MIN_WRANGLER_MAJOR; return check({ - ok: meetsMinimum, - label: `Wrangler ${version || "(unknown)"}`, - detail: meetsMinimum - ? `source: ${wrangler.source}` - : `wdl deploy requires Wrangler v${MIN_WRANGLER_MAJOR} (wrangler@^${MIN_WRANGLER_MAJOR}); found ${version || "(unknown)"} via ${wrangler.source}`, + ok: true, + label: `Wrangler ${version}`, + detail: `source: ${wrangler.source}`, }); } catch (err) { + const message = err instanceof Error && err.message ? err.message : String(err); return check({ ok: false, label: "Wrangler", - detail: err instanceof Error && err.message ? err.message : String(err), + detail: `${message}\nsource: ${wrangler.source}`, }); } } @@ -337,7 +319,12 @@ function check({ ok, label, detail = "" }) { function formatDoctor(checks) { return checks.map((item) => { const line = `${item.ok ? "✓" : "✗"} ${item.label}`; - return item.detail ? `${line}\n ${item.detail}` : line; + if (!item.detail) return line; + const detail = item.detail + .split("\n") + .map((detailLine) => ` ${detailLine}`) + .join("\n"); + return `${line}\n${detail}`; }); } diff --git a/commands/r2.js b/commands/r2.js index 9e4adaf..2a53d46 100644 --- a/commands/r2.js +++ b/commands/r2.js @@ -4,7 +4,15 @@ import { Transform } from "node:stream"; import { pipeline } from "node:stream/promises"; import { LONG_CONTROL_TIMEOUT_MS, UNLIMITED_CONTROL_BODY_BYTES } from "../lib/control-fetch.js"; import { defineCommand } from "../lib/command.js"; -import { CliError, defineCliOption, formatHelp, isMain, optionHelp, unexpectedArgument } from "../lib/common.js"; +import { + CliError, + defineCliOption, + formatHelp, + isMain, + normalizePageLimit, + optionHelp, + unexpectedArgument, +} from "../lib/common.js"; import { confirmAction } from "../lib/stdin.js"; import { escapeTerminalText, writeResult, writeStatusLine } from "../lib/output.js"; import { formatBucketList, formatObjectHead, formatObjectList } from "../lib/r2-format.js"; @@ -66,7 +74,7 @@ async function runR2({ values, positionals, context: baseContext }) { const { headers } = context.resolveControl(); const url = withQuery(context.nsUrl("r2", "buckets"), { cursor: values.cursor, - limit: normalizeListLimit(values.limit), + limit: normalizePageLimit(values.limit, "r2 --limit"), }); const body = /** @type {Parameters[0]} */ ( await context.fetchJson(url, { headers }, "list R2 buckets") @@ -83,7 +91,7 @@ async function runR2({ values, positionals, context: baseContext }) { prefix: values.prefix, delimiter: values.delimiter, cursor: values.cursor, - limit: normalizeListLimit(values.limit), + limit: normalizePageLimit(values.limit, "r2 --limit"), }); const body = /** @type {Parameters[0]} */ ( await context.fetchJson(url, { headers }, "list R2 objects") @@ -199,19 +207,6 @@ function requireR2ObjectKey(key) { return String(key); } -/** - * @param {string | undefined} limit - * @returns {string | undefined} - */ -function normalizeListLimit(limit) { - if (limit == null || limit === "") return undefined; - const n = Number(limit); - if (!Number.isInteger(n) || n < 1 || n > 1000) { - throw new CliError("r2 --limit must be an integer in [1, 1000]"); - } - return String(n); -} - /** @param {string} key */ function encodeR2KeyPath(key) { const segments = String(key).split("/"); diff --git a/commands/tail.js b/commands/tail.js index a65f8fe..f4efe82 100644 --- a/commands/tail.js +++ b/commands/tail.js @@ -19,6 +19,7 @@ const DEFAULT_MAX_RECONNECTS_AT_CAP = 10; const TAIL_CONNECT_TIMEOUT_MS = 30_000; const TAIL_ERROR_BODY_MAX_BYTES = 64 * 1024; export const SSE_MAX_LINE_CHARS = 1024 * 1024; +export const SSE_MAX_EVENT_BYTES = 4 * 1024 * 1024; // Socket-shutdown error shapes we tolerate as "our own abort". // Anything else (e.g. a 5xx racing the abort) bubbles to the user. const ABORT_TOLERATED_ERRORS = new Set(["ECONNRESET", "ECONNABORTED", "EPIPE", "ABORT_ERR"]); @@ -447,6 +448,8 @@ export class SseParser { this.onEvent = onEvent; this.buffer = ""; this.maxLineChars = SSE_MAX_LINE_CHARS; + this.maxEventBytes = SSE_MAX_EVENT_BYTES; + this.eventBytes = 0; this.event = "message"; /** @type {string | null} */ this.id = null; @@ -493,7 +496,14 @@ export class SseParser { } if (field === "event") this.event = value; else if (field === "id") this.id = value; - else if (field === "data") this.data.push(value); + else if (field === "data") { + const nextBytes = this.eventBytes + Buffer.byteLength(value, "utf8") + (this.data.length > 0 ? 1 : 0); + if (nextBytes > this.maxEventBytes) { + throw new CliError(`tail SSE event exceeded ${this.maxEventBytes} bytes`); + } + this.eventBytes = nextBytes; + this.data.push(value); + } // unknown fields ignored per spec } dispatch() { @@ -501,6 +511,7 @@ export class SseParser { // Reset event name even when there's no data so a subsequent // event without an explicit `event:` line falls back to "message". this.event = "message"; + this.eventBytes = 0; return; } this.onEvent({ event: this.event, id: this.id, data: this.data.join("\n") }); @@ -508,6 +519,7 @@ export class SseParser { // SSE spec: `id` persists until a new id (or `id:` with empty value) // overwrites it. We don't reset it. this.data = []; + this.eventBytes = 0; } /** @param {string} line */ assertLineLength(line) { diff --git a/commands/workflows.js b/commands/workflows.js index 98fb025..a290eda 100644 --- a/commands/workflows.js +++ b/commands/workflows.js @@ -1,5 +1,13 @@ import { defineCommand } from "../lib/command.js"; -import { CliError, defineCliOption, formatHelp, isMain, optionHelp, unexpectedArgument } from "../lib/common.js"; +import { + CliError, + defineCliOption, + formatHelp, + isMain, + normalizePageLimit, + optionHelp, + unexpectedArgument, +} from "../lib/common.js"; import { confirmAction } from "../lib/stdin.js"; import { escapeTerminalText, writeJsonOr, writeResult, writeStatusLine } from "../lib/output.js"; import { formatInstanceList, formatInstanceStatus, formatWorkflowList } from "../lib/workflows-format.js"; @@ -59,9 +67,10 @@ async function runWorkflows({ values, positionals, context }) { if (subcommand === "instances") { const { worker, workflow } = requireWorkflowRef(positionals, "workflows instances"); + const limit = normalizePageLimit(values.limit, "workflows --limit"); const { headers } = context.resolveControl(); const url = new URL(context.nsUrl("workflows", worker, workflow, "instances")); - if (values.limit) url.searchParams.set("limit", values.limit); + if (limit) url.searchParams.set("limit", limit); if (values.cursor) url.searchParams.set("cursor", values.cursor); const body = /** @type {{ instances?: import("../lib/workflows-format.js").WorkflowInstance[], cursor?: string }} */ ( @@ -76,10 +85,11 @@ async function runWorkflows({ values, positionals, context }) { if (values["step-limit"] && !values["include-steps"]) { throw new CliError("workflows status --step-limit requires --include-steps"); } + const stepLimit = normalizePageLimit(values["step-limit"], "workflows --step-limit"); const { headers } = context.resolveControl(); const url = new URL(context.nsUrl("workflows", worker, workflow, "instances", instanceId)); if (values["include-steps"]) url.searchParams.set("includeSteps", "true"); - if (values["step-limit"]) url.searchParams.set("stepLimit", values["step-limit"]); + if (stepLimit) url.searchParams.set("stepLimit", stepLimit); const body = /** @type {Parameters[0]} */ ( await context.fetchJson(url.href, { headers }, "get workflow instance status") ); diff --git a/docs/deploy-zh.md b/docs/deploy-zh.md index 5870543..b820072 100644 --- a/docs/deploy-zh.md +++ b/docs/deploy-zh.md @@ -4,7 +4,7 @@ `wdl deploy ` 用 `wrangler deploy --dry-run` 打包一个 Cloudflare Workers 风格的项目,然后把产物推送到 WDL 控制平面。**它不等同于 `wrangler deploy`**,后者直接对接 Cloudflare。在这个平台上**不要**用 `wrangler deploy` —— 只用 `wdl deploy`。 -wrangler 解析顺序是 `WDL_WRANGLER_BIN`、Worker 项目本地 wrangler、CLI 包本地 wrangler、最后是 `PATH`。默认不会临时 `npx --yes wrangler` 拉包;只有设置 `WDL_ALLOW_NPX_WRANGLER=1` 时才允许这个 fallback。 +wrangler 解析顺序是 `WDL_WRANGLER_BIN`、Worker 项目本地 wrangler、CLI 包本地 wrangler、最后是 `PATH`。默认不会临时 `npx --yes wrangler@^4` 拉包;只有设置 `WDL_ALLOW_NPX_WRANGLER=1` 时才允许这个 fallback。 WDL 会隐藏这个 dry-run 子进程的 Wrangler banner(因此跳过常规 banner 更新检查)并关闭匿名遥测。Wrangler 在报告未知配置字段时仍可能访问已配置的 npm registry;项目 build hook 仍保留正常的网络访问能力。 @@ -34,7 +34,7 @@ CLI 需要三个值: **CI / 自动化:** 把 `ADMIN_TOKEN`、`CONTROL_URL`、`WDL_NS` 作为环境变量从 CI secret store 注入 —— 不用交互式的 token store,也绝不提交 `.env`。 -裸 control host 会自动补 scheme;生产 host 默认 `https://`,本地 `.test` / `.local` 或 `:8080` 默认 `http://`。如果要强制协议,直接显式写 `https://...` 或 `http://...`。 +裸 control host 会自动补 scheme;生产 host 默认 `https://`,loopback 和 `.test` host 默认 `http://`。既有的裸 `:8080` 例外对任何 host(包括 `.local`)仍默认使用 HTTP。除此之外,`.local` 是局域网 / mDNS 后缀而不是 loopback,因此默认 HTTPS;所有 HTTP `.local` 目标都会显示明文 token 告警。如果要强制协议,直接显式写 `https://...` 或 `http://...`。Control URL 可以包含 path prefix,但 query string 和 fragment 会被拒绝。 优先级:`CLI 标志 > shell env > .env 中 [] 段 > .env 基础段 > wdl token store`。都没有提供时命令直接报错——没有内置默认值。 @@ -141,6 +141,8 @@ Cron triggers 和 queue consumers 是 runtime dispatch 能力,只应声明在 `wdl delete worker`、`wdl delete version`、`wdl d1 delete`、`wdl secret delete` 和 `wdl ai providers delete` 默认会提示确认。如果有 `--dry-run`,先跑一遍;否则先做只读检查。删除 AI provider 前,先运行 `wdl config explain` 确认最终解析出的 namespace,再用 `wdl ai providers get --ns ` 查看目标,并在删除时传入同一个显式 `--ns`;删除 provider 会同时删除其 metadata 和 credential。只有与用户确认后才能加 `--yes`;**不要**主动加。 +`wdl delete version` 没有 dry-run endpoint:请先检查保留版本。CLI 会拒绝 `--dry-run`,不会静默执行删除。 + `wdl workers` 会显示 `workflow-defs=yes` 或 `workflow-defs=no`;`unknown` 表示旧 control 没有返回该字段,不表示没有 workflow definitions。即使 blocker 使 `wouldDelete=no`,worker delete dry-run 仍会报告 secret 和 workflow-definition 是否存在。 删除 worker **不会**删除 R2 数据 —— 见 [r2-zh.md](./r2-zh.md)。 @@ -168,6 +170,7 @@ Cron triggers 和 queue consumers 是 runtime dispatch 能力,只应声明在 | `control promoted the worker without confirming its restart session policy` | version 已经生效,但会话可能没有重启。让必须运行新版本的 client 重连,或在 control 能确认该策略后重新部署。 | | Worker URL 返回 404 | URL 缺了 `/` 这一段。 | | `wdl tail` 没有历史日志 | tail 是 live-only;先打开 `wdl tail ` 再触发请求。 | +| `tail SSE event exceeded 4194304 bytes` | 单个 SSE event 拼接后的 UTF-8 data 超过 CLI 的 4 MiB 上限,因此当前 tail 会话已终止。修复或缩小上游 event 后再重新连接。 | | `tail session_idle` / `tail session_expired` | control 回收了 live-tail stream;CLI 会自动重连,除非达到重连上限。 | | Namespace secret 没生效 | NS 级 secret 不会强制 bump worker;重新部署一次或改用 worker 级 secret。 | | 服务绑定还在打老目标 | 绑定在调用方部署时就锁定了;重新部署调用方。 | diff --git a/docs/deploy.md b/docs/deploy.md index c654882..2a7083f 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -9,7 +9,7 @@ Do **not** use `wrangler deploy` on this platform — only `wdl deploy`. Wrangler resolution order is `WDL_WRANGLER_BIN`, the Worker project's local wrangler, the CLI package's local wrangler, then `PATH`. By default there is no -transient `npx --yes wrangler` fetch; that fallback is allowed only when +transient `npx --yes wrangler@^4` fetch; that fallback is allowed only when `WDL_ALLOW_NPX_WRANGLER=1` is set. WDL hides Wrangler's banner (which skips the normal banner update check) and @@ -59,8 +59,12 @@ environment variables from your CI secret store — not the interactive token store, and never a committed `.env`. Bare control hosts get a scheme automatically; production hosts default to -`https://`, local `.test` / `.local` or `:8080` hosts default to `http://`. To -force a protocol, write `https://...` or `http://...` explicitly. +`https://`, while loopback and `.test` hosts default to `http://`. The existing +bare `:8080` exception also defaults to HTTP on any host, including `.local`. +Outside that exception, `.local` defaults to HTTPS because mDNS is not loopback; +every HTTP `.local` target emits the plaintext-token warning. To force a +protocol, write `https://...` or `http://...` explicitly. A control URL may +include a path prefix, but query strings and fragments are rejected. Precedence: `CLI flag > shell env > .env [] section > .env base section > wdl token store`. @@ -294,6 +298,9 @@ same explicit `--ns` for deletion. Provider deletion removes both its metadata and credential. Add `--yes` only after confirming with the user; do **not** add it on your own. +`wdl delete version` has no dry-run endpoint: inspect the retained version +first. The CLI rejects `--dry-run` rather than silently performing the delete. + `wdl workers` reports `workflow-defs=yes` or `workflow-defs=no`; `unknown` means an older control omitted the field, not that no definitions exist. Worker delete dry-runs report secret and workflow-definition presence even when a blocker @@ -324,6 +331,7 @@ Deleting a worker does **not** delete R2 data — see [r2.md](./r2.md). | `control promoted the worker without confirming its restart session policy` | The version is live but its sessions may not have restarted. Reconnect clients that must run it, or deploy again once control confirms the policy. | | Worker URL returns 404 | The URL is missing the `/` segment. | | `wdl tail` has no history | Tail is live-only; open `wdl tail ` before triggering the request. | +| `tail SSE event exceeded 4194304 bytes` | One assembled SSE event exceeded the CLI's 4 MiB UTF-8 data cap, so the current tail session terminated. Reduce/fix the upstream event before reconnecting. | | `tail session_idle` / `tail session_expired` | Control reclaimed the live-tail stream; the CLI reconnects automatically unless the reconnect cap is reached. | | Namespace secret did not take effect | NS-level secrets do not force-bump workers; redeploy once or use a worker-level secret. | | Service binding still hits the old target | Bindings are pinned at caller deploy time; redeploy the caller. | diff --git a/docs/r2-zh.md b/docs/r2-zh.md index f897d7b..716c926 100644 --- a/docs/r2-zh.md +++ b/docs/r2-zh.md @@ -106,6 +106,8 @@ wdl r2 objects delete --yes # 破坏性 —— 先确认 `wdl r2 objects get` 会写出原始 object bytes。需要 stream bytes 时请 pipe 或重定向 stdout;在交互终端中请使用 `--out `。 +`--out` 接受项目目录外的显式路径,当前沿用普通覆盖语义:已有文件会被替换,symlink 会跟随到目标。下载前请核对目标路径。 + 列表被截断时输出会带 `Next cursor: `;把它传给下一次 `--cursor` 继续翻页(`wdl r2 buckets list` 同样支持 `--cursor` / `--limit`,其中 `--limit` 必须是 1..1000): ```bash diff --git a/docs/r2.md b/docs/r2.md index 89ebd14..2482ddb 100644 --- a/docs/r2.md +++ b/docs/r2.md @@ -125,7 +125,10 @@ wdl r2 objects delete --yes # destructive — confirm first ``` `wdl r2 objects get` writes raw object bytes. Pipe or redirect stdout when you -intend to stream bytes; on an interactive terminal, pass `--out `. +intend to stream bytes; on an interactive terminal, pass `--out `. `--out` +accepts an explicit path outside the project and currently uses normal overwrite +semantics: an existing file is replaced, and a symlink target is followed. +Verify the destination before downloading. When the list is truncated, the output includes `Next cursor: `; pass it to the next `--cursor` to keep paging (`wdl r2 buckets list` also supports diff --git a/docs/token-zh.md b/docs/token-zh.md index 45de0b1..02a9d30 100644 --- a/docs/token-zh.md +++ b/docs/token-zh.md @@ -19,7 +19,7 @@ ADMIN_TOKEN="" LABEL="production" ``` -它由命令独占:`wdl token` 会 canonical 重写整个文件(默认在前,然后排序、加引号的各段),所以项目专属的值请手编项目 `.env`。文件以 `0600` 权限写入。 +它由命令独占:`wdl token` 会 canonical 重写整个文件(默认在前,然后排序、加引号的各段),所以项目专属的值请手编项目 `.env`。文件以 `0600` 权限写入。读取时会拒绝非普通文件或 symlink 的 credentials 路径;在 POSIX 上还会 fail closed:所在目录不能 group/world-writable,且 group/other 用户不可访问该文件。可信 store 可用 `chmod 700 ` 和 `chmod 600 ` 修复;不要为了共享 store 放宽这些检查。 ## 命令 @@ -63,9 +63,11 @@ CLI 标志 > shell/CI env > 项目 ./.env > 全局 token 存储 > 未配置( 所以设了存储默认后,`wdl deploy`、`wdl doctor` 等不带 `--ns` 也能跑;要换别的就传 `--ns`(或 `wdl token use `)。当 namespace 来自存储默认时,`wdl config explain` 把来源显示为 `token store default`。 +如果解析需要读取 store,而该次读取发现它损坏、无法读取或未通过安全检查,`wdl config explain` 会排除它,以成功状态展示剩余 flag / shell / `.env` 来源,并在人类可读的 `tokenStore` block 或 JSON `tokenStore.error` 中报告故障。这个诊断 fallback 不会放宽实际操作命令:它们需要 store 时仍会 fail closed。如果更高优先级来源已经覆盖 namespace、control URL 和 token,CLI 不会读取或诊断 store。 + `wdl token` 子命令是这条链的例外:`set`、`use`、`rm` 会改动存储,所以它们只从显式 `--ns`(或 `use` 的位置参数)取 namespace —— 绝不取 ambient `WDL_NS` —— 以免一个游离的 shell 值写错、切错或删错条目。 -存储是**可信**的(它在你的 home 目录、由你经 `wdl token` 写入,token 和端点同源)。项目 `.env` **不可信**:若一个 `.env` 提供了 control 端点却没同时提供 token,该端点仍会被丢弃——这样不可信的项目目录永远无法把你存的 token 重定向到它指定的主机。 +通过上方路径和权限检查的存储才被视为**可信**:token 和端点同源,存放在受保护的用户级配置目录中。项目 `.env` **不可信**:若一个 `.env` 提供了 control 端点却没同时提供 token,该端点仍会被丢弃——这样不可信的项目目录永远无法把你存的 token 重定向到它指定的主机。 ## 安全:deploy 会以你的身份运行项目代码 diff --git a/docs/token.md b/docs/token.md index 017451e..20f8bd3 100644 --- a/docs/token.md +++ b/docs/token.md @@ -30,7 +30,12 @@ LABEL="production" It is command-owned: `wdl token` rewrites it canonically (default first, then sorted, quoted sections), so hand-edit a project `.env` for project-specific -values instead. The file is written with `0600` permissions. +values instead. The file is written with `0600` permissions. Reads reject a +credentials path that is not a regular, non-symlink file. On POSIX, they also +fail closed unless the containing directory is not group/world-writable and the +file is inaccessible to group and other users. Repair a trusted store with +`chmod 700 ` and `chmod 600 `; do not relax these checks for a shared +store. ## Commands @@ -86,16 +91,24 @@ So with a stored default you can run `wdl deploy`, `wdl doctor`, etc. without namespace comes from the store default, `wdl config explain` shows the source as `token store default`. +If resolution needs the store and that read finds it malformed, unreadable, or +unsafe, `wdl config explain` excludes it, exits successfully with the remaining +flag/shell/`.env` provenance, and reports the failure in a human `tokenStore` +block or JSON `tokenStore.error`. This diagnostic fallback does not weaken +operating commands: they still fail closed when they need the store. When +higher-precedence sources already cover the namespace, control URL, and token, +the store remains unread and is not diagnosed. + The `wdl token` subcommands are the exception to that chain: `set`, `use`, and `rm` mutate the store, so they take the namespace from an explicit `--ns` (or `use`'s positional) only — never the ambient `WDL_NS` — so a stray shell value can't write, switch, or delete the wrong entry. -The store is trusted (it lives in your home directory and you wrote it via -`wdl token`, so its token and endpoint are same-source). A project `.env` is -not: a `.env` that supplies a control endpoint without also supplying the token -is still dropped, so an untrusted project directory can never redirect your -stored token to a host it chose. +A store that passes the path and permission checks above is trusted: its token +and endpoint are same-source in a protected per-user config location. A project +`.env` is not: a `.env` that supplies a control endpoint without also supplying +the token is still dropped, so an untrusted project directory can never redirect +your stored token to a host it chose. ## Security: deploy runs project code as you diff --git a/docs/workflows-zh.md b/docs/workflows-zh.md index d5691c6..49cc7f8 100644 --- a/docs/workflows-zh.md +++ b/docs/workflows-zh.md @@ -42,6 +42,8 @@ wdl workflows restart --yes wdl workflows terminate --yes ``` +`--limit` 和 `--step-limit` 接受 1..1000 的整数;超出范围时 CLI 会在请求 Control 前拒绝。`--step-limit` 只能和 `--include-steps` 一起使用。 + `restart` 和 `terminate` 是破坏性实例生命周期操作;只有在已经独立确认 namespace、worker、workflow 和 instance id 后才传 `--yes`。 `wdl workflows list` 会把 active Worker version 不再导出的定义标为 `retired=yes`。既有实例仍可查看和 terminate,但 restart 会返回 `workflow_not_exported`;需要先部署一个重新导出该 workflow name 的 active version。 diff --git a/docs/workflows.md b/docs/workflows.md index 01c2f9b..097b197 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -70,6 +70,10 @@ wdl workflows restart --yes wdl workflows terminate --yes ``` +`--limit` and `--step-limit` accept integers from 1 through 1000. The CLI +rejects values outside that range before contacting Control; `--step-limit` +applies only with `--include-steps`. + `restart` and `terminate` are destructive instance lifecycle operations; pass `--yes` only after independently confirming the namespace, worker, workflow, and instance id. diff --git a/eslint.config.js b/eslint.config.js index 3ae3abb..1082e8c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -35,7 +35,7 @@ export default [ ignores: ["**/node_modules/**", "**/.deploy-dist/**", "**/.wrangler/**"], }, { - files: ["bin/**/*.js", "commands/**/*.js", "lib/**/*.js", "tests/**/*.js", "eslint.config.js"], + files: ["bin/**/*.js", "commands/**/*.js", "lib/**/*.js", "scripts/**/*.js", "tests/**/*.js", "eslint.config.js"], languageOptions: { ecmaVersion: 2024, sourceType: "module", diff --git a/lib/common.js b/lib/common.js index 28bf1d2..3ee52cd 100644 --- a/lib/common.js +++ b/lib/common.js @@ -59,6 +59,21 @@ export function isNonEmptyString(value) { return typeof value === "string" && value.length > 0; } +/** + * Normalize the page-size contract shared by paginated commands. + * @param {string | undefined} value + * @param {string} label + * @returns {string | undefined} + */ +export function normalizePageLimit(value, label) { + if (value == null || value === "") return undefined; + const limit = Number(value); + if (!Number.isInteger(limit) || limit < 1 || limit > 1000) { + throw new CliError(`${escapeTerminalText(label)} must be an integer in [1, 1000]`); + } + return String(limit); +} + /** * @param {{ usage: string[], description?: string, commands?: string[], options?: string[] }} spec */ diff --git a/lib/config-state.js b/lib/config-state.js index 28c2966..39c30b1 100644 --- a/lib/config-state.js +++ b/lib/config-state.js @@ -25,20 +25,26 @@ export class TokenStoreConfigError extends CliError { */ /** - * @param {{ + * @typedef {{ * values?: Record, * env?: NodeJS.ProcessEnv, * cwd?: string, * dotenvPath?: string, + * requireNamespace?: boolean, * readStore?: (env: NodeJS.ProcessEnv) => import("./token-store.js").TokenStore, * warn?: (line: string) => void, - * }} [options] + * }} ConfigStateOptions + */ + +/** + * @param {ConfigStateOptions} [options] */ export function resolveCliConfigState({ values = {}, env = process.env, cwd = process.cwd(), dotenvPath = ".env", + requireNamespace = false, readStore, warn = () => {}, } = {}) { @@ -75,6 +81,7 @@ export function resolveCliConfigState({ loadCliControlEnv(workingEnv, { dotenvPath: resolvedDotenvPath, nsFromFlag: /** @type {string | undefined} */ (values.ns), + requireNamespace, tokenFromFlag: flagSet(values, "token"), controlUrlFromFlag: flagSet(values, "control-url"), protectedKeys, @@ -101,6 +108,39 @@ export function resolveCliConfigState({ }; } +/** + * Resolve as much diagnostic state as possible when the token store is + * unreadable or malformed. Operating commands still fail closed in the normal + * resolver; only diagnostics use this fallback. + * @param {ConfigStateOptions} [options] + */ +export function resolveDiagnosticConfigState(options = {}) { + const shownWarnings = new Set(); + const warn = options.warn; + /** @param {string} line */ + const warnOnce = (line) => { + if (shownWarnings.has(line)) return; + shownWarnings.add(line); + warn?.(line); + }; + const diagnosticOptions = warn + ? { + ...options, + warn: warnOnce, + } + : options; + const requiredDiagnosticOptions = { ...diagnosticOptions, requireNamespace: true }; + try { + return { state: resolveCliConfigState(requiredDiagnosticOptions), tokenStoreError: null }; + } catch (err) { + if (!(err instanceof TokenStoreConfigError)) throw err; + return { + state: resolveCliConfigState({ ...requiredDiagnosticOptions, readStore: () => ({}) }), + tokenStoreError: err.message, + }; + } +} + /** * @param {(env: NodeJS.ProcessEnv) => import("./token-store.js").TokenStore} readStore */ diff --git a/lib/credentials.js b/lib/credentials.js index 9356944..dde2f9b 100644 --- a/lib/credentials.js +++ b/lib/credentials.js @@ -36,6 +36,13 @@ export function resolveControlUrl(values, env = process.env) { if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { throw new CliError(`Invalid control URL ${formatDiagnosticValue(raw)}: expected http:// or https://.`); } + // URL.search/hash are empty for a bare trailing `?` / `#`, even though + // appending endpoint text would still put it in that URL component. + if (/[?#]/.test(normalized)) { + throw new CliError( + `Invalid control URL ${formatDiagnosticValue(raw)}: query strings and fragments are not supported.` + ); + } return normalized; } @@ -125,18 +132,29 @@ function insecureControlUrlReason(controlUrl, env) { return null; } -// Loopback / dev-TLD hosts, shared by the bare-URL scheme default and the -// plaintext-token warning so the two policies cannot drift. Accepts both the -// bare IPv6 form and the bracketed form URL.hostname produces. +// Loopback / reserved test hosts trusted by the plaintext-token warning. Bare +// URL scheme selection also has a legacy :8080 compatibility exception, but a +// port alone never makes a host safe for plaintext credentials. Accepts both +// the bare IPv6 form and the bracketed form URL.hostname produces. /** @param {string} host */ export function isLocalDevHost(host) { + const normalized = host.toLowerCase(); return ( - host === "localhost" || - host === "127.0.0.1" || - host === "::1" || - host === "[::1]" || - host.endsWith(".test") || - host.endsWith(".local") + normalized === "localhost" || + isIpv4Loopback(normalized) || + normalized === "::1" || + normalized === "[::1]" || + normalized.endsWith(".test") + ); +} + +/** @param {string} host */ +function isIpv4Loopback(host) { + const parts = host.split("."); + return ( + parts.length === 4 && + parts[0] === "127" && + parts.every((part) => /^(?:0|[1-9][0-9]{0,2})$/.test(part) && Number(part) <= 255) ); } @@ -269,6 +287,7 @@ function firstDotEnvToken(body) { * @param {{ * dotenvPath?: string, * nsFromFlag?: string, + * requireNamespace?: boolean, * tokenFromFlag?: boolean, * controlUrlFromFlag?: boolean, * protectedKeys?: Set, @@ -284,6 +303,7 @@ export function loadCliControlEnv( { dotenvPath, nsFromFlag, + requireNamespace = false, tokenFromFlag = false, controlUrlFromFlag = false, protectedKeys = protectedEnvKeys(env), @@ -311,7 +331,13 @@ export function loadCliControlEnv( // way to work around it. Memoize so the at-most-one read is shared. /** @type {import("./token-store.js").TokenStore | undefined} */ let storeData; - const getStore = () => (storeData ??= readStore(env) || {}); + const getStore = () => { + if (storeData !== undefined) return storeData; + storeData = readStore(env) || {}; + return storeData; + }; + /** @type {unknown} */ + let storeReadError; let ns = firstNonEmptyString(nsFromFlag, env.WDL_NS); // The store's base WDL_NS names a default namespace — the lowest-precedence @@ -330,7 +356,8 @@ export function loadCliControlEnv( let s; try { s = getStore(); - } catch { + } catch (err) { + storeReadError = err; // corrupt/unreadable store → no usable default; do not block the command } const namespaces = (s && s.namespaces) || {}; @@ -356,10 +383,16 @@ export function loadCliControlEnv( // the gaps left by flags / shell / project .env / the guard — but only for a // slot still empty AND not supplied by a flag (resolved later). That keeps // the store unread when the credentials are already covered. + const covered = { CONTROL_URL: controlUrlFromFlag, ADMIN_TOKEN: tokenFromFlag }; + const needsFill = STORE_ENV_KEYS.some((k) => !covered[k] && (env[k] == null || env[k] === "")); if (ns) { - const covered = { CONTROL_URL: controlUrlFromFlag, ADMIN_TOKEN: tokenFromFlag }; - const needsFill = STORE_ENV_KEYS.some((k) => !covered[k] && (env[k] == null || env[k] === "")); if (needsFill) fillFromTokenStore(env, ns, getStore().namespaces || {}, onLoad, covered); + } else if ((requireNamespace || needsFill) && storeReadError !== undefined) { + // A failed default-namespace read is optional only when explicit + // credentials cover a command that needs no namespace (for example, + // `whoami`). Otherwise preserve its actionable permission/parse error + // instead of replacing it with a missing namespace/URL error. + throw storeReadError; } } diff --git a/lib/token-store.js b/lib/token-store.js index 651f38d..5f2c933 100644 --- a/lib/token-store.js +++ b/lib/token-store.js @@ -83,14 +83,30 @@ export function readTokenStore(storePath) { /** @type {string} */ let text; try { + const storeStat = lstatSync(storePath); + assertStoreFileSecure(storePath, storeStat); + assertStoreDirSecure(path.dirname(storePath), process.platform, "read"); text = readFileSync(storePath, "utf8"); } catch (err) { if (hasErrorCode(err) && err.code === "ENOENT") return { defaultNs: null, namespaces: {} }; + throw wrapTokenStoreFsError(err, `failed to read credential store ${escapeTerminalText(storePath)}`); + } + + try { + return parseTokenStoreText(text); + } catch (err) { + if (!(err instanceof CliError)) throw err; throw new CliError( - `failed to read credential store ${escapeTerminalText(storePath)}: ${formatTokenStoreError(err)}` + `failed to parse credential store ${escapeTerminalText(storePath)}: ${formatTokenStoreError(err)}` ); } +} +/** + * @param {string} text + * @returns {{ defaultNs: string | null, namespaces: Record> }} + */ +function parseTokenStoreText(text) { /** @type {Record>} */ const namespaces = {}; /** @type {string | null} */ @@ -165,14 +181,49 @@ function wrapTokenStoreFsError(err, prefix) { /** * @param {string} storeDir * @param {NodeJS.Platform} [platform] + * @param {"read" | "write"} [operation] */ -export function assertStoreDirSecure(storeDir, platform = process.platform) { +export function assertStoreDirSecure(storeDir, platform = process.platform, operation = "write") { + const storeStat = statStoreDir(storeDir, operation); if (platform === "win32") return; - if ((statSync(storeDir).mode & 0o022) !== 0) { - const escapedDir = escapeTerminalText(storeDir); + if ((storeStat.mode & 0o022) !== 0) { const quotedDir = shellArgForDisplay(storeDir); throw new CliError( - `refusing to write credentials: ${escapedDir} is group/world-writable; restrict it with \`chmod 700 ${quotedDir}\`` + `refusing to ${operation} credentials: ${escapeTerminalText(storeDir)} is group/world-writable; ` + + `restrict it with \`chmod 700 ${quotedDir}\`` + ); + } +} + +/** + * @param {string} storeDir + * @param {"read" | "write"} operation + */ +function statStoreDir(storeDir, operation) { + // Follow a user-managed config-directory symlink (or Windows junction), but + // validate the resolved target before using it. The credentials file itself + // remains lstat-checked and may not be a symlink. + const storeStat = statSync(storeDir); + if (!storeStat.isDirectory()) { + throw new CliError(`refusing to ${operation} credentials: ${escapeTerminalText(storeDir)} is not a directory`); + } + return storeStat; +} + +/** + * @param {string} storePath + * @param {import("node:fs").Stats} storeStat + * @param {NodeJS.Platform} [platform] + */ +function assertStoreFileSecure(storePath, storeStat, platform = process.platform) { + const escapedPath = escapeTerminalText(storePath); + if (!storeStat.isFile()) { + throw new CliError(`refusing to read credentials: ${escapedPath} is not a regular file`); + } + if (platform !== "win32" && (storeStat.mode & 0o077) !== 0) { + const quotedPath = shellArgForDisplay(storePath); + throw new CliError( + `refusing to read credentials: ${escapedPath} is accessible by group/other users; restrict it with \`chmod 600 ${quotedPath}\`` ); } } @@ -217,16 +268,7 @@ function writeTokenStoreFile(storePath, store, { beforeCommit = () => {}, tempDi lines.push(""); } const storeDir = path.dirname(storePath); - mkdirSync(storeDir, { recursive: true, mode: 0o700 }); - // Best-effort tighten a pre-existing dir (mkdirSync's mode only applies on - // creation). Tolerate a chmod failure (root-owned dir / no-chmod mount) — the - // assertion below enforces the property that actually matters. - try { - chmodSync(storeDir, 0o700); - } catch { - // best-effort - } - assertStoreDirSecure(storeDir); + prepareTokenStoreDir(storeDir); const tmpPath = path.join(tempDir, `${path.basename(storePath)}.${process.pid}.${randomUUID()}.tmp`); try { writeFileSync(tmpPath, lines.join("\n"), { mode: 0o600, flag: "wx" }); @@ -315,6 +357,11 @@ function withTokenStoreLock(storePath, lockTimeoutMs, staleLockMs, fn) { /** @param {string} storeDir */ function prepareTokenStoreDir(storeDir) { mkdirSync(storeDir, { recursive: true, mode: 0o700 }); + // Confirm the resolved path is a directory before chmod can affect it. + statStoreDir(storeDir, "write"); + // Best-effort tighten a pre-existing dir (mkdirSync's mode only applies on + // creation). Tolerate a chmod failure (root-owned dir / no-chmod mount) — the + // assertion below enforces the property that actually matters. try { chmodSync(storeDir, 0o700); } catch { diff --git a/lib/wrangler/command.js b/lib/wrangler/command.js index 6caaaec..df46a0c 100644 --- a/lib/wrangler/command.js +++ b/lib/wrangler/command.js @@ -9,7 +9,7 @@ import { escapeTerminalLines, escapeTerminalText, formatDiagnosticValue } from " const CLI_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const resolveFromHere = createRequire(import.meta.url); -export const MIN_WRANGLER_MAJOR = 4; +export const SUPPORTED_WRANGLER_MAJOR = 4; /** * The subset of an `execFileSync` failure / spawn error the formatters read. @@ -44,7 +44,7 @@ export function resolveWranglerCommand({ packageDirs = [CLI_DIR], platform = process.platform, } = {}) { - // Keep deploy offline by default. `npx --yes wrangler` may hit the + // Keep deploy offline by default. `npx --yes wrangler@^4` may hit the // registry, so only use it when explicitly requested. if (env.WDL_WRANGLER_BIN) { return { command: env.WDL_WRANGLER_BIN, args: [], source: "WDL_WRANGLER_BIN" }; @@ -68,7 +68,7 @@ export function resolveWranglerCommand({ if (fromPath) return { ...fromPath, source: "path" }; if (env.WDL_ALLOW_NPX_WRANGLER === "1") { - return { command: "npx", args: ["--yes", "wrangler"], source: "npx" }; + return npxWrangler(env, platform); } if (platform === "win32") { @@ -90,15 +90,16 @@ export function resolveWranglerCommand({ * cwd: string, * env: NodeJS.ProcessEnv, * wrangler: { command: string, args: string[] }, + * fallbackVersion?: () => string, * }} options * @returns {{ version: string, major: number }} */ -export function checkWranglerVersion({ execFile = execFileSync, cwd, env, wrangler }) { - const result = probeWranglerVersion({ execFile, cwd, env, wrangler }); - if (result.major < MIN_WRANGLER_MAJOR) { +export function checkWranglerVersion({ execFile = execFileSync, cwd, env, wrangler, fallbackVersion }) { + const result = probeWranglerVersion({ execFile, cwd, env, wrangler, fallbackVersion }); + if (result.major !== SUPPORTED_WRANGLER_MAJOR) { throw new CliError( - `wdl deploy requires Wrangler v${MIN_WRANGLER_MAJOR} (wrangler@^${MIN_WRANGLER_MAJOR}); ` + - `found v${result.major}. Upgrade the Worker project's wrangler dependency before deploying.` + `wdl deploy requires Wrangler v${SUPPORTED_WRANGLER_MAJOR} (wrangler@^${SUPPORTED_WRANGLER_MAJOR}); ` + + `found v${result.version}. Install a supported v${SUPPORTED_WRANGLER_MAJOR} release in the Worker project before deploying.` ); } return result; @@ -269,6 +270,37 @@ function pathWrangler(env, platform) { return null; } +/** + * @param {NodeJS.ProcessEnv} env + * @param {NodeJS.Platform} platform + */ +function npxWrangler(env, platform) { + const args = ["--yes", "wrangler@^4"]; + if (platform !== "win32") return { command: "npx", args, source: "npx" }; + + // Node >= 20.12 refuses to execFile the npx.cmd shim. A real executable shim + // (for example Volta's npx.exe) remains safe to execute directly; otherwise + // locate npm's JS entry and run it with Node. + for (const dir of (env.PATH || "").split(path.delimiter)) { + if (!dir) continue; + const executable = path.join(dir, "npx.exe"); + if (existsSync(executable)) return { command: executable, args, source: "npx" }; + } + + const candidates = []; + if (env.npm_execpath) candidates.push(path.join(path.dirname(env.npm_execpath), "npx-cli.js")); + for (const dir of (env.PATH || "").split(path.delimiter)) { + if (dir) candidates.push(path.join(dir, "node_modules", "npm", "bin", "npx-cli.js")); + } + const script = candidates.find((candidate) => existsSync(candidate)); + if (script) return { command: process.execPath, args: [script, ...args], source: "npx" }; + + throw new CliError( + "WDL_ALLOW_NPX_WRANGLER=1 is set, but no runnable npx installation was found. " + + "Install wrangler@^4 in the Worker project or set WDL_WRANGLER_BIN to a runnable Wrangler entry." + ); +} + /** * @param {unknown} rawErr * @returns {string} diff --git a/scripts/changelog-section.js b/scripts/changelog-section.js new file mode 100644 index 0000000..e032d57 --- /dev/null +++ b/scripts/changelog-section.js @@ -0,0 +1,27 @@ +#!/usr/bin/env node + +import { readFileSync } from "node:fs"; + +const version = process.argv[2]; +if (!version) { + console.error("usage: node scripts/changelog-section.js "); + process.exit(2); +} + +const lines = readFileSync("CHANGELOG.md", "utf8").split(/\r?\n/); +const start = lines.indexOf(`## ${version}`); +const end = start < 0 ? -1 : lines.findIndex((line, index) => index > start && line.startsWith("## ")); +const notes = + start < 0 + ? "" + : lines + .slice(start + 1, end < 0 ? undefined : end) + .join("\n") + .trim(); + +if (!notes) { + console.error(`stable release v${version} requires a non-empty CHANGELOG.md section`); + process.exit(3); +} + +process.stdout.write(`${notes}\n`); diff --git a/tests/integration/cli-live.test.js b/tests/integration/cli-live.test.js index b77f189..43ef021 100644 --- a/tests/integration/cli-live.test.js +++ b/tests/integration/cli-live.test.js @@ -538,7 +538,7 @@ test( assert.ok(app.versions.includes(app.activeVersion)); const oldVersion = app.versions.find((version) => version && version !== app.activeVersion); assert.ok(oldVersion, `second deploy did not leave an old version: ${app.versions.join(", ")}`); - runJson(["delete", "version", appWorker, oldVersion, "--json"], { env: storeEnv }); + runJson(["delete", "version", appWorker, oldVersion, "--yes", "--json"], { env: storeEnv }); runJson(["delete", "worker", appWorker, "--dry-run", "--json"], { env: storeEnv }); }); diff --git a/tests/unit/cli-common.test.js b/tests/unit/cli-common.test.js index 3240237..5f2b652 100644 --- a/tests/unit/cli-common.test.js +++ b/tests/unit/cli-common.test.js @@ -328,7 +328,7 @@ test("tenant lifecycle commands default namespace from WDL_NS", async () => { /** @type {ControlCall[]} */ const deleteCalls = []; - await runDeleteCommand(["version", "api", "v1", "--control-url", "http://ctl.test"], { + await runDeleteCommand(["version", "api", "v1", "--yes", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, stdout: () => {}, controlFetch: async ( diff --git a/tests/unit/cli-config-doctor.test.js b/tests/unit/cli-config-doctor.test.js index 9699d52..4fd7395 100644 --- a/tests/unit/cli-config-doctor.test.js +++ b/tests/unit/cli-config-doctor.test.js @@ -189,6 +189,31 @@ test("config explain prints final values and sources", async () => { }); }); +test("config explain reports a malformed token store without hiding resolved provenance", async () => { + await withTempDir(async (cwd) => { + const xdg = path.join(cwd, "xdg"); + const storePath = tokenStorePath({ XDG_CONFIG_HOME: xdg }); + mkdirSync(path.dirname(storePath), { recursive: true, mode: 0o700 }); + writeFileSync(storePath, '[demo]\nADMIN_TOKEN="unterminated\n', { mode: 0o600 }); + /** @type {string[]} */ + const lines = []; + + await runConfigCommand(["explain"], { + cwd, + env: { XDG_CONFIG_HOME: xdg, CONTROL_URL: "https://ctl.example", ADMIN_TOKEN: "shell-token" }, + /** @param {string} line */ + stdout: (line) => lines.push(line), + }); + + const out = lines.join("\n"); + assert.match(out, /namespace:\n {2}value: \(unset\)\n {2}source: \(unset\)/); + assert.match(out, /controlUrl:\n {2}value: https:\/\/ctl\.example\n {2}source: CONTROL_URL env/); + assert.match(out, /token:\n {2}value: \*+oken\n {2}source: ADMIN_TOKEN env/); + assert.match(out, /tokenStore:\n {2}error: failed to parse credential store/); + assert.match(out, /xdg[/\\]wdl[/\\]credentials/); + }); +}); + test("bin does not preload .env for local diagnostic commands", async () => { /** @type {string[]} */ const calls = []; @@ -318,7 +343,7 @@ test("doctor reports local checks plus remote whoami", async () => { let childEnv; /** @type {ControlCall[]} */ const calls = []; - const mockWranglerVersion = "9.8.7"; + const mockWranglerVersion = "4.98.7"; /** * @param {string} _cmd * @param {readonly string[]} _args @@ -396,8 +421,11 @@ test("doctor --strict exits non-zero when any check fails", async () => { ); const out = lines.join("\n"); - assert.match(out, /✗ Wrangler 3\.99\.0/); + assert.match(out, /✗ Wrangler\n/); assert.match(out, /wdl deploy requires Wrangler v4/); + assert.match(out, /found v3\.99\.0/); + assert.match(out, /found v3\.99\.0[^\n]*\n {2}source: /); + assert.doesNotMatch(out, /\nsource: /); }); }); @@ -457,8 +485,8 @@ test("doctor reports a corrupt token store as a failed check", async () => { writeFileSync(path.join(cwd, "wrangler.jsonc"), "{}"); const xdg = path.join(cwd, "xdg"); const storePath = tokenStorePath({ XDG_CONFIG_HOME: xdg }); - mkdirSync(path.dirname(storePath), { recursive: true }); - writeFileSync(storePath, '[demo]\nADMIN_TOKEN="unterminated\n'); + mkdirSync(path.dirname(storePath), { recursive: true, mode: 0o700 }); + writeFileSync(storePath, '[demo]\nADMIN_TOKEN="unterminated\n', { mode: 0o600 }); /** @type {string[]} */ const lines = []; @@ -492,8 +520,8 @@ test("doctor reports a corrupt token store even when it blocks credential resolu writeFileSync(path.join(cwd, "wrangler.jsonc"), "{}"); const xdg = path.join(cwd, "xdg"); const storePath = tokenStorePath({ XDG_CONFIG_HOME: xdg }); - mkdirSync(path.dirname(storePath), { recursive: true }); - writeFileSync(storePath, '[demo]\nADMIN_TOKEN="unterminated\n'); + mkdirSync(path.dirname(storePath), { recursive: true, mode: 0o700 }); + writeFileSync(storePath, '[demo]\nADMIN_TOKEN="unterminated\n', { mode: 0o600 }); /** @type {string[]} */ const lines = []; @@ -598,7 +626,7 @@ test("doctor reports namespace mismatch from whoami", async () => { }); }); -test("doctor flags a Wrangler major below the deploy minimum", async () => { +test("doctor flags a Wrangler major below the supported v4 contract", async () => { await withTempDir(async (cwd) => { /** @type {string[]} */ const lines = []; @@ -617,8 +645,34 @@ test("doctor flags a Wrangler major below the deploy minimum", async () => { }); const out = lines.join("\n"); - assert.match(out, /✗ Wrangler 3\.99\.0/); + assert.match(out, /✗ Wrangler\n/); + assert.match(out, /wdl deploy requires Wrangler v4/); + assert.match(out, /found v3\.99\.0/); + }); +}); + +test("doctor flags a Wrangler major above the supported v4 contract", async () => { + await withTempDir(async (cwd) => { + /** @type {string[]} */ + const lines = []; + await runDoctorCommand(["--ns", "acme", "--token", "secret-token"], { + cwd, + env: { CONTROL_URL: "https://api.wdl.dev" }, + execFile: () => "5.0.0\n", + /** @param {string} line */ + stdout: (line) => lines.push(line), + controlFetch: async () => + response({ + ok: true, + principal: { kind: "ns", ns: "acme" }, + minCliVersion: "0.7.1", + }), + }); + + const out = lines.join("\n"); + assert.match(out, /✗ Wrangler\n/); assert.match(out, /wdl deploy requires Wrangler v4/); + assert.match(out, /found v5\.0\.0/); }); }); diff --git a/tests/unit/cli-credentials.test.js b/tests/unit/cli-credentials.test.js index 94729f7..88bf22e 100644 --- a/tests/unit/cli-credentials.test.js +++ b/tests/unit/cli-credentials.test.js @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { @@ -13,7 +13,8 @@ import { resolveNamespace, warnIfInsecureControlUrl, } from "../../lib/credentials.js"; -import { ESC, assertNoRawTerminalControls } from "./helpers.js"; +import { readTokenStore } from "../../lib/token-store.js"; +import { ESC, POSIX_ONLY, assertNoRawTerminalControls } from "./helpers.js"; test("isTokenStoreDisabled honors the flag and WDL_TOKEN_STORE=off", () => { assert.equal(isTokenStoreDisabled({}, false), false); @@ -52,11 +53,30 @@ test("resolveControlUrl escapes invalid endpoint diagnostics", () => { test("resolveControlUrl accepts bare control hosts as https URLs", () => { assert.equal(resolveControlUrl({ "control-url": "ctl.example" }, {}), "https://ctl.example"); assert.equal(resolveControlUrl({}, { CONTROL_URL: "ctl.uat.example/" }), "https://ctl.uat.example"); + assert.equal(resolveControlUrl({}, { CONTROL_URL: "dev.local" }), "https://dev.local"); + assert.equal(resolveControlUrl({}, { CONTROL_URL: "https://ctl.example/control" }), "https://ctl.example/control"); +}); + +test("resolveControlUrl rejects query strings and fragments", () => { + for (const controlUrl of [ + "https://ctl.example/control?tenant=demo", + "https://ctl.example/control#admin", + "https://ctl.example/control?", + "https://ctl.example/control#", + ]) { + assert.throws( + () => resolveControlUrl({ "control-url": controlUrl }, {}), + /query strings and fragments are not supported/ + ); + } }); test("resolveControlUrl keeps bare local dev control URLs on http", () => { assert.equal(resolveControlUrl({}, { CONTROL_URL: "ctl.test:8080" }), "http://ctl.test:8080"); + assert.equal(resolveControlUrl({}, { CONTROL_URL: "dev.local:8080" }), "http://dev.local:8080"); assert.equal(resolveControlUrl({ "control-url": "localhost:8080/" }, {}), "http://localhost:8080"); + assert.equal(resolveControlUrl({ "control-url": "LOCALHOST:9000" }, {}), "http://LOCALHOST:9000"); + assert.equal(resolveControlUrl({ "control-url": "127.0.0.2:9000" }, {}), "http://127.0.0.2:9000"); assert.equal(resolveControlUrl({ "control-url": "[::1]" }, {}), "http://[::1]"); assert.equal(resolveControlUrl({ "control-url": "[::1]:8080/" }, {}), "http://[::1]:8080"); assert.equal(resolveControlUrl({ "control-url": "ctl.test" }, {}), "http://ctl.test"); @@ -85,8 +105,13 @@ test("warnIfInsecureControlUrl escapes control endpoint text before warning", () assert.equal(warnings[0].includes("\n"), false); }); -test("warnIfInsecureControlUrl treats local CONTROL_CONNECT_HOST host:port overrides as local", () => { - for (const connectHost of ["localhost:18080", "dev.local:18080", "[::1]:18080", "http://localhost:18080"]) { +test("warnIfInsecureControlUrl treats loopback CONTROL_CONNECT_HOST overrides as local", () => { + /** @type {string[]} */ + const controlWarnings = []; + warnIfInsecureControlUrl("http://127.0.0.2:9000", (line) => controlWarnings.push(line), {}); + assert.deepEqual(controlWarnings, []); + + for (const connectHost of ["localhost:18080", "127.0.0.53:18080", "[::1]:18080", "http://localhost:18080"]) { /** @type {string[]} */ const warnings = []; warnIfInsecureControlUrl("http://admin.test:8080", (line) => warnings.push(line), { @@ -96,6 +121,20 @@ test("warnIfInsecureControlUrl treats local CONTROL_CONNECT_HOST host:port overr } }); +test("warnIfInsecureControlUrl does not treat mDNS .local hosts as loopback", () => { + /** @type {string[]} */ + const controlWarnings = []; + warnIfInsecureControlUrl("http://dev.local", (line) => controlWarnings.push(line), {}); + assert.match(controlWarnings[0], /plain http on a non-local host/); + + /** @type {string[]} */ + const connectWarnings = []; + warnIfInsecureControlUrl("http://admin.test:8080", (line) => connectWarnings.push(line), { + CONTROL_CONNECT_HOST: "dev.local:18080", + }); + assert.match(connectWarnings[0], /CONTROL_CONNECT_HOST=dev\.local:18080 is non-local/); +}); + test("resolveNamespace prefers explicit namespace before WDL_NS", () => { assert.equal(resolveNamespace({ ns: "flag" }, { WDL_NS: "env" }), "flag"); assert.equal(resolveNamespace({}, { WDL_NS: "env" }), "env"); @@ -699,6 +738,65 @@ test("loadCliControlEnv lets shell env win over the store (gap-fill only)", () = assert.equal(env.CONTROL_URL, "https://store.example", "the empty control URL slot is filled"); }); +test("loadCliControlEnv rejects an unsafe store before mixing its URL with a shell token", POSIX_ONLY, () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-store-mixed-source-")); + try { + const storeDir = path.join(dir, "wdl"); + const storePath = path.join(storeDir, "credentials"); + mkdirSync(storeDir, { mode: 0o700 }); + writeFileSync(storePath, 'WDL_NS="acme"\n[acme]\nCONTROL_URL="http://attacker.example"\n', { mode: 0o600 }); + chmodSync(storeDir, 0o777); + const env = /** @type {NodeJS.ProcessEnv} */ ({ WDL_NS: "acme", ADMIN_TOKEN: "shell-token" }); + + assert.throws( + () => + loadCliControlEnv(env, { + loadEnv: () => [], + readStore: () => readTokenStore(storePath), + }), + /refusing to read credentials: .*group\/world-writable/ + ); + assert.equal(env.ADMIN_TOKEN, "shell-token"); + assert.equal(env.CONTROL_URL, undefined); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test( + "loadCliControlEnv surfaces an unsafe store when its default namespace is the only credential source", + POSIX_ONLY, + () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-store-default-error-")); + try { + const storeDir = path.join(dir, "wdl"); + const storePath = path.join(storeDir, "credentials"); + mkdirSync(storeDir, { mode: 0o700 }); + writeFileSync( + storePath, + 'WDL_NS="acme"\n[acme]\nCONTROL_URL="https://control.example"\nADMIN_TOKEN="store-token"\n', + { mode: 0o600 } + ); + chmodSync(storePath, 0o644); + const env = /** @type {NodeJS.ProcessEnv} */ ({}); + + assert.throws( + () => + loadCliControlEnv(env, { + loadEnv: () => [], + readStore: () => readTokenStore(storePath), + }), + /accessible by group\/other users; restrict it with `chmod 600/ + ); + assert.equal(env.WDL_NS, undefined); + assert.equal(env.ADMIN_TOKEN, undefined); + assert.equal(env.CONTROL_URL, undefined); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } +); + test("loadCliControlEnv does not fill a flag-covered slot from the store", () => { /** @type {NodeJS.ProcessEnv} */ const env = { WDL_NS: "acme" }; @@ -762,11 +860,13 @@ test("loadCliControlEnv surfaces a corrupt store when it is the credential sourc }); test("loadCliControlEnv tolerates a corrupt store when no namespace is needed", () => { - // No --ns/WDL_NS: the optional default-namespace lookup must not let a corrupt - // store abort a command that needs none (e.g. whoami --control-url … --token …). + // No --ns/WDL_NS and both credentials are explicit: the optional + // default-namespace lookup must not let a corrupt store abort whoami. const env = /** @type {NodeJS.ProcessEnv} */ ({}); assert.doesNotThrow(() => loadCliControlEnv(env, { + tokenFromFlag: true, + controlUrlFromFlag: true, loadEnv: () => [], readStore: () => { throw new Error("Invalid credentials line 3"); @@ -776,6 +876,24 @@ test("loadCliControlEnv tolerates a corrupt store when no namespace is needed", assert.equal(env.WDL_NS, undefined); }); +test("loadCliControlEnv surfaces a corrupt default-namespace store when the command requires namespace", () => { + const env = /** @type {NodeJS.ProcessEnv} */ ({ + CONTROL_URL: "https://shell.example", + ADMIN_TOKEN: "shell-tok", + }); + assert.throws( + () => + loadCliControlEnv(env, { + requireNamespace: true, + loadEnv: () => [], + readStore: () => { + throw new Error("Invalid credentials line 3"); + }, + }), + /Invalid credentials line 3/ + ); +}); + test("an empty .env ADMIN_TOKEN does not mark a .env endpoint same-source", () => { // Malicious cwd .env: a control endpoint + an EMPTY `ADMIN_TOKEN=` placeholder. // The empty token must not make the endpoint same-source. diff --git a/tests/unit/cli-delete.test.js b/tests/unit/cli-delete.test.js index bbc177c..95a3808 100644 --- a/tests/unit/cli-delete.test.js +++ b/tests/unit/cli-delete.test.js @@ -15,7 +15,7 @@ test("delete version calls the version hard-delete endpoint", async () => { assets: { cleanupTaskId: null, skippedSharedPrefix: false, warnings: [] }, }); - await runDeleteCommand(["version", "--ns", "demo", "api", "v1", "--control-url", "http://ctl.test"], deps); + await runDeleteCommand(["version", "--ns", "demo", "api", "v1", "--yes", "--control-url", "http://ctl.test"], deps); assert.equal(calls.length, 1); assert.equal(calls[0].url, "http://ctl.test/ns/demo/worker/api/versions/v1"); @@ -23,6 +23,78 @@ test("delete version calls the version hard-delete endpoint", async () => { assert.deepEqual(lines, ["OK demo/api@v1 deleted"]); }); +test("delete version requires confirmation and rejects dry-run without a request", async () => { + /** @type {ControlCall[]} */ + const calls = []; + const deps = { + env: { ADMIN_TOKEN: "tok" }, + stdin: stdinFrom(""), + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { + calls.push({ url, init }); + return response({}); + }, + }; + + await assert.rejects( + () => runDeleteCommand(["version", "--ns", "demo", "api", "v1", "--control-url", "http://ctl.test"], deps), + /Refusing to delete version "demo\/api@v1" without interactive confirmation/ + ); + await assert.rejects( + () => + runDeleteCommand( + ["version", "--ns", "demo", "api", "v1", "--dry-run", "--yes", "--control-url", "http://ctl.test"], + deps + ), + /delete version does not support --dry-run/ + ); + assert.equal(calls.length, 0); +}); + +test("delete version proceeds after interactive confirmation", async () => { + /** @type {ControlCall[]} */ + const calls = []; + /** @type {string[]} */ + const prompts = []; + const stdin = ttyStdinLine("yes\n"); + + await runDeleteCommand(["version", "--ns", "demo", "api", "v1", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdin, + stderr: (/** @type {string} */ text) => prompts.push(text), + stdout: () => {}, + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { + calls.push({ url, init }); + return response({ namespace: "demo", name: "api", version: "v1", deleted: true }); + }, + }); + + assert.equal(calls.length, 1); + assert.deepEqual(prompts, ['Are you sure you want to delete version "demo/api@v1"? [y/N] ']); + assert.equal(stdin.paused, true); +}); + +test("delete version resolves credentials before prompting", async () => { + /** @type {string[]} */ + const prompts = []; + await assert.rejects( + () => + runDeleteCommand(["version", "--ns", "demo", "api", "v1", "--control-url", "http://ctl.test"], { + env: {}, + stdin: ttyStdinLine("yes\n"), + stderr: (/** @type {string} */ text) => prompts.push(text), + controlFetch: async () => response({}), + }), + /Missing admin token/ + ); + assert.deepEqual(prompts, []); +}); + test("delete output does not expose internal cleanup task ids", async () => { const { lines, deps } = mockDeps({ namespace: "demo", diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index 29f5b93..d8a9a62 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -756,7 +756,7 @@ test("runDeployCommand preserves the local control scheme and port in the Worker /** @type {string[]} */ const lines = []; let fetchCount = 0; - await runDeployCommand([dir, "--ns", "demo", "--control-url", "https://localhost:8443"], { + await runDeployCommand([dir, "--ns", "demo", "--control-url", "https://box.local:8443"], { env: { ADMIN_TOKEN: "tok", CONTROL_CONNECT_HOST: "127.0.0.1:18080" }, stdout: (/** @type {string} */ line) => lines.push(/** @type {string} */ line), stderr: () => {}, diff --git a/tests/unit/cli-r2.test.js b/tests/unit/cli-r2.test.js index e7ddc30..790e2a3 100644 --- a/tests/unit/cli-r2.test.js +++ b/tests/unit/cli-r2.test.js @@ -7,7 +7,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { runR2Command } from "../../commands/r2.js"; import { LONG_CONTROL_TIMEOUT_MS, UNLIMITED_CONTROL_BODY_BYTES } from "../../lib/control-fetch.js"; -import { mockDeps, response, stdinFrom } from "./helpers.js"; +import { INVALID_PAGE_LIMITS, mockDeps, response, stdinFrom } from "./helpers.js"; /** @typedef {import("./helpers.js").ControlCall} ControlCall */ @@ -161,20 +161,29 @@ test("r2 list --limit is validated locally", async () => { await runR2Command(["buckets", "list", "--ns", "demo", "--limit", "1000", "--control-url", "http://ctl.test"], deps); assert.equal(calls[0].url, "http://ctl.test/ns/demo/r2/buckets?limit=1000"); - await assert.rejects( - () => - runR2Command(["buckets", "list", "--ns", "demo", "--limit", "1001", "--control-url", "http://ctl.test"], deps), - /--limit must be an integer/ - ); - await assert.rejects( - () => - runR2Command( - ["objects", "list", "--ns", "demo", "uploads", "--limit", "1.5", "--control-url", "http://ctl.test"], - deps - ), - /--limit must be an integer/ - ); - assert.equal(calls.length, 1); + for (const { value, suffix } of [ + { value: "", suffix: "" }, + { value: "01", suffix: "?limit=1" }, + { value: "1e3", suffix: "?limit=1000" }, + { value: "0x10", suffix: "?limit=16" }, + { value: "+8", suffix: "?limit=8" }, + { value: " 8 ", suffix: "?limit=8" }, + ]) { + await runR2Command(["buckets", "list", "--ns", "demo", "--limit", value, "--control-url", "http://ctl.test"], deps); + assert.equal(calls.at(-1)?.url, `http://ctl.test/ns/demo/r2/buckets${suffix}`); + } + + for (const value of INVALID_PAGE_LIMITS) { + await assert.rejects( + () => + runR2Command( + ["objects", "list", "--ns", "demo", "uploads", "--limit", value, "--control-url", "http://ctl.test"], + deps + ), + /--limit must be an integer/ + ); + } + assert.equal(calls.length, 7); }); test("r2 object get waits for stdout backpressure", async () => { diff --git a/tests/unit/cli-release.test.js b/tests/unit/cli-release.test.js new file mode 100644 index 0000000..fcea82c --- /dev/null +++ b/tests/unit/cli-release.test.js @@ -0,0 +1,31 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const CLI_ROOT = path.resolve(import.meta.dirname, "../.."); + +test("changelog section extraction gates stable release notes", () => { + const changelog = readFileSync(path.join(CLI_ROOT, "CHANGELOG.md"), "utf8"); + const stableVersion = /^## (\d+\.\d+\.\d+)\r?$/m.exec(changelog)?.[1]; + assert.ok(stableVersion, "CHANGELOG.md must contain a stable release section"); + const script = path.join(CLI_ROOT, "scripts", "changelog-section.js"); + const current = spawnSync(process.execPath, [script, stableVersion], { cwd: CLI_ROOT, encoding: "utf8" }); + assert.equal(current.status, 0, current.stderr); + assert.match(current.stdout, /\S/); + + const missing = spawnSync(process.execPath, [script, "0.0.0-missing"], { cwd: CLI_ROOT, encoding: "utf8" }); + assert.equal(missing.status, 3); + assert.match(missing.stderr, /requires a non-empty CHANGELOG\.md section/); + + const emptyDir = mkdtempSync(path.join(tmpdir(), "wdl-release-script-")); + try { + const unreadable = spawnSync(process.execPath, [script, stableVersion], { cwd: emptyDir, encoding: "utf8" }); + assert.equal(unreadable.status, 1); + assert.doesNotMatch(unreadable.stderr, /requires a non-empty CHANGELOG\.md section/); + } finally { + rmSync(emptyDir, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/cli-tail.test.js b/tests/unit/cli-tail.test.js index 0787a9f..d6eb9f9 100644 --- a/tests/unit/cli-tail.test.js +++ b/tests/unit/cli-tail.test.js @@ -43,6 +43,26 @@ test("SseParser rejects overlong lines", () => { assert.throws(() => parser.push(`data: ${"x".repeat(SSE_MAX_LINE_CHARS)}`), /SSE line exceeded/); }); +test("SseParser bounds cumulative event data and resets after dispatch", () => { + /** @type {import("../../commands/tail.js").SseEvent[]} */ + const events = []; + const parser = new SseParser((event) => events.push(event)); + parser.maxEventBytes = 5; + + parser.push("data: abc\ndata: d\n\n"); + parser.push("data: 12345\n\n"); + assert.deepEqual( + events.map((event) => event.data), + ["abc\nd", "12345"] + ); + + assert.throws(() => parser.push("data: abc\ndata: de\n"), /SSE event exceeded 5 bytes/); + + const multibyte = new SseParser(() => {}); + multibyte.maxEventBytes = 3; + assert.throws(() => multibyte.push("data: \u00e9\u00e9\n"), /SSE event exceeded 3 bytes/); +}); + test("wdl tail rejects errors raised while flushing a trailing SSE event", async () => { const fakeTransport = { /** diff --git a/tests/unit/cli-token-store.test.js b/tests/unit/cli-token-store.test.js index add557d..b250759 100644 --- a/tests/unit/cli-token-store.test.js +++ b/tests/unit/cli-token-store.test.js @@ -650,7 +650,7 @@ test("preserves a namespace named like an Object.prototype key", () => { test("handles a __proto__ section without polluting the prototype", () => { withTempDir((dir) => { const p = path.join(dir, "credentials"); - writeFileSync(p, '[__proto__]\nADMIN_TOKEN="x"\n[acme]\nADMIN_TOKEN="a"\n'); + writeFileSync(p, '[__proto__]\nADMIN_TOKEN="x"\n[acme]\nADMIN_TOKEN="a"\n', { mode: 0o600 }); const back = readTokenStore(p); assert.deepEqual(Object.keys(back.namespaces).sort(), ["__proto__", "acme"]); assert.equal(back.namespaces["__proto__"].ADMIN_TOKEN, "x"); @@ -737,6 +737,59 @@ test("assertStoreDirSecure refuses a group/world-writable store dir", POSIX_ONLY } }); +test("readTokenStore rejects insecure directories, permissions, and file types", POSIX_ONLY, () => { + withTempDir((dir) => { + const sharedDir = path.join(dir, "shared"); + const sharedStore = path.join(sharedDir, "credentials"); + mkdirSync(sharedDir, { mode: 0o700 }); + writeFileSync(sharedStore, '[acme]\nADMIN_TOKEN="tok"\n', { mode: 0o600 }); + chmodSync(sharedDir, 0o777); + assert.throws(() => readTokenStore(sharedStore), /refusing to read credentials: .*group\/world-writable/); + + const broadStore = path.join(dir, "broad-credentials"); + writeFileSync(broadStore, '[acme]\nADMIN_TOKEN="tok"\n', { mode: 0o600 }); + chmodSync(broadStore, 0o644); + assert.throws( + () => readTokenStore(broadStore), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.match(message, /accessible by group\/other users/); + assert.doesNotMatch(message, /failed to read credential store/, "the security error should not be wrapped"); + return true; + } + ); + + const target = path.join(dir, "target-credentials"); + const link = path.join(dir, "linked-credentials"); + writeFileSync(target, '[acme]\nADMIN_TOKEN="tok"\n', { mode: 0o600 }); + symlinkSync(target, link); + assert.throws(() => readTokenStore(link), /is not a regular file/); + + const directoryPath = path.join(dir, "directory-credentials"); + mkdirSync(directoryPath, { mode: 0o700 }); + assert.throws(() => readTokenStore(directoryPath), /is not a regular file/); + }); +}); + +test("token-store reads and writes through a secure symlinked config directory", POSIX_ONLY, () => { + withTempDir((dir) => { + const targetDir = path.join(dir, "target"); + const linkedDir = path.join(dir, "linked"); + mkdirSync(targetDir, { mode: 0o755 }); + chmodSync(targetDir, 0o755); + symlinkSync(targetDir, linkedDir); + + const storePath = path.join(linkedDir, "credentials"); + writeTokenStore(storePath, { namespaces: { acme: { ADMIN_TOKEN: "tok" } } }); + + assert.deepEqual(readTokenStore(storePath), { + defaultNs: null, + namespaces: { acme: { ADMIN_TOKEN: "tok" } }, + }); + assert.equal(statSync(targetDir).mode & 0o777, 0o700); + }); +}); + test("updateTokenStore escapes write-side filesystem errors", () => { withTempDir((dir) => { const badXdg = path.join(dir, `bad${ESC}dir\nFORGED\rBAD`); @@ -781,15 +834,20 @@ test("writeTokenStore escapes write-side filesystem errors", () => { test("readTokenStore rejects a key outside any section", () => { withTempDir((dir) => { const p = path.join(dir, "credentials"); - writeFileSync(p, "ADMIN_TOKEN=loose\n"); - assert.throws(() => readTokenStore(p), /outside a \[namespace\] section/); + writeFileSync(p, "ADMIN_TOKEN=loose\n", { mode: 0o600 }); + assert.throws( + () => readTokenStore(p), + new RegExp( + `failed to parse credential store .*credentials: Invalid credentials line 1.*outside a \\[namespace\\]` + ) + ); }); }); test("readTokenStore reads a base WDL_NS as the default namespace", () => { withTempDir((dir) => { const p = path.join(dir, "credentials"); - writeFileSync(p, 'WDL_NS="acme"\n[acme]\nADMIN_TOKEN="t"\n'); + writeFileSync(p, 'WDL_NS="acme"\n[acme]\nADMIN_TOKEN="t"\n', { mode: 0o600 }); assert.deepEqual(readTokenStore(p), { defaultNs: "acme", namespaces: { acme: { ADMIN_TOKEN: "t" } }, @@ -800,7 +858,7 @@ test("readTokenStore reads a base WDL_NS as the default namespace", () => { test("readTokenStore ignores unknown keys and comments", () => { withTempDir((dir) => { const p = path.join(dir, "credentials"); - writeFileSync(p, '# note\n[acme]\nADMIN_TOKEN="t"\nUNKNOWN=x\n'); + writeFileSync(p, '# note\n[acme]\nADMIN_TOKEN="t"\nUNKNOWN=x\n', { mode: 0o600 }); assert.deepEqual(readTokenStore(p), { defaultNs: null, namespaces: { acme: { ADMIN_TOKEN: "t" } }, diff --git a/tests/unit/cli-workflows.test.js b/tests/unit/cli-workflows.test.js index 2c863ae..962f059 100644 --- a/tests/unit/cli-workflows.test.js +++ b/tests/unit/cli-workflows.test.js @@ -2,7 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { runWorkflowsCommand } from "../../commands/workflows.js"; import { formatInstanceList, formatInstanceStatus, formatWorkflowList } from "../../lib/workflows-format.js"; -import { ESC, assertNoRawTerminalControls, mockDeps, response } from "./helpers.js"; +import { ESC, INVALID_PAGE_LIMITS, assertNoRawTerminalControls, mockDeps, response } from "./helpers.js"; /** @typedef {import("./helpers.js").ControlCall} ControlCall */ @@ -162,6 +162,36 @@ test("workflows list accepts flags before the subcommand", async () => { assert.deepEqual(lines, ["(no workflows)"]); }); +test("workflow page limits must be integers from 1 through 1000", async () => { + const calls = []; + const deps = { + env: { ADMIN_TOKEN: "tok", CONTROL_URL: "http://ctl.test" }, + stdout: () => {}, + controlFetch: async () => { + calls.push(true); + return response({}); + }, + }; + + await runWorkflowsCommand(["status", "api", "orders", "id", "--step-limit", "", "--ns", "demo"], deps); + + for (const value of INVALID_PAGE_LIMITS) { + await assert.rejects( + () => runWorkflowsCommand(["instances", "api", "orders", "--limit", value, "--ns", "demo"], deps), + /workflows --limit must be an integer in \[1, 1000\]/ + ); + await assert.rejects( + () => + runWorkflowsCommand( + ["status", "api", "orders", "id", "--include-steps", "--step-limit", value, "--ns", "demo"], + deps + ), + /workflows --step-limit must be an integer in \[1, 1000\]/ + ); + } + assert.equal(calls.length, 1); +}); + test("workflows commands reject unexpected positional arguments", async () => { /** @type {boolean[]} */ const calls = []; diff --git a/tests/unit/cli-wrangler-command.test.js b/tests/unit/cli-wrangler-command.test.js index 569cb69..74428c5 100644 --- a/tests/unit/cli-wrangler-command.test.js +++ b/tests/unit/cli-wrangler-command.test.js @@ -53,6 +53,26 @@ test("probeWranglerVersion returns one parsed version shape for deploy and docto }); }); +test("checkWranglerVersion accepts only the supported v4 major", () => { + const base = { + cwd: "/tmp/project", + env: {}, + wrangler: { command: "wrangler", args: [] }, + }; + assert.deepEqual(checkWranglerVersion({ ...base, execFile: versionExecFile("4.123.0") }), { + version: "4.123.0", + major: 4, + }); + assert.throws( + () => checkWranglerVersion({ ...base, execFile: versionExecFile("3.99.0") }), + /requires Wrangler v4 .* found v3/ + ); + assert.throws( + () => checkWranglerVersion({ ...base, execFile: versionExecFile("5.0.0") }), + /requires Wrangler v4 .* found v5/ + ); +}); + test("checkWranglerVersion escapes unparsable version diagnostics", () => { const execFile = /** @type {typeof import("node:child_process").execFileSync} */ ( /** @type {unknown} */ (() => `bad\u009b31m\nFORGED\rBAD`) @@ -215,8 +235,9 @@ test("resolveWranglerCommand only uses npx when explicitly allowed", () => { absProject: "/project", env: { WDL_ALLOW_NPX_WRANGLER: "1" }, packageDirs: [], + platform: "linux", }), - { command: "npx", args: ["--yes", "wrangler"], source: "npx" } + { command: "npx", args: ["--yes", "wrangler@^4"], source: "npx" } ); }); @@ -310,7 +331,46 @@ test("resolveWranglerCommand on win32 fails loudly when only a bare PATH .cmd sh }), /No runnable wrangler found/ ); - // The npx opt-in still provides a working escape hatch. + const npxScript = path.join(dir, "node_modules", "npm", "bin", "npx-cli.js"); + mkdirSync(path.dirname(npxScript), { recursive: true }); + writeFileSync(npxScript, ""); + // The npx opt-in runs npm's JS entry instead of its blocked .cmd shim. + assert.deepEqual( + resolveWranglerCommand({ + absProject: "/project", + env: { PATH: dir, WDL_ALLOW_NPX_WRANGLER: "1" }, + packageDirs: [], + platform: "win32", + }), + { + command: process.execPath, + args: [npxScript, "--yes", "wrangler@^4"], + source: "npx", + } + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("resolveWranglerCommand on win32 rejects an npx opt-in without a runnable JS entry", () => { + assert.throws( + () => + resolveWranglerCommand({ + absProject: "/project", + env: { PATH: "", WDL_ALLOW_NPX_WRANGLER: "1" }, + packageDirs: [], + platform: "win32", + }), + /no runnable npx installation was found/ + ); +}); + +test("resolveWranglerCommand on win32 runs an executable npx shim directly", () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-wrangler-win32-npx-exe-")); + try { + const executable = path.join(dir, "npx.exe"); + writeFileSync(executable, ""); assert.deepEqual( resolveWranglerCommand({ absProject: "/project", @@ -318,7 +378,11 @@ test("resolveWranglerCommand on win32 fails loudly when only a bare PATH .cmd sh packageDirs: [], platform: "win32", }), - { command: "npx", args: ["--yes", "wrangler"], source: "npx" } + { + command: executable, + args: ["--yes", "wrangler@^4"], + source: "npx", + } ); } finally { rmSync(dir, { recursive: true, force: true }); diff --git a/tests/unit/helpers.js b/tests/unit/helpers.js index 3c31fe7..e8be831 100644 --- a/tests/unit/helpers.js +++ b/tests/unit/helpers.js @@ -13,6 +13,8 @@ export const POSIX_ONLY = { skip: process.platform === "win32" ? "POSIX-only filesystem behavior" : false, }; +export const INVALID_PAGE_LIMITS = ["0", "1001", "1.5", "many"]; + // The uid does not decide this — the effective uid, CAP_DAC_OVERRIDE, and // mode-ignoring mounts do. Ask tmpdir() what it actually does. function modeBitsBlockReads() { diff --git a/tsconfig.json b/tsconfig.json index d984d83..8a3ca31 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,6 @@ "lib": ["ES2024", "DOM", "DOM.Iterable"], "types": ["node"] }, - "include": ["bin/**/*.js", "commands/**/*.js", "lib/**/*.js", "tests/**/*.js", "eslint.config.js"], + "include": ["bin/**/*.js", "commands/**/*.js", "lib/**/*.js", "scripts/**/*.js", "tests/**/*.js", "eslint.config.js"], "exclude": ["**/node_modules/**", "**/.deploy-dist/**", "**/.wrangler/**"] } From 8b1dcda58c4f6c391d0e342343ecadce27d64b61 Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Thu, 20 Aug 2026 01:54:19 +0800 Subject: [PATCH 2/2] Close credential trust gaps Read token stores through a validated no-follow file descriptor. Reject unsafe control URL components without echoing embedded credentials. Clarify token-store ownership remediation and local endpoint behavior. Signed-off-by: Lu Zhang --- .claude/skills/wdl-deploy/SKILL.md | 9 ++++--- CHANGELOG.md | 7 +++--- GUIDE-zh.md | 4 +-- GUIDE.md | 12 +++++---- docs/deploy-zh.md | 2 +- docs/deploy.md | 3 ++- docs/token-zh.md | 2 +- docs/token.md | 10 +++++--- lib/credentials.js | 12 +++++---- lib/token-store.js | 37 +++++++++++++++++++++++---- tests/unit/cli-credentials.test.js | 40 +++++++++++++++++++++++++++++- tests/unit/cli-token-store.test.js | 21 +++++++++++++++- 12 files changed, 126 insertions(+), 33 deletions(-) diff --git a/.claude/skills/wdl-deploy/SKILL.md b/.claude/skills/wdl-deploy/SKILL.md index 2a9a5ac..0c01a08 100644 --- a/.claude/skills/wdl-deploy/SKILL.md +++ b/.claude/skills/wdl-deploy/SKILL.md @@ -93,10 +93,11 @@ stubs as opaque capabilities, but the receiver cannot rewrite their host-authored caller properties. Keep delegated stubs in memory; long-term irrevocable stub storage is unsupported. -`CONTROL_URL` may include a path prefix, but query strings and fragments are -rejected. A bare `.local` host defaults to HTTPS because mDNS is not loopback, -except that the existing bare `:8080` rule still selects HTTP on any host. Every -HTTP `.local` target emits the plaintext-token warning. +`CONTROL_URL` may include a path prefix, but embedded usernames/passwords, query +strings, and fragments are rejected. A bare `.local` host defaults to HTTPS +because mDNS is not loopback, except that the existing bare `:8080` rule still +selects HTTP on any host. Every HTTP `.local` target emits the plaintext-token +warning. Never recommend setting `CONTROL_CONNECT_HOST` outside local development: it overrides the TCP target the admin token connects to (Host header + TLS SNI diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f82def..ea620b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,9 @@ ### Fixed -- Reject Control base URLs containing query strings or fragments instead of - appending command paths into the wrong URL component. +- Reject Control base URLs containing embedded credentials, query strings, or + fragments instead of risking credential disclosure or appending command paths + into the wrong URL component. - Keep `wdl config explain` usable when credential resolution needs the token store but finds it malformed, unreadable, or unsafe by reporting `tokenStore.error` alongside the remaining provenance; fully covered @@ -30,7 +31,7 @@ - Require confirmation for `wdl delete version` and reject its unsupported `--dry-run` flag instead of silently performing the deletion. - Reject non-file or symlink credential paths on read, and fail closed when - POSIX token-store directory or file permissions are unsafe. + POSIX token-store file ownership or directory/file permissions are unsafe. - Tighten local HTTP classification: recognize case-insensitive `localhost` and `.test` hosts plus the full `127.0.0.0/8` range as local targets, but treat `.local` as a network/mDNS host. Bare `.local` hosts default to HTTPS except diff --git a/GUIDE-zh.md b/GUIDE-zh.md index e9097e7..d1cf74b 100644 --- a/GUIDE-zh.md +++ b/GUIDE-zh.md @@ -85,11 +85,11 @@ ADMIN_TOKEN= ADMIN_TOKEN= ``` -CLI 只会从 `.env` 读取 WDL 平台变量:`ADMIN_TOKEN`、`CONTROL_URL`、`CONTROL_CONNECT_HOST`、`WDL_NS`。优先级是 `CLI flag > shell/CI env > [resolved-ns] section > base .env > wdl token store`,都没有提供时命令直接报错——没有内置默认值。namespace 解析顺序是 `--ns`,然后是 shell 或 base `.env` 里的 `WDL_NS`,再然后是 token store 的默认 namespace。section 名可以是 `[acme]` 这类 tenant namespace,也可以是 `[__name__]` 这种运维保留的不透明 section。Tenant Wrangler 配置默认仍使用普通 tenant namespace 语法,除非运维方明确给了这种 namespace token;否则不要把 `__name__` 形态写进 `[[services]].ns`、`allowed_callers` 或命令示例。如果没有解析出 namespace,section 会全部跳过;后续命令如果需要 namespace 或 token,会按正常校验报错。只有临时切换 namespace 时才需要显式传 `--ns`。不带 scheme 的生产 control host(例如 `api.wdl.dev`)默认补 `https://`;loopback 和保留的 `*.test` host 默认补 `http://`。既有的裸 `:8080` 例外对任何 host(包括 `.local`)仍默认使用 HTTP。除此之外,`.local` 是局域网 / mDNS 后缀而不是 loopback,因此裸 host 默认 HTTPS;所有 HTTP `.local` 目标都会显示明文 token 告警。需要强制使用其它协议时,请显式写 scheme。Control URL 可以包含 path prefix,但不能包含 query string 或 fragment,因为命令会在这个 base URL 后追加 endpoint path。 +CLI 只会从 `.env` 读取 WDL 平台变量:`ADMIN_TOKEN`、`CONTROL_URL`、`CONTROL_CONNECT_HOST`、`WDL_NS`。优先级是 `CLI flag > shell/CI env > [resolved-ns] section > base .env > wdl token store`,都没有提供时命令直接报错——没有内置默认值。namespace 解析顺序是 `--ns`,然后是 shell 或 base `.env` 里的 `WDL_NS`,再然后是 token store 的默认 namespace。section 名可以是 `[acme]` 这类 tenant namespace,也可以是 `[__name__]` 这种运维保留的不透明 section。Tenant Wrangler 配置默认仍使用普通 tenant namespace 语法,除非运维方明确给了这种 namespace token;否则不要把 `__name__` 形态写进 `[[services]].ns`、`allowed_callers` 或命令示例。如果没有解析出 namespace,section 会全部跳过;后续命令如果需要 namespace 或 token,会按正常校验报错。只有临时切换 namespace 时才需要显式传 `--ns`。不带 scheme 的生产 control host(例如 `api.wdl.dev`)默认补 `https://`;loopback 和保留的 `*.test` host 默认补 `http://`。既有的裸 `:8080` 例外对任何 host(包括 `.local`)仍默认使用 HTTP。除此之外,`.local` 是局域网 / mDNS 后缀而不是 loopback,因此裸 host 默认 HTTPS;所有 HTTP `.local` 目标都会显示明文 token 告警。需要强制使用其它协议时,请显式写 scheme。Control URL 可以包含 path prefix,但不能嵌入 username/password,也不能包含 query string 或 fragment,因为命令会在这个 base URL 后追加 endpoint path,并单独发送 admin token。 `CONTROL_CONNECT_HOST` 是本地开发 / 调试用的覆盖开关:它改变请求实际连接的 TCP 目标,而 HTTP Host header 和 TLS SNI 仍跟随 `CONTROL_URL`(所以 HTTPS 下控制面证书仍会拒绝被重定向的连接;纯 http 没有这层保护)。只在本地开发用 —— 不要在 CI 或生产 shell 中持久设置,残留值可能把 admin token 路由到非预期目标。覆盖值写成 URL 时,scheme 只决定默认 TCP 端口(`http` 为 80,`https` 为 443);请求使用 HTTP 还是 HTTPS、Host 和 SNI 仍由 `CONTROL_URL` 决定。 -推荐的做法是把这些凭证放进托管存储,而不是 shell export 或项目 `.env`:`wdl token set --ns --control-url ` 用隐藏输入读取 token、调 `/whoami` 校验后按 namespace 存入 `~/.config/wdl/credentials`(不进 shell 历史、也不落在项目文件里)。存储是优先级最低的层——命令行标志、shell env、项目 `.env` 仍然胜出——`wdl token list` / `wdl token rm` 管理它。第一个存入的 namespace 成为默认(一行 base `WDL_NS`,和项目 `.env` 一样),命令不带 `--ns` 也能跑;`wdl token use ` 切换默认。CLI 会拒绝 symlink / 非普通文件形式的 credentials 路径;在 POSIX 上,如果 store 目录可被 group/other 写或文件能被 group/other 访问,也会拒绝读取。详见 [token-zh.md](./docs/token-zh.md)。 +推荐的做法是把这些凭证放进托管存储,而不是 shell export 或项目 `.env`:`wdl token set --ns --control-url ` 用隐藏输入读取 token、调 `/whoami` 校验后按 namespace 存入 `~/.config/wdl/credentials`(不进 shell 历史、也不落在项目文件里)。存储是优先级最低的层——命令行标志、shell env、项目 `.env` 仍然胜出——`wdl token list` / `wdl token rm` 管理它。第一个存入的 namespace 成为默认(一行 base `WDL_NS`,和项目 `.env` 一样),命令不带 `--ns` 也能跑;`wdl token use ` 切换默认。CLI 会拒绝 symlink / 非普通文件形式的 credentials 路径;在 POSIX 上,如果 store 文件不属于当前用户、store 目录可被 group/other 写或文件能被 group/other 访问,也会拒绝读取。详见 [token-zh.md](./docs/token-zh.md)。 `wdl ai`、`wdl secret` 和 `wdl token` 都可能接收凭据,因此会脱敏无效参数的细节。如果完整子命令路径前的 string option 使用分离式值,而该值本身也是命令词,请把子命令放到前面,或改用 `--flag=value` 消歧。例如写 `wdl secret list --worker put` 或 `wdl secret --worker=put list`,不要写 `wdl secret --worker put list`。 diff --git a/GUIDE.md b/GUIDE.md index 51ed72c..24169ba 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -117,10 +117,11 @@ on any host, including `.local`. Outside that exception, `.local` is a network/mDNS suffix rather than loopback, so its bare hosts default to HTTPS. Every HTTP `.local` target emits the plaintext-token warning. Include an explicit scheme when you need to force a different protocol. A control URL may -include a path prefix, but query strings and fragments are rejected because -commands append endpoint paths to this base. If no namespace resolves, section -values are skipped and the command will fail normally if it needs a namespace or -token. Pass `--ns` when you want to override the default for one command. +include a path prefix, but embedded usernames/passwords, query strings, and +fragments are rejected because commands append endpoint paths to this base and +send the admin token separately. If no namespace resolves, section values are +skipped and the command will fail normally if it needs a namespace or token. +Pass `--ns` when you want to override the default for one command. `CONTROL_CONNECT_HOST` is a local-dev / debug override: it changes the TCP target the request connects to while the HTTP Host header and TLS SNI keep @@ -141,7 +142,8 @@ shell env, and a project `.env` still win — and `wdl token list` / `WDL_NS`, like a project `.env`'s), so commands run without `--ns`; `wdl token use ` switches it. The CLI rejects a symlink/non-file credentials path. On POSIX, it also rejects a store in a group/world-writable directory or a -file accessible to group/other users. See [token.md](./docs/token.md). +file that is not owned by the current user or is accessible to group/other +users. See [token.md](./docs/token.md). Because `wdl ai`, `wdl secret`, and `wdl token` can receive credentials, they redact invalid argument details. If a string option appears before the complete diff --git a/docs/deploy-zh.md b/docs/deploy-zh.md index b820072..6761f1c 100644 --- a/docs/deploy-zh.md +++ b/docs/deploy-zh.md @@ -34,7 +34,7 @@ CLI 需要三个值: **CI / 自动化:** 把 `ADMIN_TOKEN`、`CONTROL_URL`、`WDL_NS` 作为环境变量从 CI secret store 注入 —— 不用交互式的 token store,也绝不提交 `.env`。 -裸 control host 会自动补 scheme;生产 host 默认 `https://`,loopback 和 `.test` host 默认 `http://`。既有的裸 `:8080` 例外对任何 host(包括 `.local`)仍默认使用 HTTP。除此之外,`.local` 是局域网 / mDNS 后缀而不是 loopback,因此默认 HTTPS;所有 HTTP `.local` 目标都会显示明文 token 告警。如果要强制协议,直接显式写 `https://...` 或 `http://...`。Control URL 可以包含 path prefix,但 query string 和 fragment 会被拒绝。 +裸 control host 会自动补 scheme;生产 host 默认 `https://`,loopback 和 `.test` host 默认 `http://`。既有的裸 `:8080` 例外对任何 host(包括 `.local`)仍默认使用 HTTP。除此之外,`.local` 是局域网 / mDNS 后缀而不是 loopback,因此默认 HTTPS;所有 HTTP `.local` 目标都会显示明文 token 告警。如果要强制协议,直接显式写 `https://...` 或 `http://...`。Control URL 可以包含 path prefix,但嵌入的 username/password、query string 和 fragment 会被拒绝。 优先级:`CLI 标志 > shell env > .env 中 [] 段 > .env 基础段 > wdl token store`。都没有提供时命令直接报错——没有内置默认值。 diff --git a/docs/deploy.md b/docs/deploy.md index 2a7083f..4250ec3 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -64,7 +64,8 @@ bare `:8080` exception also defaults to HTTP on any host, including `.local`. Outside that exception, `.local` defaults to HTTPS because mDNS is not loopback; every HTTP `.local` target emits the plaintext-token warning. To force a protocol, write `https://...` or `http://...` explicitly. A control URL may -include a path prefix, but query strings and fragments are rejected. +include a path prefix, but embedded usernames/passwords, query strings, and +fragments are rejected. Precedence: `CLI flag > shell env > .env [] section > .env base section > wdl token store`. diff --git a/docs/token-zh.md b/docs/token-zh.md index 02a9d30..c6b1e4e 100644 --- a/docs/token-zh.md +++ b/docs/token-zh.md @@ -19,7 +19,7 @@ ADMIN_TOKEN="" LABEL="production" ``` -它由命令独占:`wdl token` 会 canonical 重写整个文件(默认在前,然后排序、加引号的各段),所以项目专属的值请手编项目 `.env`。文件以 `0600` 权限写入。读取时会拒绝非普通文件或 symlink 的 credentials 路径;在 POSIX 上还会 fail closed:所在目录不能 group/world-writable,且 group/other 用户不可访问该文件。可信 store 可用 `chmod 700 ` 和 `chmod 600 ` 修复;不要为了共享 store 放宽这些检查。 +它由命令独占:`wdl token` 会 canonical 重写整个文件(默认在前,然后排序、加引号的各段),所以项目专属的值请手编项目 `.env`。文件以 `0600` 权限写入。读取时会拒绝非普通文件或 symlink 的 credentials 路径;在 POSIX 上还会 fail closed:文件必须属于当前用户,所在目录不能 group/world-writable,且 group/other 用户不可访问该文件。如果可信 store 的 owner 错误,请由管理员执行 `chown `,或删除后用 `wdl token set` 重建各条目;然后用 `chmod 700 ` 和 `chmod 600 ` 修复权限。不要为了共享 store 放宽这些检查。 ## 命令 diff --git a/docs/token.md b/docs/token.md index 20f8bd3..f9bc7c9 100644 --- a/docs/token.md +++ b/docs/token.md @@ -32,10 +32,12 @@ It is command-owned: `wdl token` rewrites it canonically (default first, then sorted, quoted sections), so hand-edit a project `.env` for project-specific values instead. The file is written with `0600` permissions. Reads reject a credentials path that is not a regular, non-symlink file. On POSIX, they also -fail closed unless the containing directory is not group/world-writable and the -file is inaccessible to group and other users. Repair a trusted store with -`chmod 700 ` and `chmod 600 `; do not relax these checks for a shared -store. +fail closed unless the file is owned by the current user, the containing +directory is not group/world-writable, and the file is inaccessible to group and +other users. If a trusted store has the wrong owner, use `chown ` +from an administrative account, or delete it and recreate the entries with +`wdl token set`. Then apply `chmod 700 ` and `chmod 600 `; do not +relax these checks for a shared store. ## Commands diff --git a/lib/credentials.js b/lib/credentials.js index dde2f9b..27e8d5e 100644 --- a/lib/credentials.js +++ b/lib/credentials.js @@ -31,17 +31,19 @@ export function resolveControlUrl(values, env = process.env) { try { parsed = new URL(normalized); } catch { - throw new CliError(`Invalid control URL ${formatDiagnosticValue(raw)}.`); + throw new CliError("Invalid control URL."); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new CliError(`Invalid control URL ${formatDiagnosticValue(raw)}: expected http:// or https://.`); + throw new CliError("Invalid control URL: expected http:// or https://."); + } + if (parsed.username || parsed.password) { + // Do not echo the raw URL: it may contain a password. + throw new CliError("Invalid control URL: embedded usernames and passwords are not supported."); } // URL.search/hash are empty for a bare trailing `?` / `#`, even though // appending endpoint text would still put it in that URL component. if (/[?#]/.test(normalized)) { - throw new CliError( - `Invalid control URL ${formatDiagnosticValue(raw)}: query strings and fragments are not supported.` - ); + throw new CliError("Invalid control URL: query strings and fragments are not supported."); } return normalized; } diff --git a/lib/token-store.js b/lib/token-store.js index 5f2c933..9acae34 100644 --- a/lib/token-store.js +++ b/lib/token-store.js @@ -1,8 +1,12 @@ import { randomUUID } from "node:crypto"; import { chmodSync, + closeSync, + constants, + fstatSync, lstatSync, mkdirSync, + openSync, readFileSync, renameSync, rmSync, @@ -83,12 +87,32 @@ export function readTokenStore(storePath) { /** @type {string} */ let text; try { - const storeStat = lstatSync(storePath); - assertStoreFileSecure(storePath, storeStat); - assertStoreDirSecure(path.dirname(storePath), process.platform, "read"); - text = readFileSync(storePath, "utf8"); + // POSIX O_NOFOLLOW closes the lstat/open race on the final path component. + // Windows has no equivalent fs flag, so retain the static lstat rejection + // there; fstat still validates the descriptor actually read on all hosts. + const expectedStoreStat = process.platform === "win32" ? lstatSync(storePath) : null; + if (expectedStoreStat) assertStoreFileSecure(storePath, expectedStoreStat); + const flags = constants.O_RDONLY | (process.platform === "win32" ? 0 : constants.O_NOFOLLOW | constants.O_NONBLOCK); + const fd = openSync(storePath, flags); + try { + const openedStoreStat = fstatSync(fd); + assertStoreFileSecure(storePath, openedStoreStat); + if ( + expectedStoreStat && + (expectedStoreStat.dev !== openedStoreStat.dev || expectedStoreStat.ino !== openedStoreStat.ino) + ) { + throw new CliError(`refusing to read credentials: ${escapeTerminalText(storePath)} changed while opening`); + } + assertStoreDirSecure(path.dirname(storePath), process.platform, "read"); + text = readFileSync(fd, "utf8"); + } finally { + closeSync(fd); + } } catch (err) { if (hasErrorCode(err) && err.code === "ENOENT") return { defaultNs: null, namespaces: {} }; + if (hasErrorCode(err) && err.code === "ELOOP") { + throw new CliError(`refusing to read credentials: ${escapeTerminalText(storePath)} is not a regular file`); + } throw wrapTokenStoreFsError(err, `failed to read credential store ${escapeTerminalText(storePath)}`); } @@ -202,7 +226,7 @@ export function assertStoreDirSecure(storeDir, platform = process.platform, oper function statStoreDir(storeDir, operation) { // Follow a user-managed config-directory symlink (or Windows junction), but // validate the resolved target before using it. The credentials file itself - // remains lstat-checked and may not be a symlink. + // is independently opened and descriptor-checked, and may not be a symlink. const storeStat = statSync(storeDir); if (!storeStat.isDirectory()) { throw new CliError(`refusing to ${operation} credentials: ${escapeTerminalText(storeDir)} is not a directory`); @@ -220,6 +244,9 @@ function assertStoreFileSecure(storePath, storeStat, platform = process.platform if (!storeStat.isFile()) { throw new CliError(`refusing to read credentials: ${escapedPath} is not a regular file`); } + if (platform !== "win32" && typeof process.geteuid === "function" && storeStat.uid !== process.geteuid()) { + throw new CliError(`refusing to read credentials: ${escapedPath} is not owned by the current user`); + } if (platform !== "win32" && (storeStat.mode & 0o077) !== 0) { const quotedPath = shellArgForDisplay(storePath); throw new CliError( diff --git a/tests/unit/cli-credentials.test.js b/tests/unit/cli-credentials.test.js index 88bf22e..9c583ce 100644 --- a/tests/unit/cli-credentials.test.js +++ b/tests/unit/cli-credentials.test.js @@ -38,12 +38,13 @@ test("resolveControlUrl requires a configured endpoint", () => { assert.throws(() => resolveControlUrl({}, {}), /No control URL configured/); }); -test("resolveControlUrl escapes invalid endpoint diagnostics", () => { +test("resolveControlUrl does not echo invalid endpoint input", () => { assert.throws( () => resolveControlUrl({ "control-url": `ftp://ctl.test/${ESC}[2J\u009b` }, {}), (err) => { const message = /** @type {Error} */ (err).message; assert.match(message, /Invalid control URL/); + assert.doesNotMatch(message, /ctl\.test/); assertNoRawTerminalControls(message, "control URL errors"); return true; } @@ -71,6 +72,43 @@ test("resolveControlUrl rejects query strings and fragments", () => { } }); +test("resolveControlUrl errors never echo embedded credentials", () => { + /** @type {{ values: Record, env: NodeJS.ProcessEnv, expected: RegExp }[]} */ + const cases = [ + { + values: { "control-url": "https://api.wdl.dev@evil.example" }, + env: {}, + expected: /embedded usernames and passwords/, + }, + { + values: {}, + env: { CONTROL_URL: "https://operator:SENTINEL_PASSWORD@ctl.example" }, + expected: /embedded usernames and passwords/, + }, + { + values: { "control-url": "ftp://operator:SENTINEL_PASSWORD@ctl.example" }, + env: {}, + expected: /expected http:\/\/ or https:\/\//, + }, + { + values: { "control-url": "https://operator:SENTINEL_PASSWORD@[invalid" }, + env: {}, + expected: /^Invalid control URL\.$/, + }, + ]; + for (const { values, env, expected } of cases) { + assert.throws( + () => resolveControlUrl(values, env), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.match(message, expected); + assert.doesNotMatch(message, /api\.wdl\.dev|operator|SENTINEL_PASSWORD|evil\.example|ctl\.example/); + return true; + } + ); + } +}); + test("resolveControlUrl keeps bare local dev control URLs on http", () => { assert.equal(resolveControlUrl({}, { CONTROL_URL: "ctl.test:8080" }), "http://ctl.test:8080"); assert.equal(resolveControlUrl({}, { CONTROL_URL: "dev.local:8080" }), "http://dev.local:8080"); diff --git a/tests/unit/cli-token-store.test.js b/tests/unit/cli-token-store.test.js index b250759..aed0187 100644 --- a/tests/unit/cli-token-store.test.js +++ b/tests/unit/cli-token-store.test.js @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { chmodSync, existsSync, @@ -768,6 +768,25 @@ test("readTokenStore rejects insecure directories, permissions, and file types", const directoryPath = path.join(dir, "directory-credentials"); mkdirSync(directoryPath, { mode: 0o700 }); assert.throws(() => readTokenStore(directoryPath), /is not a regular file/); + + const fifoPath = path.join(dir, "fifo-credentials"); + const mkfifo = spawnSync("mkfifo", [fifoPath], { encoding: "utf8" }); + assert.equal(mkfifo.status, 0, mkfifo.stderr || "mkfifo failed"); + const fifoRead = spawnSync( + process.execPath, + [ + "--input-type=module", + "--eval", + `import { readTokenStore } from ${JSON.stringify(new URL("../../lib/token-store.js", import.meta.url).href)}; +try { readTokenStore(${JSON.stringify(fifoPath)}); } +catch (err) { process.stderr.write(err.message); process.exit(2); }`, + ], + { encoding: "utf8", timeout: 1_000 } + ); + const fifoReadError = /** @type {NodeJS.ErrnoException | undefined} */ (fifoRead.error); + assert.notEqual(fifoReadError?.code, "ETIMEDOUT", "reading a FIFO credential path must not block"); + assert.equal(fifoRead.status, 2, fifoRead.stderr); + assert.match(fifoRead.stderr, /is not a regular file/); }); });