From e6fdcab0b491e9d61d106a36a84ea99bae71569d Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Mon, 15 Jun 2026 10:45:03 +0800 Subject: [PATCH 01/13] Add wdl token: a local credential store `wdl token set/list/use/rm` stores control URLs and tokens in ~/.config/wdl/credentials, so commands resolve them without a per-shell ADMIN_TOKEN or a token in every project's .env. There's no "login": `set` reads the token from stdin, validates it against /whoami, confirms its principal matches the namespace, then stores it; `rm` deletes the local copy without revoking it. The store reuses the project-.env dialect (one parser), is written 0600, and is the lowest-precedence credential layer (flag > shell env > project .env > store). It's trusted and exempt from the cross-origin guard, but a project .env endpoint is still dropped when its token isn't from that .env, so an untrusted cwd can't redirect a stored token. A base WDL_NS names a default namespace (like a project .env's base WDL_NS): the first stored namespace becomes the default, `--default` / `use` change it, `list` marks it `*`, selection is --ns > shell WDL_NS > .env WDL_NS > store default. CHANGELOG sits under Unreleased; the version bump and tag are a separate release commit. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/wdl-deploy/SKILL.md | 3 + AGENTS.md | 12 + CHANGELOG.md | 25 ++ GUIDE-zh.md | 2 +- GUIDE.md | 13 +- README-zh.md | 1 + README.md | 1 + bin/wdl.js | 5 +- commands/token.js | 220 +++++++++++++++++ docs/README-zh.md | 1 + docs/README.md | 1 + docs/token-zh.md | 76 ++++++ docs/token.md | 107 ++++++++ lib/common.js | 58 ++++- lib/config-state.js | 15 +- lib/token-store.js | 129 ++++++++++ templates/AGENTS.md | 1 + tests/unit/cli-token-store.test.js | 153 ++++++++++++ tests/unit/cli-token.test.js | 377 +++++++++++++++++++++++++++++ 19 files changed, 1186 insertions(+), 14 deletions(-) create mode 100644 commands/token.js create mode 100644 docs/token-zh.md create mode 100644 docs/token.md create mode 100644 lib/token-store.js create mode 100644 tests/unit/cli-token-store.test.js create mode 100644 tests/unit/cli-token.test.js diff --git a/.claude/skills/wdl-deploy/SKILL.md b/.claude/skills/wdl-deploy/SKILL.md index b59822e..2453bf7 100644 --- a/.claude/skills/wdl-deploy/SKILL.md +++ b/.claude/skills/wdl-deploy/SKILL.md @@ -20,6 +20,9 @@ Open the relevant doc before answering: commands. - `docs/secrets.md` — `wdl secret` (worker-level vs namespace-level), runtime secret precedence, `--json` automation output, anti-patterns. +- `docs/token.md` — `wdl token set/list/use/rm`, the local credential store + (`~/.config/wdl/credentials`), its default namespace, and where it sits in + credential resolution. - `docs/d1.md` — `[[d1_databases]]` config, `wdl d1` commands, migrations. - `docs/durable-objects.md` — `[[durable_objects.bindings]]`, migration class declarations, the DO runtime surface. diff --git a/AGENTS.md b/AGENTS.md index 8703b61..a9d6761 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,6 +123,18 @@ when behavior changes. ## Security & Configuration Tips +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 — `--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 intact when touching `loadCliControlEnv` or +`lib/token-store.js`. + Do not commit tenant tokens or generated secrets. Read credentials from the environment (`ADMIN_TOKEN`, `CONTROL_URL`, `WDL_NS`) and keep example configuration generic. When adding deploy features, validate unsupported diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a00122..aad2799 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## Unreleased + +### Added + +- `wdl token set/list/use/rm` manages a local credential store at + `~/.config/wdl/credentials` (`$XDG_CONFIG_HOME`/`%APPDATA%` honored), so + commands resolve a control URL and token without a per-shell `ADMIN_TOKEN` + export or a token in every project's `.env`. `set` reads the token from stdin + (hidden on a TTY) and validates it against `/whoami` before storing it under + the namespace; `rm` deletes the local copy without revoking it. The store is + the same `dotenv`/INI dialect as a project `.env`, written `0600`, and is the + lowest-precedence credential layer: + `flag > shell env > project .env > token store`. It is trusted (home + directory, same-source token + endpoint) and is not subject to the + cross-origin `.env` guard, while a project `.env` endpoint is still dropped + when the token comes from the store. `wdl config explain` shows + `token store [].…` as a value's source. +- The store carries a default namespace (a base `WDL_NS`, the analogue of a + project `.env`'s base `WDL_NS`): the first stored namespace becomes the + default, `wdl token set --default` and `wdl token use ` change it, and + `wdl token list` marks it with `*`. With a default set, commands resolve a + namespace without `--ns`; the selection chain is + `--ns > shell WDL_NS > project .env WDL_NS > store default`, and + `wdl config explain` shows `token store default` as the namespace source. + ## 1.0.0 Initial open-source release. diff --git a/GUIDE-zh.md b/GUIDE-zh.md index b7aa876..5278b7b 100644 --- a/GUIDE-zh.md +++ b/GUIDE-zh.md @@ -83,7 +83,7 @@ ADMIN_TOKEN= CLI 只会从 `.env` 读取 WDL 平台变量:`ADMIN_TOKEN`、`ADMIN_URL`、 `CONTROL_URL`、`CONTROL_CONNECT_HOST`、`WDL_NS`。优先级是 `CLI flag > shell/CI env > [resolved-ns] section > base .env`,都没有提供时命令直接报错——没有内置默认值。namespace 解析顺序是 `--ns`,然后是 shell 或 base `.env` 里的 `WDL_NS`。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。 -目前这些凭证来自 shell、`.env` 文件或命令行标志。后续版本(1.1)会新增 `wdl auth login`,用隐藏输入读取 token 并存入托管配置文件,使其不进入 shell 历史、也不落在项目文件里。 +这些凭证也可以来自托管存储,而不是 shell 或 `.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)。 用 `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` 是否可达。当 control plane 暴露 `/whoami` 时,`doctor` 可以发现 token 是否有效、principal namespace、platform version 和 CLI compatibility;更细的 capability 检查仍需要额外的 control endpoint。 diff --git a/GUIDE.md b/GUIDE.md index a1bc2f4..974c9df 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -112,10 +112,15 @@ 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. -Today these credentials come from your shell, a `.env` file, or flags. A future -release (1.1) will add `wdl auth login`, which reads the token with hidden input -and stores it in a managed config file, so it never lands in shell history or a -project file. +These credentials can also come from a managed store instead of your shell or a +`.env`: `wdl token set --ns --control-url ` reads the token with +hidden input, validates it against `/whoami`, and stores it under the namespace +in `~/.config/wdl/credentials` (so it never lands in shell 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). 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 diff --git a/README-zh.md b/README-zh.md index 7676c41..2db712e 100644 --- a/README-zh.md +++ b/README-zh.md @@ -70,6 +70,7 @@ wdl deploy [--ns ] [--env ] [--verbose] wdl tail [...] [--ns ] [--raw] wdl workers [--ns ] wdl secret (--worker | --scope ns) [KEY] [--json] +wdl token [--ns ] [--control-url ] [--label ] [--default] wdl d1 ... wdl r2 buckets list / wdl r2 objects ... wdl workflows ... diff --git a/README.md b/README.md index dc7679f..2c7b8bd 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ wdl deploy [--ns ] [--env ] [--verbose] wdl tail [...] [--ns ] [--raw] wdl workers [--ns ] wdl secret (--worker | --scope ns) [KEY] [--json] +wdl token [--ns ] [--control-url ] [--label ] [--default] wdl d1 ... wdl r2 buckets list / wdl r2 objects ... wdl workflows ... diff --git a/bin/wdl.js b/bin/wdl.js index 84b783d..b56833b 100755 --- a/bin/wdl.js +++ b/bin/wdl.js @@ -13,14 +13,16 @@ import * as workflowsCmd from "../commands/workflows.js"; import * as configCmd from "../commands/config.js"; import * as doctorCmd from "../commands/doctor.js"; import * as whoamiCmd from "../commands/whoami.js"; +import * as tokenCmd from "../commands/token.js"; import { isHelpAlias } from "../lib/command.js"; import { commonCliOptions, formatHelp, handleCliError, isMain, loadCliControlEnv } from "../lib/common.js"; import { currentCliVersion } from "../lib/package-info.js"; +import { readTokenStore, tokenStorePath } from "../lib/token-store.js"; // Ordered for `wdl help`. Each entry carries its own { name, summary } metadata, // so the dispatch map and the help table below are both derived from it — no // command name or description is maintained twice. -const REGISTRY = [initCmd, deployCmd, secretCmd, workersCmd, deleteCmd, d1Cmd, r2Cmd, tailCmd, workflowsCmd, configCmd, doctorCmd, whoamiCmd]; +const REGISTRY = [initCmd, deployCmd, secretCmd, workersCmd, deleteCmd, d1Cmd, r2Cmd, tailCmd, workflowsCmd, tokenCmd, configCmd, doctorCmd, whoamiCmd]; // Alias -> canonical command name. const ALIASES = { secrets: "secret" }; @@ -66,6 +68,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) { nsFromFlag: scanned.ns, tokenFromFlag: scanned.tokenFromFlag, loadEnv: loadEnvOverride, + readStore: (e) => readTokenStore(tokenStorePath(e)), }); } catch (err) { handleCliError(err); diff --git a/commands/token.js b/commands/token.js new file mode 100644 index 0000000..440e6b7 --- /dev/null +++ b/commands/token.js @@ -0,0 +1,220 @@ +// The `wdl token` command set: store, list, switch the default for, and remove +// tokens in the global credential store (lib/token-store.js). There is no +// "login" — a token is operator-issued; `set` stores it after validating it +// against /whoami (and makes the first one the default namespace), `use` picks +// which stored namespace is the default when --ns is omitted, and `rm` deletes +// the local copy without revoking it. + +import { defineCommand } from "../lib/command.js"; +import { + CliError, + defineCliOption, + escapeTerminalText, + formatHelp, + isMain, + optionHelp, + readTtyLine, + resolveControlUrl, + warnIfInsecureControlUrl, + writeResult, +} from "../lib/common.js"; +import { maskToken } from "../lib/config-state.js"; +import { fetchWhoami, namespaceFromPrincipal } from "../lib/whoami.js"; +import { readTokenStore, tokenStorePath, writeTokenStore } from "../lib/token-store.js"; + +const TOKEN_OPTIONS = [ + defineCliOption("label", { type: "string" }, "--label ", "Human label shown by `wdl token list` (set)."), + defineCliOption("default", { type: "boolean" }, "--default", "Make this the default namespace, used when --ns is omitted (set)."), + "ns", + // `endpoint`, not `control`: the token is read from stdin, never a --token flag. + "endpoint", + // Custom json option: `list --json` prints the local store, not a control + // response, so the preset's description would be wrong here. + defineCliOption("json", { type: "boolean" }, "--json", "Print stored entries as JSON (tokens masked)."), + "help", +]; + +const command = defineCommand({ + name: "token", + summary: "Store, list, switch the default for, and remove control-plane tokens locally.", + options: TOKEN_OPTIONS, + autoloadEnv: false, + usage: usageText, + run: runToken, +}); + +export const main = command.main; +export const runTokenCommand = command.run; +export const meta = command.meta; + +/** @param {{ values: Record, positionals: string[], context: import("../lib/command.js").CommandContext }} arg */ +async function runToken({ values, positionals, context }) { + const [sub, ...rest] = positionals; + // `use` takes the namespace as a positional (`wdl token use acme`); the + // others take none beyond the subcommand. + if (sub !== "use" && rest.length > 0) throw new CliError(usageText()); + switch (sub) { + case "set": + return tokenSet({ values, context }); + case "list": + return tokenList({ values, context }); + case "use": + if (rest.length > 1) throw new CliError(usageText()); + return tokenUse({ context, nsArg: rest[0] }); + case "rm": + return tokenRemove({ context }); + default: + throw new CliError(usageText()); + } +} + +async function tokenSet({ values, context }) { + const ns = context.resolveNamespace(); + if (!ns) throw new CliError("token set requires --ns "); + // The control URL comes from flags/shell only, never the store — we are + // writing the store, so it cannot supply its own endpoint. Give a token-set + // message rather than resolveControlUrl's generic "set it in .env" hint, + // which would be backwards here (the store exists to avoid a .env). + if (!values["control-url"] && !values.admin && !context.env.CONTROL_URL && !context.env.ADMIN_URL) { + throw new CliError( + `token set needs the control URL for ${ns}: pass --control-url (a stored token is scoped to one control plane).` + ); + } + const controlUrl = resolveControlUrl(values, context.env); + // Warn before a plaintext token travels unencrypted, like every other path + // that sends the token. + warnIfInsecureControlUrl(controlUrl, context.warn); + + const token = (await readStdin(context.stdin, { + prompt: `Token for ${ns} @ ${controlUrl} (input hidden): `, + stderr: context.stderr, + })).trim(); + if (!token) throw new CliError("no token provided on stdin"); + + // Validate before storing so a typo'd or revoked token is never persisted, + // and confirm the token actually belongs to this namespace — otherwise it + // would be stored under [ns] while authenticating as someone else. + const whoami = await fetchWhoami({ + controlUrl, + headers: { "x-admin-token": token }, + controlFetch: context.controlFetch, + }); + const principalNs = namespaceFromPrincipal(whoami.principal); + if (principalNs !== ns) { + throw new CliError( + principalNs + ? `token principal is namespace "${principalNs}", not "${ns}" — run with --ns ${principalNs}` + : `this token is not scoped to namespace "${ns}"; wdl token stores tenant tokens under their own namespace` + ); + } + + const storePath = tokenStorePath(context.env); + const store = readTokenStore(storePath); + const previous = store.namespaces[ns] || {}; + store.namespaces[ns] = { + CONTROL_URL: controlUrl, + ADMIN_TOKEN: token, + LABEL: typeof values.label === "string" ? values.label : previous.LABEL, + }; + // The first stored namespace (no default yet), or an explicit --default, + // becomes the default used when --ns/WDL_NS is omitted — the store's analogue + // of a base WDL_NS in a project .env. + const becameDefault = Boolean(values.default) || !store.defaultNs; + if (becameDefault) store.defaultNs = ns; + writeTokenStore(storePath, store); + context.stdout( + `Stored token for ${escapeTerminalText(ns)} @ ${escapeTerminalText(controlUrl)} (${maskToken(token)}).` + ); + if (becameDefault) { + context.stdout(`${escapeTerminalText(ns)} is now the default namespace (used when --ns is omitted).`); + } +} + +function tokenUse({ context, nsArg }) { + const ns = nsArg || context.resolveNamespace(); + if (!ns) throw new CliError("token use requires a namespace: wdl token use "); + const storePath = tokenStorePath(context.env); + const store = readTokenStore(storePath); + if (!store.namespaces[ns]) { + throw new CliError(`no stored token for namespace "${ns}" — run \`wdl token set --ns ${ns}\` first`); + } + store.defaultNs = ns; + writeTokenStore(storePath, store); + context.stdout(`Default namespace set to ${escapeTerminalText(ns)} (used when --ns is omitted).`); +} + +function tokenList({ values, context }) { + const store = readTokenStore(tokenStorePath(context.env)); + const rows = Object.keys(store.namespaces).sort().map((ns) => ({ + default: store.defaultNs === ns, + namespace: ns, + label: store.namespaces[ns].LABEL || "", + controlUrl: store.namespaces[ns].CONTROL_URL || "", + token: maskToken(store.namespaces[ns].ADMIN_TOKEN), + })); + writeResult(values.json, rows, () => formatTokenList(rows), context.stdout); +} + +function tokenRemove({ context }) { + const ns = context.resolveNamespace(); + if (!ns) throw new CliError("token rm requires --ns "); + const storePath = tokenStorePath(context.env); + const store = readTokenStore(storePath); + if (!store.namespaces[ns]) throw new CliError(`no stored token for namespace "${ns}"`); + delete store.namespaces[ns]; + // Preserve the "a lone stored namespace is the default" invariant: if we + // removed the default, promote a sole survivor, else clear it (an ambiguous + // set of remaining namespaces needs an explicit --ns or `wdl token use`). + if (store.defaultNs === ns) { + const remaining = Object.keys(store.namespaces); + store.defaultNs = remaining.length === 1 ? remaining[0] : null; + } + writeTokenStore(storePath, store); + context.stdout( + `Removed the stored token for ${escapeTerminalText(ns)}. This does not revoke it on the control plane.` + ); +} + +// Returns an array of lines; writeResult escapes each one at its choke point. +function formatTokenList(rows) { + if (rows.length === 0) return ["(no stored tokens)"]; + const header = ["", "NAMESPACE", "LABEL", "CONTROL URL", "TOKEN"]; + const cells = [header, ...rows.map((r) => [r.default ? "*" : "", r.namespace, r.label, r.controlUrl, r.token])]; + const widths = header.map((_, col) => Math.max(...cells.map((l) => l[col].length))); + const lines = cells.map((l) => l.map((cell, col) => cell.padEnd(widths[col])).join(" ").trimEnd()); + if (rows.some((r) => r.default)) lines.push("", "* default namespace (used when --ns is omitted)"); + return lines; +} + +// Read a single line: a TTY prompts without echo; a pipe is read to EOF. +/** + * @param {{ isTTY?: boolean, setEncoding: (encoding: string) => void, on: Function, off: Function, pause?: Function }} stdin + * @param {{ prompt?: string, stderr?: (text: string) => void }} [options] + */ +function readStdin(stdin, { prompt, stderr } = {}) { + if (stdin.isTTY) return readTtyLine(stdin, { prompt, stderr }); + return new Promise((resolve, reject) => { + let data = ""; + stdin.setEncoding("utf8"); + stdin.on("data", (chunk) => (data += chunk)); + stdin.on("end", () => resolve(data.replace(/\r?\n$/, ""))); + stdin.on("error", reject); + }); +} + +function usageText() { + return formatHelp({ + usage: [ + "wdl token set --ns [--control-url ] [--label ] [--default]", + "wdl token list [--json]", + "wdl token use ", + "wdl token rm --ns ", + ], + description: "Store, list, and remove tokens in the local credential store (~/.config/wdl/credentials).", + options: optionHelp(TOKEN_OPTIONS), + }); +} + +if (isMain(import.meta.url)) { + await main(); +} diff --git a/docs/README-zh.md b/docs/README-zh.md index 1fb7c7f..b93b94c 100644 --- a/docs/README-zh.md +++ b/docs/README-zh.md @@ -23,6 +23,7 @@ | 静态资源和 `env.ASSETS.url()` | [assets-zh.md](./assets-zh.md) | | WDL 环境覆盖 `[env.]` 规则 | [env-overrides-zh.md](./env-overrides-zh.md) | | Worker / namespace 运行时 secrets | [secrets-zh.md](./secrets-zh.md) | +| 本地存储控制面 token | [token-zh.md](./token-zh.md) | 组合功能时读多个专题。例如:queue 消费后写状态,读 [queues-zh.md](./queues-zh.md) 和 [kv-zh.md](./kv-zh.md);上传文件后记录索引,读 [r2-zh.md](./r2-zh.md) 和 [d1-zh.md](./d1-zh.md);带静态页面的管理工具,读 [assets-zh.md](./assets-zh.md) 和实际绑定对应的专题。 diff --git a/docs/README.md b/docs/README.md index dc3deb7..6ea6471 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,6 +32,7 @@ English set. | Static assets and `env.ASSETS.url()` | [assets.md](./assets.md) | | WDL `[env.]` override rules | [env-overrides.md](./env-overrides.md) | | Worker / namespace runtime secrets | [secrets.md](./secrets.md) | +| Storing control-plane tokens locally | [token.md](./token.md) | Combining features means reading several topics. For example: writing state after consuming a queue, read [queues.md](./queues.md) and [kv.md](./kv.md); diff --git a/docs/token-zh.md b/docs/token-zh.md new file mode 100644 index 0000000..07765cf --- /dev/null +++ b/docs/token-zh.md @@ -0,0 +1,76 @@ +# Tokens —— `wdl token` 参考 + +[English](./token.md) | 中文 + +## 是什么 + +`wdl token` 管理本地凭证存储 `~/.config/wdl/credentials`(`$XDG_CONFIG_HOME/wdl/credentials`,Windows 上为 `%APPDATA%\wdl\credentials`),让命令无需每个 shell 都 export `ADMIN_TOKEN`、也无需在每个项目的 `.env` 里放 token 就能解析出 control URL 和 token。 + +没有"登录"这回事。WDL token 由运维方签发;`wdl token set` 只是把它存起来(存前会调 `/whoami` 校验,并确认其 principal 就是你要存入的那个 namespace),`wdl token rm` 删的是本地副本——**不会吊销** token。 + +存储用的是和项目 `.env` 相同的 `dotenv`/INI 方言,按 namespace 为 key,每条自包含。开头(任何段之前)的一行 `WDL_NS` 指定**默认 namespace**——即不带 `--ns` 时使用的那个,和项目 `.env` 里的 base `WDL_NS` 行为完全一致: + +```ini +WDL_NS="acme" + +[acme] +CONTROL_URL="https://api.example" +ADMIN_TOKEN="" +LABEL="production" +``` + +它由命令独占:`wdl token` 会 canonical 重写整个文件(默认在前,然后排序、加引号的各段),所以项目专属的值请手编项目 `.env`。文件以 `0600` 权限写入。 + +## 命令 + +```bash +# 存 token。token 从 stdin 读(TTY 下隐藏输入)、调 /whoami 校验、确认属于 --ns 后再存。 +# control URL 来自 --control-url 或 CONTROL_URL —— 绝不来自存储本身。 +# 第一个存入的 namespace 自动成为默认;--default 可把任意一次 set 设为默认。 +wdl token set --ns acme --control-url https://api.example +wdl token set --ns acme --control-url https://api.example --label production +wdl token set --ns demo --control-url https://api.example --default +printf '%s' "$TOKEN" | wdl token set --ns acme --control-url https://api.example + +# 列出已存的 namespace 和脱敏 token;默认那个用 * 标记 +# (--json 供脚本用,每行带 "default" 布尔字段,仍脱敏)。 +wdl token list + +# 选择哪个已存 namespace 作为默认(不带 --ns 时使用)。 +wdl token use acme + +# 删除某 namespace 的本地副本(不会在控制面吊销)。 +wdl token rm --ns acme +``` + +## 在解析链中的位置 + +存储是优先级最低的凭证层: + +``` +CLI 标志 > shell/CI env > 项目 ./.env > 全局 token 存储 > 未配置(报错) +``` + +更高层的值总是胜出,存储只填空缺。解析按 namespace 进行:选中某条后,它同时提供 control URL 和 token。当某个值来自存储时,`wdl config explain` 会把来源显示为 `token store [].…`。 + +**选哪个 namespace** 走它自己的链,存储默认在最底层——和项目 `.env` 的 base `WDL_NS` 同形,只低一层: + +``` +--ns > shell/CI WDL_NS > 项目 ./.env 的 WDL_NS > 存储默认(base WDL_NS) +``` + +所以设了存储默认后,`wdl deploy`、`wdl doctor` 等不带 `--ns` 也能跑;要换别的就传 `--ns`(或 `wdl token use `)。当 namespace 来自存储默认时,`wdl config explain` 把来源显示为 `token store default`。 + +存储是**可信**的(它在你的 home 目录、由你经 `wdl token` 写入,token 和端点同源)。项目 `.env` **不可信**:若一个 `.env` 提供了 control 端点却没同时提供 token,该端点仍会被丢弃——这样不可信的项目目录永远无法把你存的 token 重定向到它指定的主机。 + +## 反模式 + +- ❌ 把 `wdl token rm` 当吊销。它只删本地副本;在运维方吊销之前 token 仍然有效。 +- ❌ 手编 `~/.config/wdl/credentials`。下次 `wdl token` 写入时会被 canonical 重写,你的改动(含注释)会丢失。手管的覆盖值请用项目 `.env`。 +- ❌ 把 token 作为命令行参数传。`set` 从 stdin 读,避免进入 shell 历史——在提示符输入或用管道传入。 +- ❌ 指望存储能覆盖 shell 或项目 `.env` 里已设的 token。它是最低层,只填空缺。 + +## 相关 + +- [deploy-zh.md](./deploy-zh.md) —— `ADMIN_TOKEN` / `CONTROL_URL` 的优先级,以及存储所处其下的 `.env` 结构。 +- [secrets-zh.md](./secrets-zh.md) —— `wdl secret`,管理 worker 的运行时密钥(与这里管理的部署 token 是两回事)。 diff --git a/docs/token.md b/docs/token.md new file mode 100644 index 0000000..ee835b4 --- /dev/null +++ b/docs/token.md @@ -0,0 +1,107 @@ +# Tokens — `wdl token` reference + +English | [中文](./token-zh.md) + +## What it is + +`wdl token` manages a local credential store at `~/.config/wdl/credentials` +(`$XDG_CONFIG_HOME/wdl/credentials`, or `%APPDATA%\wdl\credentials` on Windows) +so commands resolve a control URL and token without a per-shell `ADMIN_TOKEN` +export or a token in every project's `.env`. + +There is no "login". A WDL token is issued by your operator; `wdl token set` +just stores it (after checking it against `/whoami` and confirming its principal +is the namespace you are storing it under), and `wdl token rm` deletes the local +copy — it does not revoke the token. + +The store is the same `dotenv`/INI dialect a project `.env` uses, keyed by +namespace, with each entry self-contained. A base `WDL_NS` line (before any +section) names the default namespace — the one used when you do not pass `--ns` +— exactly as a base `WDL_NS` works in a project `.env`: + +```ini +WDL_NS="acme" + +[acme] +CONTROL_URL="https://api.example" +ADMIN_TOKEN="" +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. + +## Commands + +```bash +# Store a token. The token is read from stdin (hidden on a TTY), validated +# against /whoami, checked to belong to --ns, then stored. The control URL comes +# from --control-url or CONTROL_URL — never from the store itself. The first +# stored namespace becomes the default; --default makes any set the default. +wdl token set --ns acme --control-url https://api.example +wdl token set --ns acme --control-url https://api.example --label production +wdl token set --ns demo --control-url https://api.example --default +printf '%s' "$TOKEN" | wdl token set --ns acme --control-url https://api.example + +# List stored namespaces with masked tokens; the default is marked with * +# (--json for scripting; each row carries a "default" boolean, still masked). +wdl token list + +# Choose which stored namespace is the default (used when --ns is omitted). +wdl token use acme + +# Remove the local copy for a namespace (does not revoke on the control plane). +wdl token rm --ns acme +``` + +## Where it sits in resolution + +The store is the lowest-precedence credential layer: + +``` +CLI flag > shell/CI env > project ./.env > global token store > unset (error) +``` + +A value from a higher layer always wins; the store only fills gaps. Resolution +is per namespace: a namespace selects the entry, which supplies both the control +URL and the token. `wdl config explain` shows `token store [].…` as the +source when a value came from the store. + +Which namespace is selected follows its own chain, with the store's default at +the bottom — the same shape, one layer lower than a project `.env`'s base +`WDL_NS`: + +``` +--ns > shell/CI WDL_NS > project ./.env WDL_NS > store default (base WDL_NS) +``` + +So with a stored default you can run `wdl deploy`, `wdl doctor`, etc. without +`--ns`; pass `--ns` (or `wdl token use `) to pick a different one. When the +namespace comes from the store default, `wdl config explain` shows the source as +`token store default`. + +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. + +## Anti-patterns + +- ❌ Treating `wdl token rm` as revocation. It deletes the local copy only; the + token still works until your operator revokes it. +- ❌ Hand-editing `~/.config/wdl/credentials`. It is rewritten on the next + `wdl token` write and your edits (including comments) are lost. Use a project + `.env` for hand-managed overrides. +- ❌ Passing the token as a command-line argument. `set` reads it from stdin so + it stays out of shell history; type it at the prompt or pipe it in. +- ❌ Expecting the store to override a token already set in your shell or a + project `.env`. It is the lowest layer and only fills gaps. + +## Related + +- [deploy.md](./deploy.md) — `ADMIN_TOKEN` / `CONTROL_URL` precedence and the + `.env` layout the store sits beneath. +- [secrets.md](./secrets.md) — `wdl secret`, for a worker's runtime secrets (a + different thing from the deploy token managed here). diff --git a/lib/common.js b/lib/common.js index ddecee4..202ca31 100644 --- a/lib/common.js +++ b/lib/common.js @@ -89,6 +89,9 @@ const CLI_OPTION_PRESETS = { ns: [OPTION_DEFS.ns], env: [OPTION_DEFS.env], control: [OPTION_DEFS.controlUrl, OPTION_DEFS.admin, OPTION_DEFS.token], + // Control-plane endpoint flags without --token, for commands that read the + // token elsewhere (e.g. `wdl token set` reads it from stdin). + endpoint: [OPTION_DEFS.controlUrl, OPTION_DEFS.admin], json: [OPTION_DEFS.json], yes: [OPTION_DEFS.yes], help: [OPTION_DEFS.help], @@ -350,9 +353,10 @@ export function loadCliDotEnv( * tokenFromFlag?: boolean, * protectedKeys?: Set, * loadEnv?: typeof loadCliDotEnv, + * readStore?: (env: NodeJS.ProcessEnv) => { defaultNs?: string | null, namespaces?: Record> }, * warn?: (message: string) => void, * onCrossOrigin?: (line: string) => void, - * onLoad?: (entry: { key: string, value: string, section: string | null, line: number }) => void, + * onLoad?: (entry: { key: string, value: string, section: string | null, line: number, origin?: "store" | "store-default" }) => void, * }} [options] */ export function loadCliControlEnv(env, { @@ -361,6 +365,7 @@ export function loadCliControlEnv(env, { tokenFromFlag = false, protectedKeys = new Set(Object.keys(env)), loadEnv = loadCliDotEnv, + readStore = () => ({}), warn, onCrossOrigin = (line) => console.error(line), onLoad, @@ -372,11 +377,56 @@ export function loadCliControlEnv(env, { if (Array.isArray(result)) for (const key of result) loaded.add(key); }; record(loadEnv(env, dotenvPath, { protectedKeys, onLoad, warn })); - const ns = firstNonEmptyString(nsFromFlag, env.WDL_NS); + + // Read the store once: it supplies both the lowest-precedence default + // namespace and the per-namespace control URL + token gap-fills below. + const store = readStore(env) || {}; + const storeNamespaces = store.namespaces || {}; + const storeDefaultNs = typeof store.defaultNs === "string" ? store.defaultNs : null; + + let ns = firstNonEmptyString(nsFromFlag, env.WDL_NS); + // The store's base WDL_NS names a default namespace — the lowest-precedence + // source for *which* namespace to use, below --ns and shell/.env WDL_NS, and + // only when that default actually has a stored entry. Materialize it into env + // so the rest of the pipeline (control-URL resolution, the [ns] overlay, + // resolveNamespace in callers) sees the same namespace an explicit one would. + if (!ns && storeDefaultNs && storeNamespaces[storeDefaultNs]) { + ns = storeDefaultNs; + if (env.WDL_NS == null || env.WDL_NS === "") { + env.WDL_NS = ns; + if (onLoad) onLoad({ key: "WDL_NS", value: ns, section: ns, line: 0, origin: "store-default" }); + } + } + if (ns) { record(loadEnv(env, dotenvPath, { resolvedNs: ns, loadBase: false, protectedKeys, onLoad, warn })); } + // Drop untrusted project-.env endpoints BEFORE filling from the global store, + // so a dropped endpoint's slot is filled by the trusted store rather than + // staying shadowed by what the guard just removed. guardCrossOriginControlEnv(env, loaded, tokenFromFlag, onCrossOrigin); + // The global token store (~/.config/wdl) is the lowest-precedence layer and + // is trusted (you wrote it via `wdl token`, token + endpoint same-source), so + // it fills only the gaps left by flags / shell / project .env / the guard and + // is not itself subject to the cross-origin guard. readStore defaults to no + // store; the bin dispatcher and config-state wire the real reader. + if (ns) fillFromTokenStore(env, ns, storeNamespaces, onLoad); +} + +// Only the control-plane endpoint and token are materialized into env from a +// store section; LABEL is store-only metadata for `wdl token list`. +const STORE_ENV_KEYS = ["CONTROL_URL", "ADMIN_TOKEN"]; + +function fillFromTokenStore(env, ns, namespaces, onLoad) { + const entry = namespaces[ns]; + if (!entry) return; + for (const key of STORE_ENV_KEYS) { + const value = entry[key]; + if (value == null || value === "") continue; + if (env[key] != null && env[key] !== "") continue; // gap-fill only + env[key] = value; + if (onLoad) onLoad({ key, value, section: ns, line: 0, origin: "store" }); + } } // A control endpoint from a cwd .env is only trusted when the EFFECTIVE token @@ -401,7 +451,7 @@ function guardCrossOriginControlEnv(env, loadedFromDotenv, tokenFromFlag, onCros } } -function parseDotEnvSection(line, lineNumber) { +export function parseDotEnvSection(line, lineNumber) { if (!line.startsWith("[")) return null; const match = line.match(/^\[([^\]]*)\]\s*(?:#.*)?$/); if (!match) { @@ -475,7 +525,7 @@ export function readTtyLine(stdin, { prompt, stderr } = {}) { }); } -function parseDotEnvValue(value) { +export function parseDotEnvValue(value) { if (!value) return ""; const quote = value[0]; if (quote === "\"" || quote === "'") { diff --git a/lib/config-state.js b/lib/config-state.js index 7f7614f..acef6b5 100644 --- a/lib/config-state.js +++ b/lib/config-state.js @@ -1,5 +1,6 @@ import path from "node:path"; import { CliError, loadCliControlEnv, resolveControlUrl, resolveNamespace } from "./common.js"; +import { readTokenStore, tokenStorePath } from "./token-store.js"; /** * @param {{ @@ -17,10 +18,15 @@ export function resolveCliConfigState({ values = {}, env = process.env, cwd = pr for (const key of Object.keys(env)) sources.set(key, `${key} env`); const resolvedDotenvPath = path.resolve(cwd, dotenvPath); - const recordDotenvLoad = ({ key, section }) => { - const label = section === null - ? `.env ${key}` - : `.env [${section}].${key}`; + /** @param {{ key: string, section: string | null, origin?: "store" | "store-default" }} entry */ + const recordDotenvLoad = ({ key, section, origin }) => { + const label = origin === "store-default" + ? "token store default" + : origin === "store" + ? `token store [${section}].${key}` + : section === null + ? `.env ${key}` + : `.env [${section}].${key}`; sources.set(key, label); }; @@ -33,6 +39,7 @@ export function resolveCliConfigState({ values = {}, env = process.env, cwd = pr nsFromFlag: values.ns, tokenFromFlag: typeof values.token === "string" && values.token.length > 0, protectedKeys, + readStore: (e) => readTokenStore(tokenStorePath(e)), onLoad: recordDotenvLoad, warn: () => {}, onCrossOrigin: warn, diff --git a/lib/token-store.js b/lib/token-store.js new file mode 100644 index 0000000..c6265f6 --- /dev/null +++ b/lib/token-store.js @@ -0,0 +1,129 @@ +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { CliError, parseDotEnvSection, parseDotEnvValue } from "./common.js"; + +// The global credential store is the lowest-precedence layer of the same +// credential model as a project `.env`: a `dotenv`/INI-subset file in the +// user's config dir, command-managed by `wdl token`. Each `[namespace]` +// section is self-contained (its own control URL + token) because different +// namespaces can live on different control planes. `LABEL` is an optional +// human note shown by `wdl token list`. +// +// A base `WDL_NS` line (before any section) names the default namespace, used +// when no --ns/WDL_NS selects one — the store's analogue of a base WDL_NS in a +// project `.env`. The parsed store is `{ defaultNs, namespaces }`. +const STORE_KEYS = ["CONTROL_URL", "ADMIN_TOKEN", "LABEL"]; + +// Resolve the per-user config directory: %APPDATA%\wdl on Windows, else +// $XDG_CONFIG_HOME/wdl or ~/.config/wdl. `homedir` is injectable for tests. +export function tokenStoreDir(env = process.env, homedir = os.homedir) { + if (process.platform === "win32" && env.APPDATA) { + return path.join(env.APPDATA, "wdl"); + } + const base = + typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.length > 0 + ? env.XDG_CONFIG_HOME + : path.join(homedir(), ".config"); + return path.join(base, "wdl"); +} + +export function tokenStorePath(env = process.env, homedir = os.homedir) { + return path.join(tokenStoreDir(env, homedir), "credentials"); +} + +// Parse the store into { defaultNs, namespaces: { ns: { CONTROL_URL, +// ADMIN_TOKEN, LABEL } } } using the same section/value dialect primitives as +// project `.env`, so the two formats never diverge. A missing file is an empty +// store ({ defaultNs: null, namespaces: {} }). +/** @returns {{ defaultNs: string | null, namespaces: Record> }} */ +export function readTokenStore(storePath) { + let text; + try { + text = readFileSync(storePath, "utf8"); + } catch (err) { + if (err && err.code === "ENOENT") return { defaultNs: null, namespaces: {} }; + throw err; + } + + /** @type {Record>} */ + const namespaces = {}; + /** @type {string | null} */ + let defaultNs = null; + let section = null; + for (const [idx, rawLine] of text.replace(/^\uFEFF/, "").split(/\r?\n/).entries()) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + + const nextSection = parseDotEnvSection(line, idx + 1); + if (nextSection !== null) { + section = nextSection; + namespaces[section] ??= {}; + continue; + } + const eq = line.indexOf("="); + if (eq <= 0) { + throw new CliError(`Invalid credentials line ${idx + 1}: expected KEY=value`); + } + const key = line.slice(0, eq).trim(); + const value = parseDotEnvValue(line.slice(eq + 1).trim()); + if (section === null) { + // The only key allowed before any [namespace] is the default-namespace + // pointer, the store's analogue of a base WDL_NS in a project `.env`. + if (key === "WDL_NS") { + defaultNs = value; + continue; + } + throw new CliError(`Invalid credentials line ${idx + 1}: key outside a [namespace] section`); + } + if (!STORE_KEYS.includes(key)) continue; + namespaces[section][key] = value; + } + return { defaultNs, namespaces }; +} + +// Serialize the store back to the same dialect — every value double-quoted and +// escaped so it round-trips through parseDotEnvValue — and write it with 0600 +// perms (0700 dir). The file is command-owned, so it is rewritten canonically +// (default first, then sorted sections, fixed key order); user comments are not +// preserved (edit a project `.env` for hand-managed notes). +/** @param {{ defaultNs?: string | null, namespaces?: Record> }} store */ +export function writeTokenStore(storePath, store) { + const namespaces = store.namespaces || {}; + const lines = [ + "# Managed by `wdl token`. Do not hand-edit — use a project .env for overrides.", + "", + ]; + // Write the default-namespace pointer only when it has a stored entry, so the + // file never carries a dangling default. Mirrors a base WDL_NS in a `.env`. + if (store.defaultNs && namespaces[store.defaultNs]) { + lines.push(`WDL_NS=${quoteValue(store.defaultNs)}`, ""); + } + for (const ns of Object.keys(namespaces).sort()) { + lines.push(`[${ns}]`); + for (const key of STORE_KEYS) { + const value = namespaces[ns][key]; + if (value == null || value === "") continue; + lines.push(`${key}=${quoteValue(value)}`); + } + lines.push(""); + } + mkdirSync(path.dirname(storePath), { recursive: true, mode: 0o700 }); + writeFileSync(storePath, lines.join("\n"), { mode: 0o600 }); + // writeFileSync's mode only applies on create; force perms on an existing file. + chmodSync(storePath, 0o600); +} + +// Escape for the double-quoted dialect: backslash first, then the rest, so a +// value with quotes/newlines/tabs survives a read→write→read round trip. +function quoteValue(value) { + const escaped = String(value) + .replaceAll("\\", "\\\\") + .replaceAll('"', '\\"') + .replaceAll("\n", "\\n") + .replaceAll("\r", "\\r") + .replaceAll("\t", "\\t"); + return `"${escaped}"`; +} + +export const __test__ = { quoteValue, STORE_KEYS }; diff --git a/templates/AGENTS.md b/templates/AGENTS.md index 5f5ed94..086ba69 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -31,6 +31,7 @@ package. | Scheduled / cron jobs | `cron-triggers.md` | | WDL environment override rules (preview / production) | `env-overrides.md` | | Runtime secrets | `secrets.md` | +| Storing control-plane tokens locally | `token.md` | | Deploy / dry-run / list and delete workers | `deploy.md` | Open the relevant doc before editing `wrangler.jsonc` / `wrangler.toml` or diff --git a/tests/unit/cli-token-store.test.js b/tests/unit/cli-token-store.test.js new file mode 100644 index 0000000..2c0ee00 --- /dev/null +++ b/tests/unit/cli-token-store.test.js @@ -0,0 +1,153 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + readTokenStore, + tokenStoreDir, + tokenStorePath, + writeTokenStore, + __test__, +} from "../../lib/token-store.js"; + +function withTempHome(fn) { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-token-store-")); + try { + return fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +test("tokenStoreDir honors XDG_CONFIG_HOME, then falls back to ~/.config", () => { + assert.equal( + tokenStoreDir({ XDG_CONFIG_HOME: "/x/cfg" }, () => "/home/u"), + path.join("/x/cfg", "wdl") + ); + assert.equal( + tokenStoreDir({}, () => "/home/u"), + path.join("/home/u", ".config", "wdl") + ); + assert.equal( + tokenStorePath({ XDG_CONFIG_HOME: "/x/cfg" }, () => "/home/u"), + path.join("/x/cfg", "wdl", "credentials") + ); +}); + +test("readTokenStore returns an empty store when the file is absent", () => { + withTempHome((dir) => { + assert.deepEqual(readTokenStore(path.join(dir, "credentials")), { defaultNs: null, namespaces: {} }); + }); +}); + +test("writeTokenStore then readTokenStore round-trips namespaces and fields", () => { + withTempHome((dir) => { + const p = path.join(dir, "wdl", "credentials"); + const store = { + defaultNs: null, + namespaces: { + acme: { CONTROL_URL: "https://api.example", ADMIN_TOKEN: "tok-acme", LABEL: "production" }, + "acme-staging": { CONTROL_URL: "https://api.example", ADMIN_TOKEN: "tok-stg" }, + }, + }; + writeTokenStore(p, store); + assert.deepEqual(readTokenStore(p), store); + }); +}); + +test("writeTokenStore then readTokenStore round-trips the default namespace", () => { + withTempHome((dir) => { + const p = path.join(dir, "credentials"); + const store = { + defaultNs: "acme", + namespaces: { + acme: { ADMIN_TOKEN: "tok-acme" }, + demo: { ADMIN_TOKEN: "tok-demo" }, + }, + }; + writeTokenStore(p, store); + assert.match(readFileSync(p, "utf8"), /^WDL_NS="acme"$/m); + assert.deepEqual(readTokenStore(p), store); + }); +}); + +test("writeTokenStore drops a default that has no stored entry", () => { + withTempHome((dir) => { + const p = path.join(dir, "credentials"); + writeTokenStore(p, { defaultNs: "ghost", namespaces: { acme: { ADMIN_TOKEN: "t" } } }); + assert.doesNotMatch(readFileSync(p, "utf8"), /WDL_NS=/); + assert.equal(readTokenStore(p).defaultNs, null); + }); +}); + +test("writeTokenStore quotes and escapes so odd token characters round-trip", () => { + withTempHome((dir) => { + const p = path.join(dir, "credentials"); + const store = { + defaultNs: null, + namespaces: { + acme: { ADMIN_TOKEN: 'tok with "quotes" and =sign #hash', LABEL: "multi\nline note" }, + }, + }; + writeTokenStore(p, store); + assert.deepEqual(readTokenStore(p), store); + }); +}); + +test("writeTokenStore writes canonical sorted output with a managed-by header", () => { + withTempHome((dir) => { + const p = path.join(dir, "credentials"); + writeTokenStore(p, { + namespaces: { + zeta: { ADMIN_TOKEN: "z" }, + alpha: { ADMIN_TOKEN: "a", LABEL: "first" }, + }, + }); + const text = readFileSync(p, "utf8"); + assert.match(text, /^# Managed by `wdl token`/); + assert.ok(text.indexOf("[alpha]") < text.indexOf("[zeta]"), "sections sorted"); + assert.match(text, /\[alpha\]\nADMIN_TOKEN="a"\nLABEL="first"/); + }); +}); + +test("writeTokenStore sets 0600 file permissions", () => { + if (process.platform === "win32") return; + withTempHome((dir) => { + const p = path.join(dir, "credentials"); + writeTokenStore(p, { namespaces: { acme: { ADMIN_TOKEN: "t" } } }); + assert.equal(statSync(p).mode & 0o777, 0o600); + // Re-write over an existing file keeps 0600. + writeTokenStore(p, { namespaces: { acme: { ADMIN_TOKEN: "t2" } } }); + assert.equal(statSync(p).mode & 0o777, 0o600); + }); +}); + +test("readTokenStore rejects a key outside any section", () => { + withTempHome((dir) => { + const p = path.join(dir, "credentials"); + writeFileSync(p, "ADMIN_TOKEN=loose\n"); + assert.throws(() => readTokenStore(p), /outside a \[namespace\] section/); + }); +}); + +test("readTokenStore reads a base WDL_NS as the default namespace", () => { + withTempHome((dir) => { + const p = path.join(dir, "credentials"); + writeFileSync(p, 'WDL_NS="acme"\n[acme]\nADMIN_TOKEN="t"\n'); + assert.deepEqual(readTokenStore(p), { defaultNs: "acme", namespaces: { acme: { ADMIN_TOKEN: "t" } } }); + }); +}); + +test("readTokenStore ignores unknown keys and comments", () => { + withTempHome((dir) => { + const p = path.join(dir, "credentials"); + writeFileSync(p, "# note\n[acme]\nADMIN_TOKEN=\"t\"\nUNKNOWN=x\n"); + assert.deepEqual(readTokenStore(p), { defaultNs: null, namespaces: { acme: { ADMIN_TOKEN: "t" } } }); + }); +}); + +test("quoteValue escapes backslash before other sequences", () => { + assert.equal(__test__.quoteValue("a\\b"), '"a\\\\b"'); + assert.equal(__test__.quoteValue('q"q'), '"q\\"q"'); +}); diff --git a/tests/unit/cli-token.test.js b/tests/unit/cli-token.test.js new file mode 100644 index 0000000..91d8d86 --- /dev/null +++ b/tests/unit/cli-token.test.js @@ -0,0 +1,377 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { runTokenCommand } from "../../commands/token.js"; +import { loadCliControlEnv } from "../../lib/common.js"; +import { readTokenStore, tokenStorePath, writeTokenStore } from "../../lib/token-store.js"; +import { response } from "./helpers.js"; + +async function withTempXdg(fn) { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-token-cmd-")); + try { + return await fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +function stdinFrom(value) { + const stdin = Object.assign(new EventEmitter(), { setEncoding() {} }); + queueMicrotask(() => { + stdin.emit("data", value); + stdin.emit("end"); + }); + return stdin; +} + +/** @param {string} xdg @param {{ stdin?: any, controlFetch?: Function }} [opts] */ +function deps(xdg, { stdin, controlFetch } = {}) { + const lines = []; + const warnings = []; + const calls = []; + return { + lines, + warnings, + calls, + deps: { + env: { XDG_CONFIG_HOME: xdg }, + stdout: (line) => lines.push(line), + stderr: () => {}, + warn: (line) => warnings.push(line), + stdin, + controlFetch: controlFetch || (async (url, init = {}) => { + calls.push({ url, init }); + return response({ ok: true, principal: { kind: "ns", ns: "acme" } }); + }), + }, + }; +} + +// --- wdl token set --- + +test("token set reads stdin, validates via /whoami, and stores the credential", async () => { + await withTempXdg(async (xdg) => { + const { lines, calls, deps: d } = deps(xdg, { stdin: stdinFrom("tok-secret-1234\n") }); + await runTokenCommand(["set", "--ns", "acme", "--control-url", "https://api.example"], d); + + assert.match(calls[0].url, /https:\/\/api\.example\/whoami$/); + assert.equal(calls[0].init.headers["x-admin-token"], "tok-secret-1234"); + + const store = readTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg })); + assert.deepEqual(store.namespaces.acme, { CONTROL_URL: "https://api.example", ADMIN_TOKEN: "tok-secret-1234" }); + assert.equal(store.defaultNs, "acme", "the first stored namespace becomes the default"); + assert.match(lines.join("\n"), /Stored token for acme @ https:\/\/api\.example \(\*\*\*\*1234\)/); + assert.match(lines.join("\n"), /acme is now the default namespace/); + }); +}); + +test("token set makes only the first namespace the default; later sets do not steal it", async () => { + await withTempXdg(async (xdg) => { + await runTokenCommand( + ["set", "--ns", "acme", "--control-url", "https://api.example"], + deps(xdg, { stdin: stdinFrom("tok-1\n") }).deps + ); + const second = deps(xdg, { + stdin: stdinFrom("tok-2\n"), + controlFetch: async () => response({ ok: true, principal: { kind: "ns", ns: "demo" } }), + }); + await runTokenCommand(["set", "--ns", "demo", "--control-url", "https://api.example"], second.deps); + + assert.equal(readTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg })).defaultNs, "acme"); + assert.doesNotMatch(second.lines.join("\n"), /default namespace/, "a non-first set is silent about the default"); + }); +}); + +test("token set --default makes an existing namespace the default", async () => { + await withTempXdg(async (xdg) => { + await runTokenCommand( + ["set", "--ns", "acme", "--control-url", "https://api.example"], + deps(xdg, { stdin: stdinFrom("tok-1\n") }).deps + ); + await runTokenCommand( + ["set", "--ns", "demo", "--control-url", "https://api.example", "--default"], + deps(xdg, { + stdin: stdinFrom("tok-2\n"), + controlFetch: async () => response({ ok: true, principal: { kind: "ns", ns: "demo" } }), + }).deps + ); + assert.equal(readTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg })).defaultNs, "demo"); + }); +}); + +test("token set stores and preserves a --label", async () => { + await withTempXdg(async (xdg) => { + await runTokenCommand( + ["set", "--ns", "acme", "--control-url", "https://api.example", "--label", "production"], + deps(xdg, { stdin: stdinFrom("tok-1\n") }).deps + ); + let store = readTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg })); + assert.equal(store.namespaces.acme.LABEL, "production"); + + // Re-set without --label keeps the existing label. + await runTokenCommand( + ["set", "--ns", "acme", "--control-url", "https://api.example"], + deps(xdg, { stdin: stdinFrom("tok-2\n") }).deps + ); + store = readTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg })); + assert.equal(store.namespaces.acme.LABEL, "production"); + assert.equal(store.namespaces.acme.ADMIN_TOKEN, "tok-2"); + }); +}); + +test("token set does not store a token that fails /whoami", async () => { + await withTempXdg(async (xdg) => { + const controlFetch = async () => response({ error: "unauthorized" }, 401); + await assert.rejects( + () => runTokenCommand( + ["set", "--ns", "acme", "--control-url", "https://api.example"], + deps(xdg, { stdin: stdinFrom("bad-token\n"), controlFetch }).deps + ), + /whoami/ + ); + assert.deepEqual(readTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg })), { defaultNs: null, namespaces: {} }); + }); +}); + +test("token set requires --ns and a control URL", async () => { + await withTempXdg(async (xdg) => { + await assert.rejects( + () => runTokenCommand(["set", "--control-url", "https://api.example"], deps(xdg, { stdin: stdinFrom("t\n") }).deps), + /requires --ns/ + ); + await assert.rejects( + () => runTokenCommand(["set", "--ns", "acme"], deps(xdg, { stdin: stdinFrom("t\n") }).deps), + /needs the control URL/ + ); + }); +}); + +test("token set rejects a token whose principal namespace differs from --ns", async () => { + await withTempXdg(async (xdg) => { + const controlFetch = async () => response({ ok: true, principal: { kind: "ns", ns: "other" } }); + await assert.rejects( + () => runTokenCommand( + ["set", "--ns", "acme", "--control-url", "https://api.example"], + deps(xdg, { stdin: stdinFrom("tok\n"), controlFetch }).deps + ), + /namespace "other", not "acme"/ + ); + assert.deepEqual(readTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg })), { defaultNs: null, namespaces: {} }); + }); +}); + +test("token set rejects a token that is not scoped to a namespace", async () => { + await withTempXdg(async (xdg) => { + const controlFetch = async () => response({ ok: true, principal: { kind: "operator" } }); + await assert.rejects( + () => runTokenCommand( + ["set", "--ns", "acme", "--control-url", "https://api.example"], + deps(xdg, { stdin: stdinFrom("tok\n"), controlFetch }).deps + ), + /not scoped to namespace "acme"/ + ); + assert.deepEqual(readTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg })), { defaultNs: null, namespaces: {} }); + }); +}); + +test("token set warns before sending the token to a plain-http non-local host", async () => { + await withTempXdg(async (xdg) => { + const { warnings, deps: d } = deps(xdg, { stdin: stdinFrom("tok\n") }); + await runTokenCommand(["set", "--ns", "acme", "--control-url", "http://example.com"], d); + assert.match(warnings.join("\n"), /plain http on a non-local host/); + }); +}); + +test("token does not accept a --token flag (the token comes from stdin)", async () => { + await withTempXdg(async (xdg) => { + await assert.rejects( + () => runTokenCommand( + ["set", "--ns", "acme", "--control-url", "https://api.example", "--token", "x"], + deps(xdg, { stdin: stdinFrom("tok\n") }).deps + ), + /Unknown option|--token/ + ); + }); +}); + +// --- wdl token list / rm --- + +test("token list formats stored namespaces with masked tokens and marks the default", async () => { + await withTempXdg(async (xdg) => { + writeTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg }), { + defaultNs: "acme", + namespaces: { + acme: { CONTROL_URL: "https://api.example", ADMIN_TOKEN: "tok-abcd1234", LABEL: "production" }, + demo: { CONTROL_URL: "https://api.example", ADMIN_TOKEN: "tok-zzzz9999" }, + }, + }); + const { lines, deps: d } = deps(xdg); + await runTokenCommand(["list"], d); + const out = lines.join("\n"); + assert.match(out, /NAMESPACE\s+LABEL\s+CONTROL URL\s+TOKEN/); + assert.match(out, /\*\s+acme\s+production\s+https:\/\/api\.example\s+\*\*\*\*1234/); + assert.match(out, /default namespace \(used when --ns is omitted\)/); + assert.doesNotMatch(out, /tok-abcd1234/, "raw token must not be printed"); + }); +}); + +test("token use switches the default namespace and rejects an unstored one", async () => { + await withTempXdg(async (xdg) => { + const p = tokenStorePath({ XDG_CONFIG_HOME: xdg }); + writeTokenStore(p, { + defaultNs: "acme", + namespaces: { acme: { ADMIN_TOKEN: "a" }, demo: { ADMIN_TOKEN: "d" } }, + }); + const { lines, deps: d } = deps(xdg); + await runTokenCommand(["use", "demo"], d); + assert.equal(readTokenStore(p).defaultNs, "demo"); + assert.match(lines.join("\n"), /Default namespace set to demo/); + + await assert.rejects( + () => runTokenCommand(["use", "ghost"], deps(xdg).deps), + /no stored token for namespace "ghost"/ + ); + }); +}); + +test("token list prints a placeholder when empty", async () => { + await withTempXdg(async (xdg) => { + const { lines, deps: d } = deps(xdg); + await runTokenCommand(["list"], d); + assert.deepEqual(lines, ["(no stored tokens)"]); + }); +}); + +test("token rm removes a stored namespace and errors when absent", async () => { + await withTempXdg(async (xdg) => { + const p = tokenStorePath({ XDG_CONFIG_HOME: xdg }); + writeTokenStore(p, { namespaces: { acme: { ADMIN_TOKEN: "t" }, demo: { ADMIN_TOKEN: "d" } } }); + + const { lines, deps: d } = deps(xdg); + await runTokenCommand(["rm", "--ns", "acme"], d); + assert.deepEqual(Object.keys(readTokenStore(p).namespaces), ["demo"]); + assert.match(lines.join("\n"), /does not revoke it on the control plane/); + + await assert.rejects( + () => runTokenCommand(["rm", "--ns", "acme"], deps(xdg).deps), + /no stored token for namespace "acme"/ + ); + }); +}); + +test("token rm of the default promotes a sole survivor, clears it when ambiguous", async () => { + await withTempXdg(async (xdg) => { + const p = tokenStorePath({ XDG_CONFIG_HOME: xdg }); + writeTokenStore(p, { + defaultNs: "acme", + namespaces: { acme: { ADMIN_TOKEN: "a" }, demo: { ADMIN_TOKEN: "d" }, prod: { ADMIN_TOKEN: "p" } }, + }); + + // Three stored, default removed → two remain → ambiguous → default cleared. + await runTokenCommand(["rm", "--ns", "acme"], deps(xdg).deps); + assert.equal(readTokenStore(p).defaultNs, null); + + // Re-point the default, then remove down to one → the survivor is promoted. + await runTokenCommand(["use", "demo"], deps(xdg).deps); + await runTokenCommand(["rm", "--ns", "demo"], deps(xdg).deps); + assert.equal(readTokenStore(p).defaultNs, "prod"); + }); +}); + +test("token rejects unknown subcommands", async () => { + await withTempXdg(async (xdg) => { + await assert.rejects(() => runTokenCommand(["frobnicate"], deps(xdg).deps), /Usage:/); + }); +}); + +// --- resolution integration (the global store as the lowest-precedence layer) --- + +test("loadCliControlEnv fills control URL and token from the store as a gap-filler", () => { + const env = { WDL_NS: "acme" }; + loadCliControlEnv(env, { + nsFromFlag: "acme", + loadEnv: () => [], + readStore: () => ({ namespaces: { acme: { CONTROL_URL: "https://store.example", ADMIN_TOKEN: "store-tok" } } }), + }); + assert.equal(env.CONTROL_URL, "https://store.example"); + assert.equal(env.ADMIN_TOKEN, "store-tok"); +}); + +test("loadCliControlEnv selects the store's default namespace when nothing else does", () => { + const env = /** @type {NodeJS.ProcessEnv} */ ({}); + loadCliControlEnv(env, { + loadEnv: () => [], + readStore: () => ({ + defaultNs: "acme", + namespaces: { acme: { CONTROL_URL: "https://store.example", ADMIN_TOKEN: "store-tok" } }, + }), + }); + assert.equal(env.WDL_NS, "acme", "the default namespace is materialized for downstream resolution"); + assert.equal(env.CONTROL_URL, "https://store.example"); + assert.equal(env.ADMIN_TOKEN, "store-tok"); +}); + +test("loadCliControlEnv lets an explicit namespace override the store default", () => { + const env = { WDL_NS: "demo" }; + loadCliControlEnv(env, { + loadEnv: () => [], + readStore: () => ({ + defaultNs: "acme", + namespaces: { + acme: { CONTROL_URL: "https://acme.example", ADMIN_TOKEN: "acme-tok" }, + demo: { CONTROL_URL: "https://demo.example", ADMIN_TOKEN: "demo-tok" }, + }, + }), + }); + assert.equal(env.WDL_NS, "demo", "shell WDL_NS wins over the store default"); + assert.equal(env.CONTROL_URL, "https://demo.example"); + assert.equal(env.ADMIN_TOKEN, "demo-tok"); +}); + +test("loadCliControlEnv ignores a store default with no stored entry", () => { + const env = /** @type {NodeJS.ProcessEnv} */ ({}); + loadCliControlEnv(env, { + loadEnv: () => [], + readStore: () => ({ defaultNs: "ghost", namespaces: { acme: { ADMIN_TOKEN: "a" } } }), + }); + assert.equal(env.WDL_NS, undefined, "a dangling default does not select a namespace"); + assert.equal(env.ADMIN_TOKEN, undefined); +}); + +test("loadCliControlEnv lets shell env win over the store (gap-fill only)", () => { + const env = { WDL_NS: "acme", ADMIN_TOKEN: "shell-tok" }; + loadCliControlEnv(env, { + nsFromFlag: "acme", + protectedKeys: new Set(["ADMIN_TOKEN"]), + loadEnv: () => [], + readStore: () => ({ namespaces: { acme: { CONTROL_URL: "https://store.example", ADMIN_TOKEN: "store-tok" } } }), + }); + assert.equal(env.ADMIN_TOKEN, "shell-tok", "shell token is not overwritten"); + assert.equal(env.CONTROL_URL, "https://store.example", "the empty control URL slot is filled"); +}); + +test("a project .env endpoint is still dropped when the token comes from the store", () => { + // Malicious cwd .env supplies an endpoint but no token; the store supplies the + // token (and a trusted endpoint). The guard must drop the project endpoint + // before the store fills it, so the store token is never sent to the .env host. + const env = /** @type {NodeJS.ProcessEnv} */ ({}); + const warnings = []; + loadCliControlEnv(env, { + nsFromFlag: "acme", + loadEnv: (e, _path, opts) => { + if (!opts.resolvedNs) { + e.CONTROL_URL = "https://evil.example"; + return ["CONTROL_URL"]; + } + return []; + }, + readStore: () => ({ namespaces: { acme: { CONTROL_URL: "https://good.example", ADMIN_TOKEN: "store-tok" } } }), + onCrossOrigin: (line) => warnings.push(line), + }); + assert.equal(env.CONTROL_URL, "https://good.example", "evil endpoint dropped, store endpoint used"); + assert.equal(env.ADMIN_TOKEN, "store-tok"); + assert.equal(warnings.length, 1); +}); From ac69f99a50b85e42c92a1ab1e6c69094cd358c6b Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Mon, 15 Jun 2026 11:59:30 +0800 Subject: [PATCH 02/13] Harden wdl token: hidden input, prototype-safe store, lazy reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Codex review on #1: - Hide the token on a TTY. readTtyLine now switches to raw mode (echo off) for hidden input; `set` previously echoed the typed token into the terminal and scrollback despite its "(input hidden)" prompt. Hidden input fails closed — if raw mode cannot be enabled it errors rather than silently echo a secret. `wdl secret put` reads its value through the same hidden path (identical issue). - Preserve namespaces named like Object.prototype keys (constructor, toString, __proto__). readTokenStore built sections with `??=`/assignment on a plain object, so those names matched an inherited member (and `__proto__` hit the prototype setter), and the section vanished from Object.keys; sections are now created with Object.defineProperty and looked up with own-property checks throughout the store and command paths. - Round-trip literal backslash escapes. parseDotEnvValue unescaped with chained replaceAll in an order that turned a stored "\n" (backslash + n) into a newline, corrupting tokens; replace it with a single left-to-right scan. - Read the global store lazily. It is the lowest-precedence, optional layer, so a corrupt ~/.config/wdl/credentials no longer aborts a command whose namespace and credentials already come from flags / shell / .env — the store is consulted only to supply a default namespace or fill an empty, non-flag credential slot. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 9 ++ bin/wdl.js | 6 ++ commands/secret.js | 7 +- commands/token.js | 11 ++- lib/common.js | 142 ++++++++++++++++++++++------- lib/config-state.js | 3 + lib/token-store.js | 10 +- tests/unit/cli-lifecycle.test.js | 4 +- tests/unit/cli-token-store.test.js | 46 ++++++++++ tests/unit/cli-token.test.js | 103 ++++++++++++++++++++- 10 files changed, 294 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aad2799..79fe759 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,15 @@ `--ns > shell WDL_NS > project .env WDL_NS > store default`, and `wdl config explain` shows `token store default` as the namespace source. +### Fixed + +- `wdl secret put` no longer echoes the typed secret on a TTY: input is read in + raw mode (hidden), and fails closed — it errors rather than echo if the + terminal cannot hide input. +- `.env` values containing literal backslash escape sequences (e.g. a token + with a backslash followed by `n`) now round-trip correctly instead of being + decoded as control characters. + ## 1.0.0 Initial open-source release. diff --git a/bin/wdl.js b/bin/wdl.js index b56833b..a1e2570 100755 --- a/bin/wdl.js +++ b/bin/wdl.js @@ -67,6 +67,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) { loadCliControlEnv(env, { nsFromFlag: scanned.ns, tokenFromFlag: scanned.tokenFromFlag, + controlUrlFromFlag: scanned.controlUrlFromFlag, loadEnv: loadEnvOverride, readStore: (e) => readTokenStore(tokenStorePath(e)), }); @@ -98,6 +99,11 @@ function scanCommandArgs(commandModule, args) { // (an empty --token "" falls back to env), so the cross-origin guard must // distrust .env control endpoints. Matches config-state's detection. tokenFromFlag: typeof values.token === "string" && values.token.length > 0, + // A control endpoint from a flag means the store need not be consulted to + // fill it, so a corrupt store cannot block a fully flag-supplied command. + controlUrlFromFlag: + (typeof values["control-url"] === "string" && values["control-url"].length > 0) || + (typeof values.admin === "string" && values.admin.length > 0), help: values.help === true || isHelpAlias(positionals), }; } diff --git a/commands/secret.js b/commands/secret.js index 27ca1b2..ba530c6 100644 --- a/commands/secret.js +++ b/commands/secret.js @@ -80,7 +80,7 @@ async function runSecret({ values, positionals, context }) { if (!keyArg) throw new CliError("put requires a KEY argument"); // Empty string is a set secret (≠ unset), matching wrangler. const value = await readStdin(stdin, { - prompt: `Enter secret value for ${scopeLabel}/${keyArg}: `, + prompt: `Enter secret value for ${scopeLabel}/${keyArg} (input hidden): `, stderr, }); const body = await context.fetchJson(context.nsUrl(...secretPath, keyArg), { @@ -150,11 +150,12 @@ function pickPromoteWarning(body) { // Pipe/redirect mode reads until EOF so multi-line secrets work. TTY mode // reads one line so typing a value and pressing Enter submits immediately. /** - * @param {{ isTTY?: boolean, setEncoding: (encoding: string) => void, on: Function, off: Function, pause?: Function }} stdin + * @param {{ isTTY?: boolean, setEncoding: (encoding: string) => void, setRawMode?: (mode: boolean) => void, on: Function, off: Function, pause?: Function }} stdin * @param {{ prompt?: string, stderr?: (text: string) => void }} [options] */ function readStdin(stdin, { prompt, stderr } = {}) { - if (stdin.isTTY) return readTtyLine(stdin, { prompt, stderr }); + // hidden: a secret value must never echo to the terminal or scrollback. + if (stdin.isTTY) return readTtyLine(stdin, { prompt, stderr, hidden: true }); return new Promise((resolve, reject) => { let data = ""; diff --git a/commands/token.js b/commands/token.js index 440e6b7..db522ea 100644 --- a/commands/token.js +++ b/commands/token.js @@ -110,7 +110,7 @@ async function tokenSet({ values, context }) { const storePath = tokenStorePath(context.env); const store = readTokenStore(storePath); - const previous = store.namespaces[ns] || {}; + const previous = Object.hasOwn(store.namespaces, ns) ? store.namespaces[ns] : {}; store.namespaces[ns] = { CONTROL_URL: controlUrl, ADMIN_TOKEN: token, @@ -135,7 +135,7 @@ function tokenUse({ context, nsArg }) { if (!ns) throw new CliError("token use requires a namespace: wdl token use "); const storePath = tokenStorePath(context.env); const store = readTokenStore(storePath); - if (!store.namespaces[ns]) { + if (!Object.hasOwn(store.namespaces, ns)) { throw new CliError(`no stored token for namespace "${ns}" — run \`wdl token set --ns ${ns}\` first`); } store.defaultNs = ns; @@ -160,7 +160,7 @@ function tokenRemove({ context }) { if (!ns) throw new CliError("token rm requires --ns "); const storePath = tokenStorePath(context.env); const store = readTokenStore(storePath); - if (!store.namespaces[ns]) throw new CliError(`no stored token for namespace "${ns}"`); + if (!Object.hasOwn(store.namespaces, ns)) throw new CliError(`no stored token for namespace "${ns}"`); delete store.namespaces[ns]; // Preserve the "a lone stored namespace is the default" invariant: if we // removed the default, promote a sole survivor, else clear it (an ambiguous @@ -188,11 +188,12 @@ function formatTokenList(rows) { // Read a single line: a TTY prompts without echo; a pipe is read to EOF. /** - * @param {{ isTTY?: boolean, setEncoding: (encoding: string) => void, on: Function, off: Function, pause?: Function }} stdin + * @param {{ isTTY?: boolean, setEncoding: (encoding: string) => void, setRawMode?: (mode: boolean) => void, on: Function, off: Function, pause?: Function }} stdin * @param {{ prompt?: string, stderr?: (text: string) => void }} [options] */ function readStdin(stdin, { prompt, stderr } = {}) { - if (stdin.isTTY) return readTtyLine(stdin, { prompt, stderr }); + // hidden: the token must never echo to the terminal or scrollback on a TTY. + if (stdin.isTTY) return readTtyLine(stdin, { prompt, stderr, hidden: true }); return new Promise((resolve, reject) => { let data = ""; stdin.setEncoding("utf8"); diff --git a/lib/common.js b/lib/common.js index 202ca31..3d7381d 100644 --- a/lib/common.js +++ b/lib/common.js @@ -351,6 +351,7 @@ export function loadCliDotEnv( * dotenvPath?: string, * nsFromFlag?: string, * tokenFromFlag?: boolean, + * controlUrlFromFlag?: boolean, * protectedKeys?: Set, * loadEnv?: typeof loadCliDotEnv, * readStore?: (env: NodeJS.ProcessEnv) => { defaultNs?: string | null, namespaces?: Record> }, @@ -363,6 +364,7 @@ export function loadCliControlEnv(env, { dotenvPath, nsFromFlag, tokenFromFlag = false, + controlUrlFromFlag = false, protectedKeys = new Set(Object.keys(env)), loadEnv = loadCliDotEnv, readStore = () => ({}), @@ -378,11 +380,13 @@ export function loadCliControlEnv(env, { }; record(loadEnv(env, dotenvPath, { protectedKeys, onLoad, warn })); - // Read the store once: it supplies both the lowest-precedence default - // namespace and the per-namespace control URL + token gap-fills below. - const store = readStore(env) || {}; - const storeNamespaces = store.namespaces || {}; - const storeDefaultNs = typeof store.defaultNs === "string" ? store.defaultNs : null; + // The global store is the lowest-precedence, optional layer, so read it + // lazily — only when it can actually contribute. Reading it eagerly would let + // a corrupt or unreadable ~/.config/wdl/credentials abort a command whose + // namespace and credentials already came from flags / shell / .env, with no + // way to work around it. Memoize so the at-most-one read is shared. + let storeData; + const getStore = () => (storeData ??= (readStore(env) || {})); let ns = firstNonEmptyString(nsFromFlag, env.WDL_NS); // The store's base WDL_NS names a default namespace — the lowest-precedence @@ -390,11 +394,16 @@ export function loadCliControlEnv(env, { // only when that default actually has a stored entry. Materialize it into env // so the rest of the pipeline (control-URL resolution, the [ns] overlay, // resolveNamespace in callers) sees the same namespace an explicit one would. - if (!ns && storeDefaultNs && storeNamespaces[storeDefaultNs]) { - ns = storeDefaultNs; - if (env.WDL_NS == null || env.WDL_NS === "") { - env.WDL_NS = ns; - if (onLoad) onLoad({ key: "WDL_NS", value: ns, section: ns, line: 0, origin: "store-default" }); + if (!ns) { + const s = getStore(); + const namespaces = s.namespaces || {}; + const def = typeof s.defaultNs === "string" ? s.defaultNs : null; + if (def && Object.hasOwn(namespaces, def)) { + ns = def; + if (env.WDL_NS == null || env.WDL_NS === "") { + env.WDL_NS = ns; + if (onLoad) onLoad({ key: "WDL_NS", value: ns, section: ns, line: 0, origin: "store-default" }); + } } } @@ -405,22 +414,32 @@ export function loadCliControlEnv(env, { // so a dropped endpoint's slot is filled by the trusted store rather than // staying shadowed by what the guard just removed. guardCrossOriginControlEnv(env, loaded, tokenFromFlag, onCrossOrigin); - // The global token store (~/.config/wdl) is the lowest-precedence layer and - // is trusted (you wrote it via `wdl token`, token + endpoint same-source), so - // it fills only the gaps left by flags / shell / project .env / the guard and - // is not itself subject to the cross-origin guard. readStore defaults to no - // store; the bin dispatcher and config-state wire the real reader. - if (ns) fillFromTokenStore(env, ns, storeNamespaces, onLoad); + // The store is trusted (you wrote it via `wdl token`, token + endpoint + // same-source) and not itself subject to the cross-origin guard, so it fills + // the gaps left by flags / shell / project .env / the guard — but only for a + // slot that is still empty AND not supplied by a flag (resolved later). That + // keeps the store unread when the credentials are already covered. + 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); + } } // Only the control-plane endpoint and token are materialized into env from a // store section; LABEL is store-only metadata for `wdl token list`. const STORE_ENV_KEYS = ["CONTROL_URL", "ADMIN_TOKEN"]; -function fillFromTokenStore(env, ns, namespaces, onLoad) { +/** @param {Record} [covered] */ +function fillFromTokenStore(env, ns, namespaces, onLoad, covered = {}) { + // hasOwn, not namespaces[ns]: a namespace named like an Object.prototype key + // (e.g. "constructor") must not resolve to an inherited member. + if (!Object.hasOwn(namespaces, ns)) return; const entry = namespaces[ns]; - if (!entry) return; for (const key of STORE_ENV_KEYS) { + // A flag supplies this slot (resolved later); don't shadow it in env with + // the store value, which would mislead anyone reading env[key] directly. + if (covered[key]) continue; const value = entry[key]; if (value == null || value === "") continue; if (env[key] != null && env[key] !== "") continue; // gap-fill only @@ -487,17 +506,29 @@ export async function confirmAction({ } /** - * @param {{ setEncoding: (encoding: string) => void, on: Function, off: Function, pause?: Function }} stdin - * @param {{ prompt?: string, stderr?: (text: string) => void }} [options] + * @param {{ isTTY?: boolean, setEncoding: (encoding: string) => void, setRawMode?: (mode: boolean) => void, on: Function, off: Function, pause?: Function }} stdin + * @param {{ prompt?: string, stderr?: (text: string) => void, hidden?: boolean }} [options] */ -export function readTtyLine(stdin, { prompt, stderr } = {}) { +export function readTtyLine(stdin, { prompt, stderr, hidden = false } = {}) { return new Promise((resolve, reject) => { let data = ""; + // Hidden input needs raw mode: a cooked TTY echoes keystrokes, so a token + // typed at the prompt would land in the terminal and scrollback. Raw mode + // disables echo and line editing, so we accumulate characters and handle + // newline / backspace / Ctrl-C ourselves, echoing nothing. Hidden input + // fails closed — if raw mode can't be enabled we reject rather than + // silently echo a secret, leaving the caller to fall back to a pipe. + const wantHidden = hidden && stdin.isTTY === true; + let raw = false; const cleanup = () => { stdin.off("data", onData); stdin.off("end", onEnd); stdin.off("error", onError); + if (raw) { + try { stdin.setRawMode(false); } catch { /* terminal already restored */ } + if (stderr) stderr("\n"); // the un-echoed Enter still needs a line break + } if (typeof stdin.pause === "function") stdin.pause(); }; @@ -505,19 +536,41 @@ export function readTtyLine(stdin, { prompt, stderr } = {}) { cleanup(); resolve(value); }; - - const onData = (chunk) => { - data += chunk; - const newline = data.search(/\r?\n/); - if (newline !== -1) finish(data.slice(0, newline)); - }; - const onEnd = () => finish(data.replace(/\r?\n$/, "")); - const onError = (err) => { + const fail = (err) => { cleanup(); reject(err); }; + const onData = (chunk) => { + if (!raw) { + data += chunk; + const newline = data.search(/\r?\n/); + if (newline !== -1) finish(data.slice(0, newline)); + return; + } + for (const ch of chunk) { + if (ch === "\r" || ch === "\n") return finish(data); + if (ch === "\u0003") return fail(new CliError("input aborted")); // Ctrl-C + if (ch === "\u0004") return finish(data); // Ctrl-D: submit what we have + if (ch === "\u007f" || ch === "\b") { data = data.slice(0, -1); continue; } // backspace + data += ch; + } + }; + const onEnd = () => finish(raw ? data : data.replace(/\r?\n$/, "")); + const onError = (err) => fail(err); + stdin.setEncoding("utf8"); + if (wantHidden) { + const failClosed = () => + reject(new CliError("cannot hide input on this terminal; pipe the value in instead")); + if (typeof stdin.setRawMode !== "function") return failClosed(); + try { + stdin.setRawMode(true); + raw = true; + } catch { + return failClosed(); + } + } if (prompt && stderr) stderr(prompt); stdin.on("data", onData); stdin.on("end", onEnd); @@ -540,16 +593,35 @@ export function parseDotEnvValue(value) { const inner = value.slice(1, end); if (quote === "'") return inner; - return inner - .replaceAll("\\n", "\n") - .replaceAll("\\r", "\r") - .replaceAll("\\t", "\t") - .replaceAll('\\"', "\"") - .replaceAll("\\\\", "\\"); + return unescapeDoubleQuoted(inner); } return value.replace(/\s+#.*$/, ""); } +// Single-pass unescape for the double-quoted dialect. A left-to-right scan is +// required: chaining replaceAll("\\n", "\n") before replaceAll("\\\\", "\\") +// would turn an escaped backslash followed by a literal "n" (stored as "\\n") +// into a newline, corrupting any value that legitimately contains a backslash +// (e.g. a token). Mirrors quoteValue in lib/token-store.js. +function unescapeDoubleQuoted(s) { + let out = ""; + for (let i = 0; i < s.length; i += 1) { + if (s[i] === "\\" && i + 1 < s.length) { + const next = s[i + 1]; + i += 1; + if (next === "n") out += "\n"; + else if (next === "r") out += "\r"; + else if (next === "t") out += "\t"; + else if (next === "\"") out += "\""; + else if (next === "\\") out += "\\"; + else out += "\\" + next; // preserve unknown escapes verbatim + } else { + out += s[i]; + } + } + return out; +} + function findClosingQuote(value, quote) { for (let i = 1; i < value.length; i += 1) { if (value[i] !== quote) continue; diff --git a/lib/config-state.js b/lib/config-state.js index acef6b5..8afd73e 100644 --- a/lib/config-state.js +++ b/lib/config-state.js @@ -38,6 +38,9 @@ export function resolveCliConfigState({ values = {}, env = process.env, cwd = pr dotenvPath: resolvedDotenvPath, nsFromFlag: values.ns, tokenFromFlag: typeof values.token === "string" && values.token.length > 0, + controlUrlFromFlag: + (typeof values["control-url"] === "string" && values["control-url"].length > 0) || + (typeof values.admin === "string" && values.admin.length > 0), protectedKeys, readStore: (e) => readTokenStore(tokenStorePath(e)), onLoad: recordDotenvLoad, diff --git a/lib/token-store.js b/lib/token-store.js index c6265f6..f57f551 100644 --- a/lib/token-store.js +++ b/lib/token-store.js @@ -58,7 +58,13 @@ export function readTokenStore(storePath) { const nextSection = parseDotEnvSection(line, idx + 1); if (nextSection !== null) { section = nextSection; - namespaces[section] ??= {}; + // defineProperty, never `namespaces[section] = {}`: a "__proto__" section + // would invoke the prototype setter (dropping the section), and a + // "constructor"/"toString" one would collide with an inherited member. + // defineProperty creates the own data entry regardless, prototype intact. + if (!Object.hasOwn(namespaces, section)) { + Object.defineProperty(namespaces, section, { value: {}, writable: true, enumerable: true, configurable: true }); + } continue; } const eq = line.indexOf("="); @@ -96,7 +102,7 @@ export function writeTokenStore(storePath, store) { ]; // Write the default-namespace pointer only when it has a stored entry, so the // file never carries a dangling default. Mirrors a base WDL_NS in a `.env`. - if (store.defaultNs && namespaces[store.defaultNs]) { + if (store.defaultNs && Object.hasOwn(namespaces, store.defaultNs)) { lines.push(`WDL_NS=${quoteValue(store.defaultNs)}`, ""); } for (const ns of Object.keys(namespaces).sort()) { diff --git a/tests/unit/cli-lifecycle.test.js b/tests/unit/cli-lifecycle.test.js index 7572122..1bd37c3 100644 --- a/tests/unit/cli-lifecycle.test.js +++ b/tests/unit/cli-lifecycle.test.js @@ -48,6 +48,7 @@ function ttyStdinLine(value) { isTTY: true, paused: false, setEncoding(_encoding) {}, + setRawMode(_mode) {}, // a real TTY has this; hidden input requires it pause() { this.paused = true; }, @@ -1100,7 +1101,8 @@ test("secret put reads one tty line without waiting for EOF", async () => { assert.equal(calls.length, 1); assert.equal(calls[0].init.body, JSON.stringify({ value: "typed-value" })); - assert.deepEqual(prompts, ["Enter secret value for demo (ns)/KEY: "]); + // The prompt, then a newline written when raw (hidden) mode is restored. + assert.deepEqual(prompts, ["Enter secret value for demo (ns)/KEY (input hidden): ", "\n"]); assert.equal(stdin.paused, true); }); diff --git a/tests/unit/cli-token-store.test.js b/tests/unit/cli-token-store.test.js index 2c0ee00..ccd6703 100644 --- a/tests/unit/cli-token-store.test.js +++ b/tests/unit/cli-token-store.test.js @@ -95,6 +95,52 @@ test("writeTokenStore quotes and escapes so odd token characters round-trip", () }); }); +test("round-trips a token containing literal backslash escape sequences", () => { + withTempHome((dir) => { + const p = path.join(dir, "credentials"); + // The token literally contains `\n`, `\t`, `\\`, `\"` as backslash+char, + // plus a Windows-style path — none of which must be decoded as control chars. + const store = { + defaultNs: null, + namespaces: { + acme: { ADMIN_TOKEN: "a\\nb\\tc\\\\d\\\"e", LABEL: "C:\\Users\\x" }, + }, + }; + writeTokenStore(p, store); + assert.deepEqual(readTokenStore(p), store); + }); +}); + +test("preserves a namespace named like an Object.prototype key", () => { + withTempHome((dir) => { + const p = path.join(dir, "credentials"); + const store = { + defaultNs: "constructor", + namespaces: { + constructor: { ADMIN_TOKEN: "tok-ctor" }, + toString: { ADMIN_TOKEN: "tok-tostr" }, + }, + }; + writeTokenStore(p, store); + const back = readTokenStore(p); + assert.deepEqual(Object.keys(back.namespaces).sort(), ["constructor", "toString"]); + assert.equal(back.namespaces["constructor"].ADMIN_TOKEN, "tok-ctor"); + assert.equal(back.defaultNs, "constructor"); + }); +}); + +test("handles a __proto__ section without polluting the prototype", () => { + withTempHome((dir) => { + const p = path.join(dir, "credentials"); + writeFileSync(p, '[__proto__]\nADMIN_TOKEN="x"\n[acme]\nADMIN_TOKEN="a"\n'); + const back = readTokenStore(p); + assert.deepEqual(Object.keys(back.namespaces).sort(), ["__proto__", "acme"]); + assert.equal(back.namespaces["__proto__"].ADMIN_TOKEN, "x"); + assert.equal(Object.getPrototypeOf(back.namespaces), Object.prototype, "map prototype untouched"); + assert.equal(/** @type {any} */ ({}).ADMIN_TOKEN, undefined, "Object.prototype not polluted"); + }); +}); + test("writeTokenStore writes canonical sorted output with a managed-by header", () => { withTempHome((dir) => { const p = path.join(dir, "credentials"); diff --git a/tests/unit/cli-token.test.js b/tests/unit/cli-token.test.js index 91d8d86..1b60141 100644 --- a/tests/unit/cli-token.test.js +++ b/tests/unit/cli-token.test.js @@ -5,7 +5,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { runTokenCommand } from "../../commands/token.js"; -import { loadCliControlEnv } from "../../lib/common.js"; +import { loadCliControlEnv, readTtyLine } from "../../lib/common.js"; import { readTokenStore, tokenStorePath, writeTokenStore } from "../../lib/token-store.js"; import { response } from "./helpers.js"; @@ -287,6 +287,57 @@ test("token rejects unknown subcommands", async () => { }); }); +test("token use/list/rm handle a namespace named like an Object.prototype key", async () => { + await withTempXdg(async (xdg) => { + const p = tokenStorePath({ XDG_CONFIG_HOME: xdg }); + writeTokenStore(p, { namespaces: { constructor: { ADMIN_TOKEN: "c" }, acme: { ADMIN_TOKEN: "a" } } }); + + await runTokenCommand(["use", "constructor"], deps(xdg).deps); + assert.equal(readTokenStore(p).defaultNs, "constructor"); + + const { lines, deps: d } = deps(xdg); + await runTokenCommand(["list"], d); + assert.match(lines.join("\n"), /\*\s+constructor/); + + await runTokenCommand(["rm", "--ns", "constructor"], deps(xdg).deps); + assert.equal(Object.hasOwn(readTokenStore(p).namespaces, "constructor"), false); + }); +}); + +// --- hidden TTY input --- + +test("readTtyLine hides input by switching the TTY to raw mode", async () => { + const rawCalls = []; + const stderr = []; + const stdin = Object.assign(new EventEmitter(), { + isTTY: true, + setEncoding() {}, + setRawMode(v) { rawCalls.push(v); }, + pause() {}, + }); + const pending = readTtyLine(stdin, { prompt: "tok: ", stderr: (s) => stderr.push(s), hidden: true }); + queueMicrotask(() => { + stdin.emit("data", "sec"); + stdin.emit("data", "X" + String.fromCharCode(127)); // typo, then backspace removes it + stdin.emit("data", "ret" + String.fromCharCode(13)); // Enter + }); + assert.equal(await pending, "secret"); + assert.deepEqual(rawCalls, [true, false], "raw mode (echo off) enabled, then restored"); +}); + +test("readTtyLine fails closed when a TTY cannot hide input", async () => { + const stdin = Object.assign(new EventEmitter(), { + isTTY: true, + setEncoding() {}, + pause() {}, + // no setRawMode: cannot disable echo, so hidden input must reject, not leak + }); + await assert.rejects( + () => readTtyLine(stdin, { prompt: "tok: ", stderr: () => {}, hidden: true }), + /cannot hide input/ + ); +}); + // --- resolution integration (the global store as the lowest-precedence layer) --- test("loadCliControlEnv fills control URL and token from the store as a gap-filler", () => { @@ -353,6 +404,56 @@ 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 does not fill a flag-covered slot from the store", () => { + const env = { WDL_NS: "acme" }; + // --control-url supplies the endpoint, so the store fills only the token and + // never writes its own CONTROL_URL into env. + loadCliControlEnv(env, { + nsFromFlag: "acme", + controlUrlFromFlag: true, + loadEnv: () => [], + readStore: () => ({ namespaces: { acme: { CONTROL_URL: "https://store.example", ADMIN_TOKEN: "store-tok" } } }), + }); + assert.equal(env.CONTROL_URL, undefined, "flag-covered endpoint is not shadowed by the store"); + assert.equal(env.ADMIN_TOKEN, "store-tok", "the uncovered token slot is still filled"); +}); + +test("loadCliControlEnv does not read the store when ns and credentials are present", () => { + const env = { WDL_NS: "acme", CONTROL_URL: "https://shell.example", ADMIN_TOKEN: "shell-tok" }; + let reads = 0; + loadCliControlEnv(env, { + nsFromFlag: "acme", + loadEnv: () => [], + readStore: () => { reads += 1; return {}; }, + }); + assert.equal(reads, 0, "the store is the lowest layer and untouched when nothing needs it"); +}); + +test("loadCliControlEnv ignores a corrupt store when flags cover the credentials", () => { + let reads = 0; + // ns + both creds come from flags, so the store is never consulted and a + // corrupt ~/.config/wdl/credentials cannot abort the command. + loadCliControlEnv(/** @type {NodeJS.ProcessEnv} */ ({}), { + nsFromFlag: "acme", + tokenFromFlag: true, + controlUrlFromFlag: true, + loadEnv: () => [], + readStore: () => { reads += 1; throw new Error("Invalid credentials line 3"); }, + }); + assert.equal(reads, 0, "store never read"); +}); + +test("loadCliControlEnv surfaces a corrupt store when it is the credential source", () => { + assert.throws( + () => loadCliControlEnv(/** @type {NodeJS.ProcessEnv} */ ({}), { + nsFromFlag: "acme", + loadEnv: () => [], + readStore: () => { throw new Error("Invalid credentials line 3"); }, + }), + /Invalid credentials/ + ); +}); + test("a project .env endpoint is still dropped when the token comes from the store", () => { // Malicious cwd .env supplies an endpoint but no token; the store supplies the // token (and a trusted endpoint). The guard must drop the project endpoint From 5458b194eba2117599cb25423d03d1396803a232 Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Mon, 15 Jun 2026 16:44:27 +0800 Subject: [PATCH 03/13] Address Codex round 2: remove ADMIN_URL/--admin (BREAKING), fix default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING: drop the `--admin` flag and the `ADMIN_URL` environment variable — legacy compatibility aliases for the control endpoint. Use `--control-url` and `CONTROL_URL` instead. `--admin` is now an unknown option and `ADMIN_URL` is no longer read from the shell or `.env`. Removed from the option/preset defs, `resolveControlUrl`'s fallback chain, the cross-origin guard's endpoint-key set, the `.env` key set (so it is also no longer stripped from the wrangler child env), `config explain`'s source labels, and the GUIDE. This also resolves Codex's "honor ADMIN_URL before the store" finding by removing the alias outright — there is no ADMIN_URL left to honor. Also promote a sole surviving namespace as the default after any `wdl token rm`, not only when the removed namespace was the current default: removing the default from {a,b,c} clears it, and a later removal down to one entry now makes that entry the default again instead of leaving the store with no usable default. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 8 ++++++++ GUIDE-zh.md | 2 +- GUIDE.md | 2 +- bin/wdl.js | 8 +++----- commands/token.js | 16 ++++++++-------- lib/command.js | 2 +- lib/common.js | 23 ++++++++--------------- lib/config-state.js | 6 +----- tests/unit/cli-deploy.test.js | 1 - tests/unit/cli-lifecycle.test.js | 2 +- tests/unit/cli-token.test.js | 14 ++++++++++++++ 11 files changed, 46 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79fe759..1df06e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,14 @@ `--ns > shell WDL_NS > project .env WDL_NS > store default`, and `wdl config explain` shows `token store default` as the namespace source. +### Removed + +- **BREAKING:** the `--admin` flag and the `ADMIN_URL` environment variable — + legacy compatibility aliases for the control endpoint — are removed. Use + `--control-url ` and the `CONTROL_URL` environment variable instead. + `--admin` is now an unknown option and `ADMIN_URL` is no longer read from the + shell or `.env`. + ### Fixed - `wdl secret put` no longer echoes the typed secret on a TTY: input is read in diff --git a/GUIDE-zh.md b/GUIDE-zh.md index 5278b7b..acf124c 100644 --- a/GUIDE-zh.md +++ b/GUIDE-zh.md @@ -81,7 +81,7 @@ ADMIN_TOKEN= ADMIN_TOKEN= ``` -CLI 只会从 `.env` 读取 WDL 平台变量:`ADMIN_TOKEN`、`ADMIN_URL`、 `CONTROL_URL`、`CONTROL_CONNECT_HOST`、`WDL_NS`。优先级是 `CLI flag > shell/CI env > [resolved-ns] section > base .env`,都没有提供时命令直接报错——没有内置默认值。namespace 解析顺序是 `--ns`,然后是 shell 或 base `.env` 里的 `WDL_NS`。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`,都没有提供时命令直接报错——没有内置默认值。namespace 解析顺序是 `--ns`,然后是 shell 或 base `.env` 里的 `WDL_NS`。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。 这些凭证也可以来自托管存储,而不是 shell 或 `.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)。 diff --git a/GUIDE.md b/GUIDE.md index 974c9df..cf5fdab 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -95,7 +95,7 @@ ADMIN_TOKEN= ``` The CLI loads only WDL platform variables from `.env`: `ADMIN_TOKEN`, -`ADMIN_URL`, `CONTROL_URL`, `CONTROL_CONNECT_HOST`, and `WDL_NS`. Precedence is +`CONTROL_URL`, `CONTROL_CONNECT_HOST`, and `WDL_NS`. Precedence is `CLI flag > shell/CI env > [resolved-ns] section > base .env`, and if none supplies a value the command fails — there is no built-in default. Namespace resolution is `--ns`, then `WDL_NS` from your shell or base `.env`. Section diff --git a/bin/wdl.js b/bin/wdl.js index a1e2570..788ee63 100755 --- a/bin/wdl.js +++ b/bin/wdl.js @@ -99,11 +99,9 @@ function scanCommandArgs(commandModule, args) { // (an empty --token "" falls back to env), so the cross-origin guard must // distrust .env control endpoints. Matches config-state's detection. tokenFromFlag: typeof values.token === "string" && values.token.length > 0, - // A control endpoint from a flag means the store need not be consulted to - // fill it, so a corrupt store cannot block a fully flag-supplied command. - controlUrlFromFlag: - (typeof values["control-url"] === "string" && values["control-url"].length > 0) || - (typeof values.admin === "string" && values.admin.length > 0), + // A --control-url means the store need not be consulted to fill the + // endpoint, so a corrupt store cannot block a fully flag-supplied command. + controlUrlFromFlag: typeof values["control-url"] === "string" && values["control-url"].length > 0, help: values.help === true || isHelpAlias(positionals), }; } diff --git a/commands/token.js b/commands/token.js index db522ea..59d45ff 100644 --- a/commands/token.js +++ b/commands/token.js @@ -75,7 +75,7 @@ async function tokenSet({ values, context }) { // writing the store, so it cannot supply its own endpoint. Give a token-set // message rather than resolveControlUrl's generic "set it in .env" hint, // which would be backwards here (the store exists to avoid a .env). - if (!values["control-url"] && !values.admin && !context.env.CONTROL_URL && !context.env.ADMIN_URL) { + if (!values["control-url"] && !context.env.CONTROL_URL) { throw new CliError( `token set needs the control URL for ${ns}: pass --control-url (a stored token is scoped to one control plane).` ); @@ -162,13 +162,13 @@ function tokenRemove({ context }) { const store = readTokenStore(storePath); if (!Object.hasOwn(store.namespaces, ns)) throw new CliError(`no stored token for namespace "${ns}"`); delete store.namespaces[ns]; - // Preserve the "a lone stored namespace is the default" invariant: if we - // removed the default, promote a sole survivor, else clear it (an ambiguous - // set of remaining namespaces needs an explicit --ns or `wdl token use`). - if (store.defaultNs === ns) { - const remaining = Object.keys(store.namespaces); - store.defaultNs = remaining.length === 1 ? remaining[0] : null; - } + // Keep the "a lone stored namespace is the default" invariant after any + // removal: a sole survivor becomes the default even if an earlier removal + // already cleared it; removing the current default from a still-ambiguous set + // clears it (an explicit --ns or `wdl token use` is then needed). + const remaining = Object.keys(store.namespaces); + if (remaining.length === 1) store.defaultNs = remaining[0]; + else if (store.defaultNs === ns) store.defaultNs = null; writeTokenStore(storePath, store); context.stdout( `Removed the stored token for ${escapeTerminalText(ns)}. This does not revoke it on the control plane.` diff --git a/lib/command.js b/lib/command.js index 60efb00..f282d90 100644 --- a/lib/command.js +++ b/lib/command.js @@ -29,7 +29,7 @@ import { * Flag-preset names accepted in a command's `options` list; each expands to * the matching shared option specs: * "ns" -> --ns - * "control" -> --control-url, --admin (alias), --token + * "control" -> --control-url, --token * "env" -> --env * "json" -> --json * "yes" -> --yes diff --git a/lib/common.js b/lib/common.js index 3d7381d..ae8b022 100644 --- a/lib/common.js +++ b/lib/common.js @@ -5,11 +5,10 @@ import { isAdminAcceptableNs } from "./ns-pattern.js"; // Control-plane endpoint keys: where the admin token gets sent. A cwd .env // must not redirect these for a token that came from the shell/--token. -const CONTROL_ENDPOINT_KEYS = ["CONTROL_URL", "ADMIN_URL", "CONTROL_CONNECT_HOST"]; +const CONTROL_ENDPOINT_KEYS = ["CONTROL_URL", "CONTROL_CONNECT_HOST"]; export const CLI_DOTENV_KEYS = new Set([ "ADMIN_TOKEN", - "ADMIN_URL", "CONTROL_CONNECT_HOST", "CONTROL_URL", "WDL_NS", @@ -50,7 +49,7 @@ export function commonCliOptions({ namespace = true, controlUrl = true, token = export function commonCliOptionSpecs({ namespace = true, controlUrl = true, token = true, json = false, help = true } = {}) { const specs = []; if (namespace) specs.push(OPTION_DEFS.ns); - if (controlUrl) specs.push(OPTION_DEFS.controlUrl, OPTION_DEFS.admin); + if (controlUrl) specs.push(OPTION_DEFS.controlUrl); if (token) specs.push(OPTION_DEFS.token); if (json) specs.push(OPTION_DEFS.json); if (help) specs.push(OPTION_DEFS.help); @@ -77,8 +76,7 @@ export function defineHiddenCliOption(name, parseConfig) { const OPTION_DEFS = { ns: defineCliOption("ns", { type: "string" }, "--ns ", "Namespace (env: WDL_NS)."), env: defineCliOption("env", { type: "string" }, "--env ", "Wrangler environment (env: CLOUDFLARE_ENV)."), - controlUrl: defineCliOption("control-url", { type: "string" }, "--control-url ", "Control URL (env: CONTROL_URL; ADMIN_URL accepted for compatibility)."), - admin: defineCliOption("admin", { type: "string" }, "--admin ", "Alias for --control-url."), + controlUrl: defineCliOption("control-url", { type: "string" }, "--control-url ", "Control URL (env: CONTROL_URL)."), token: defineCliOption("token", { type: "string" }, "--token ", "Admin token (env: ADMIN_TOKEN)."), json: defineCliOption("json", { type: "boolean" }, "--json", "Print the raw control response."), yes: defineCliOption("yes", { type: "boolean" }, "--yes", "Confirm destructive actions."), @@ -88,10 +86,10 @@ const OPTION_DEFS = { const CLI_OPTION_PRESETS = { ns: [OPTION_DEFS.ns], env: [OPTION_DEFS.env], - control: [OPTION_DEFS.controlUrl, OPTION_DEFS.admin, OPTION_DEFS.token], + control: [OPTION_DEFS.controlUrl, OPTION_DEFS.token], // Control-plane endpoint flags without --token, for commands that read the // token elsewhere (e.g. `wdl token set` reads it from stdin). - endpoint: [OPTION_DEFS.controlUrl, OPTION_DEFS.admin], + endpoint: [OPTION_DEFS.controlUrl], json: [OPTION_DEFS.json], yes: [OPTION_DEFS.yes], help: [OPTION_DEFS.help], @@ -167,12 +165,7 @@ function isParseArgsError(err) { } export function resolveControlUrl(values, env = process.env) { - const raw = ( - values["control-url"] || - values.admin || - env.CONTROL_URL || - env.ADMIN_URL - ); + const raw = values["control-url"] || env.CONTROL_URL; // No built-in default: a fallback host would silently receive the admin // token whenever a self-hosted user forgets to configure their endpoint. if (!raw) { @@ -417,8 +410,8 @@ export function loadCliControlEnv(env, { // The store is trusted (you wrote it via `wdl token`, token + endpoint // same-source) and not itself subject to the cross-origin guard, so it fills // the gaps left by flags / shell / project .env / the guard — but only for a - // slot that is still empty AND not supplied by a flag (resolved later). That - // keeps the store unread when the credentials are already covered. + // slot still empty AND not supplied by a flag (resolved later). That keeps + // the store unread when the credentials are already covered. if (ns) { const covered = { CONTROL_URL: controlUrlFromFlag, ADMIN_TOKEN: tokenFromFlag }; const needsFill = STORE_ENV_KEYS.some((k) => !covered[k] && (env[k] == null || env[k] === "")); diff --git a/lib/config-state.js b/lib/config-state.js index 8afd73e..d5c673d 100644 --- a/lib/config-state.js +++ b/lib/config-state.js @@ -38,9 +38,7 @@ export function resolveCliConfigState({ values = {}, env = process.env, cwd = pr dotenvPath: resolvedDotenvPath, nsFromFlag: values.ns, tokenFromFlag: typeof values.token === "string" && values.token.length > 0, - controlUrlFromFlag: - (typeof values["control-url"] === "string" && values["control-url"].length > 0) || - (typeof values.admin === "string" && values.admin.length > 0), + controlUrlFromFlag: typeof values["control-url"] === "string" && values["control-url"].length > 0, protectedKeys, readStore: (e) => readTokenStore(tokenStorePath(e)), onLoad: recordDotenvLoad, @@ -99,9 +97,7 @@ function sourceForNamespace(values, env, sources) { function sourceForControlUrl(values, env, sources) { if (typeof values["control-url"] === "string" && values["control-url"].length > 0) return "--control-url"; - if (typeof values.admin === "string" && values.admin.length > 0) return "--admin"; if (typeof env.CONTROL_URL === "string" && env.CONTROL_URL.length > 0) return sources.get("CONTROL_URL") || "CONTROL_URL env"; - if (typeof env.ADMIN_URL === "string" && env.ADMIN_URL.length > 0) return sources.get("ADMIN_URL") || "ADMIN_URL env"; return null; } diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index 6612bb9..539ce77 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -1363,7 +1363,6 @@ test("wranglerChildEnv strips WDL control-plane environment", () => { assert.deepEqual( wranglerChildEnv({ ADMIN_TOKEN: "secret", - ADMIN_URL: "https://ctl.admin.example", CONTROL_CONNECT_HOST: "ctl.connect.example", CONTROL_URL: "https://ctl.example", WDL_NS: "tenant", diff --git a/tests/unit/cli-lifecycle.test.js b/tests/unit/cli-lifecycle.test.js index 1bd37c3..c393d05 100644 --- a/tests/unit/cli-lifecycle.test.js +++ b/tests/unit/cli-lifecycle.test.js @@ -110,7 +110,7 @@ test("resolveControlUrl keeps bare local dev control URLs on http", () => { test("resolveControlContext centralizes admin token and headers", () => { assert.deepEqual( - resolveControlContext({ admin: "http://ctl.example/" }, { ADMIN_TOKEN: "tok" }), + resolveControlContext({ "control-url": "http://ctl.example/" }, { ADMIN_TOKEN: "tok" }), { controlUrl: "http://ctl.example", token: "tok", diff --git a/tests/unit/cli-token.test.js b/tests/unit/cli-token.test.js index 1b60141..4bd9e15 100644 --- a/tests/unit/cli-token.test.js +++ b/tests/unit/cli-token.test.js @@ -281,6 +281,20 @@ test("token rm of the default promotes a sole survivor, clears it when ambiguous }); }); +test("token rm promotes the sole survivor even after the default was already cleared", async () => { + await withTempXdg(async (xdg) => { + const p = tokenStorePath({ XDG_CONFIG_HOME: xdg }); + writeTokenStore(p, { + defaultNs: "acme", + namespaces: { acme: { ADMIN_TOKEN: "a" }, demo: { ADMIN_TOKEN: "d" }, prod: { ADMIN_TOKEN: "p" } }, + }); + await runTokenCommand(["rm", "--ns", "acme"], deps(xdg).deps); // default cleared, 2 remain + assert.equal(readTokenStore(p).defaultNs, null); + await runTokenCommand(["rm", "--ns", "demo"], deps(xdg).deps); // non-default removed, prod alone + assert.equal(readTokenStore(p).defaultNs, "prod", "sole survivor promoted even with no prior default"); + }); +}); + test("token rejects unknown subcommands", async () => { await withTempXdg(async (xdg) => { await assert.rejects(() => runTokenCommand(["frobnicate"], deps(xdg).deps), /Usage:/); From 4be3d3e3b8979f7b6abafe8aad83f9d57b93f414 Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Mon, 15 Jun 2026 17:45:59 +0800 Subject: [PATCH 04/13] Address Codex round 3 + make wdl init's --ns optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit token set (Codex round 3): - Create the namespace section with Object.defineProperty instead of `store.namespaces[ns] = …`, so a "__proto__" namespace lands as an own section rather than hitting the prototype setter and vanishing from the written store (mirrors readTokenStore's section creation). - Only auto-set the default for the first stored namespace (an empty store) or an explicit --default. Previously any set with `store.defaultNs` null claimed the default — including after it was deliberately cleared by removing it from an ambiguous set — silently changing where no-`--ns` commands go. wdl init: - `--ns` is now optional: the namespace is a deploy-time concern (resolved from `--ns` / `WDL_NS` / a project `.env` / a `wdl token` default). With `--ns` the scaffolded deploy script stays `wdl deploy . --ns `; without it it is `wdl deploy .` and the next-steps output explains how to supply it. - init no longer autoloads control credentials (autoloadEnv: false); it only scaffolds files locally, so a corrupt token store can no longer abort scaffolding (Codex "skip token-store reads for init"). - The README AI-usage steps now point at `wdl doctor` / `wdl token set` for credential setup — credentials may resolve from shell env, a project `.env`, or the token store — instead of mandating WDL_NS/ADMIN_TOKEN/CONTROL_URL in a shell rc, and no longer assume `--ns` at init time. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/rules/examples.md | 2 +- CHANGELOG.md | 8 ++++++++ GUIDE-zh.md | 2 +- GUIDE.md | 7 ++++--- README-zh.md | 7 ++++--- README.md | 7 ++++--- commands/init.js | 31 +++++++++++++++++------------- commands/token.js | 28 ++++++++++++++++++--------- templates/AGENTS.md | 8 +++++--- tests/unit/cli-init.test.js | 10 +++++----- tests/unit/cli-lifecycle.test.js | 6 +++--- tests/unit/cli-token.test.js | 33 ++++++++++++++++++++++++++++++++ 12 files changed, 105 insertions(+), 44 deletions(-) diff --git a/.claude/rules/examples.md b/.claude/rules/examples.md index 37e7bbe..d231a82 100644 --- a/.claude/rules/examples.md +++ b/.claude/rules/examples.md @@ -14,7 +14,7 @@ structure from scratch. ## Available examples -Each lives under `examples//`. Prefer `wdl init --ns ` for +Each lives under `examples//`. Prefer `wdl init [--ns ]` for plain workers. Pick and copy the closest example when an example matches the requested feature set better than the minimal init template. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1df06e7..bc4a3af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,14 @@ `--ns > shell WDL_NS > project .env WDL_NS > store default`, and `wdl config explain` shows `token store default` as the namespace source. +### Changed + +- `wdl init`'s `--ns` is now optional. With `--ns`, the scaffolded `npm run + deploy` keeps `wdl deploy . --ns `; without it the script is + `wdl deploy .` and the namespace is resolved at deploy time (`--ns` / `WDL_NS` + / project `.env` / a `wdl token` default). `init` also no longer autoloads + control credentials, so a corrupt token store cannot block scaffolding. + ### Removed - **BREAKING:** the `--admin` flag and the `ADMIN_URL` environment variable — diff --git a/GUIDE-zh.md b/GUIDE-zh.md index acf124c..13dc88c 100644 --- a/GUIDE-zh.md +++ b/GUIDE-zh.md @@ -99,7 +99,7 @@ npm install 它会写入: -- `package.json` —— `npm run deploy` 已把 `--ns` 烤进去,另有 `npm run dry-run` 本地打包检查;devDependencies 固定 `wrangler@^4` 和 `@wdl-dev/cli`。 +- `package.json` —— 传了 `--ns` 时 `npm run deploy` 会把它烤进去,否则就是 `wdl deploy .`(namespace 在部署期解析),另有 `npm run dry-run` 本地打包检查;devDependencies 固定 `wrangler@^4` 和 `@wdl-dev/cli`。 - `wrangler.jsonc` —— 顶层 `name` 是 worker 名(默认等于目录名,可用 `--worker ` 覆盖)。 - `src/index.js`、`.gitignore`,以及 `AGENTS.md`/`CLAUDE.md`,方便 AI 代理找到 `node_modules/@wdl-dev/cli/docs/` 下的分主题文档。 diff --git a/GUIDE.md b/GUIDE.md index cf5fdab..caf2a71 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -144,9 +144,10 @@ npm install It writes: -- `package.json` — `npm run deploy` with `--ns` baked in, plus an - `npm run dry-run` local bundle check; pins `wrangler@^4` and `@wdl-dev/cli` as - devDependencies. +- `package.json` — `npm run deploy` with `--ns` baked in when you pass it + (otherwise just `wdl deploy .`, with the namespace resolved at deploy time), + plus an `npm run dry-run` local bundle check; pins `wrangler@^4` and + `@wdl-dev/cli` as devDependencies. - `wrangler.jsonc` — top-level `name` is the worker name (defaults to the directory name; override with `--worker `). - `src/index.js`, `.gitignore`, and `AGENTS.md`/`CLAUDE.md` so AI agents can diff --git a/README-zh.md b/README-zh.md index 2db712e..fa50233 100644 --- a/README-zh.md +++ b/README-zh.md @@ -65,7 +65,7 @@ Worker 此时位于 `https://./hello/`。 ## 命令 ```bash -wdl init --ns [--worker ] +wdl init [--ns ] [--worker ] wdl deploy [--ns ] [--env ] [--verbose] wdl tail [...] [--ns ] [--raw] wdl workers [--ns ] @@ -125,9 +125,10 @@ Worker/项目目录名:[如果已知就填,例如 hello-counter;不知道 步骤: 1. 检查 Node.js >= 22 和 npm。缺 `wdl` 时执行 `npm i -g @wdl-dev/cli`;安装后确认 `command -v wdl` 可用。 -2. 确认 `WDL_NS`、`ADMIN_TOKEN`、`CONTROL_URL`。CLI 没有内置控制面地址,三个值都必须配置——由运维方提供。如果有缺失,引导我写入当前 shell 的 rc 文件(先看 `$SHELL`:zsh → `~/.zshrc`,bash → `~/.bashrc` 或 `~/.bash_profile`,fish → `~/.config/fish/config.fish`),然后让当前 shell 生效;已设置就跳过。 +2. 确认 namespace 和 control 凭证能解析出来——跑 `wdl doctor`。它们可以来自 shell/CI env(`WDL_NS`、`ADMIN_TOKEN`、`CONTROL_URL`)、项目 `.env`,或 `wdl token` store;control URL 和 token 由运维方提供(CLI 没有内置控制面地址)。都解析不出来时,最干净的做法是让我跑 `wdl token set --ns --control-url `,在隐藏提示里输入 token——它会经校验、以 `0600` 存入,并成为默认 namespace,之后 `wdl deploy` 不用再带 `--ns`。优先用这个,而不是把 token 写进 shell rc 文件。 3. 确认项目目录名以字母开头,后续只含字母、数字和连字符。执行: - `wdl init --ns "$WDL_NS" && cd && npm install` + `wdl init && cd && npm install` + (给 `wdl init` 加 `--ns ` 可把 namespace 烤进 deploy 脚本;否则部署期从 `wdl token` 默认或 `--ns` 解析。) 4. 立刻打开并阅读新目录里的 `AGENTS.md`,再根据我的功能打开 `node_modules/@wdl-dev/cli/docs/` 下相关文档和示例。注意:session 中新生成的 `AGENTS.md` 不会自动加载,必须显式读取。 5. 根据功能修改 `wrangler.jsonc` 和 `src/`。需要第三方 API 鉴权 secret 时用 `wdl secret put --worker ` 写入,不要把 token 放进源码、`wrangler.jsonc` 或 `.env`。 6. 先跑 `npm run dry-run` 修复本地 bundle 问题,再跑 `npm run deploy` 部署。 diff --git a/README.md b/README.md index 2c7b8bd..5d9c7cd 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ source precedence (flags beat shell env, which beats `.env`). ## Commands ```bash -wdl init --ns [--worker ] +wdl init [--ns ] [--worker ] wdl deploy [--ns ] [--env ] [--verbose] wdl tail [...] [--ns ] [--raw] wdl workers [--ns ] @@ -160,9 +160,10 @@ Start executing right away — don't just hand me a plan. Follow these rules thr Steps: 1. Check Node.js >= 22 and npm. If `wdl` is missing, run `npm i -g @wdl-dev/cli`, then confirm `command -v wdl` works. -2. Confirm `WDL_NS`, `ADMIN_TOKEN`, and `CONTROL_URL`. The CLI has no built-in control endpoint, so all three are required — my operator provides them. If any are missing, guide me to add them to my shell rc file (check `$SHELL` first: zsh → `~/.zshrc`, bash → `~/.bashrc` or `~/.bash_profile`, fish → `~/.config/fish/config.fish`), then reload the current shell; skip if already set. +2. Confirm a namespace and control credentials resolve — run `wdl doctor`. They can come from shell/CI env (`WDL_NS`, `ADMIN_TOKEN`, `CONTROL_URL`), a project `.env`, or the `wdl token` store; my operator provides the control URL and token (the CLI has no built-in endpoint). If nothing resolves, the cleanest setup is for me to run `wdl token set --ns --control-url ` and enter the token at the hidden prompt — it is validated, stored `0600`, and becomes the default namespace, so later `wdl deploy` needs no `--ns`. Prefer this over writing the token into a shell rc file. 3. Confirm the project directory name starts with a letter and contains only letters, digits, and hyphens. Run: - `wdl init --ns "$WDL_NS" && cd && npm install` + `wdl init && cd && npm install` + (add `--ns ` to `wdl init` to bake the namespace into the deploy script; otherwise it resolves from the `wdl token` default or `--ns` at deploy time.) 4. Immediately open and read `AGENTS.md` in the new directory, then open the relevant docs and examples under `node_modules/@wdl-dev/cli/docs/` for my feature. Note: a freshly generated `AGENTS.md` is not loaded automatically mid-session — read it explicitly. 5. Edit `wrangler.jsonc` and `src/` for the feature. Push third-party API secrets with `wdl secret put --worker `; never put tokens in source, `wrangler.jsonc`, or `.env`. 6. Run `npm run dry-run` first and fix local bundle issues, then deploy with `npm run deploy`. diff --git a/commands/init.js b/commands/init.js index dd57802..98082f4 100644 --- a/commands/init.js +++ b/commands/init.js @@ -14,17 +14,19 @@ const DEFAULT_COMPATIBILITY_DATE = "2026-05-31"; const CLI_ROOT = path.resolve(fileURLToPath(import.meta.url), "../.."); const INIT_OPTIONS = [ - defineCliOption("ns", { type: "string" }, "--ns ", "Tenant namespace baked into the deploy scripts (required)."), + defineCliOption("ns", { type: "string" }, "--ns ", "Tenant namespace baked into the deploy script (optional)."), defineCliOption("worker", { type: "string" }, "--worker ", "Worker name in wrangler.jsonc (defaults to )."), defineCliOption("help", { type: "boolean", short: "h" }, "-h, --help", "Show this help."), ]; // init is not a defineCommand (no control plane / namespace), so its metadata -// is declared directly for the bin registry's help table. +// is declared directly for the bin registry's help table. autoloadEnv is false: +// init only scaffolds files locally, so it must not load .env control vars or +// read the token store — a corrupt store must never block project scaffolding. export const meta = { name: "init", summary: "Scaffold a new WDL Worker project.", - autoloadEnv: true, + autoloadEnv: false, parseOptions: optionParseOptions(INIT_OPTIONS), }; @@ -38,9 +40,6 @@ export async function main(argv = process.argv.slice(2)) { if (!args.target) { throw new CliError("missing argument. Run `wdl init --help`."); } - if (!args.ns) { - throw new CliError("missing --ns . Pass the tenant namespace, e.g. `--ns acme`."); - } const { targetDir, packageName, isInPlace } = resolveTarget(args.target); @@ -51,8 +50,8 @@ export async function main(argv = process.argv.slice(2)) { ); } - const ns = args.ns.trim(); - validateNs(ns, "--ns"); + const ns = args.ns ? args.ns.trim() : null; + if (ns) validateNs(ns, "--ns"); const workerName = (args.worker ? args.worker.trim() : "") || packageName; validateWorker(workerName, args.worker ? "--worker" : "worker name"); @@ -167,7 +166,7 @@ async function writeStarter(targetDir, { packageName, workerName, ns }) { private: true, type: "module", scripts: { - deploy: `wdl deploy . --ns ${ns}`, + deploy: ns ? `wdl deploy . --ns ${ns}` : "wdl deploy .", "dry-run": "wrangler deploy --dry-run --outdir=.deploy-dist", }, devDependencies: { @@ -260,7 +259,7 @@ async function copyAgentsDoc(targetDir) { } function printNextSteps(target, { packageName, workerName, ns, isInPlace }) { - const url = `https://${ns}./${workerName}/`; + const url = `https://${ns || ""}./${workerName}/`; const lines = [ "", `Scaffolded ${packageName}.`, @@ -278,8 +277,14 @@ function printNextSteps(target, { packageName, workerName, ns, isInPlace }) { lines.push("the agent reads it to find the right per-feature docs under"); lines.push("node_modules/@wdl-dev/cli/docs/."); lines.push(""); - lines.push(`Deploy (--ns ${ns} is baked into the npm script):`); - lines.push(" npm run deploy"); + if (ns) { + lines.push(`Deploy (--ns ${ns} is baked into the npm script):`); + lines.push(" npm run deploy"); + } else { + lines.push("Deploy — pick a namespace (none is baked in):"); + lines.push(" npm run deploy -- --ns "); + lines.push(" # or set WDL_NS / a project .env / a `wdl token` default, then: npm run deploy"); + } lines.push(""); console.log(lines.join("\n")); } @@ -287,7 +292,7 @@ function printNextSteps(target, { packageName, workerName, ns, isInPlace }) { function printHelp(exitCode) { console.log(formatHelp({ usage: [ - "wdl init --ns [--worker ]", + "wdl init [--ns ] [--worker ]", "wdl init --help", ], description: diff --git a/commands/token.js b/commands/token.js index 59d45ff..c5413c5 100644 --- a/commands/token.js +++ b/commands/token.js @@ -111,15 +111,25 @@ async function tokenSet({ values, context }) { const storePath = tokenStorePath(context.env); const store = readTokenStore(storePath); const previous = Object.hasOwn(store.namespaces, ns) ? store.namespaces[ns] : {}; - store.namespaces[ns] = { - CONTROL_URL: controlUrl, - ADMIN_TOKEN: token, - LABEL: typeof values.label === "string" ? values.label : previous.LABEL, - }; - // The first stored namespace (no default yet), or an explicit --default, - // becomes the default used when --ns/WDL_NS is omitted — the store's analogue - // of a base WDL_NS in a project .env. - const becameDefault = Boolean(values.default) || !store.defaultNs; + const wasEmpty = Object.keys(store.namespaces).length === 0; + // defineProperty, not `store.namespaces[ns] = …`: a namespace named "__proto__" + // would otherwise hit the prototype setter and never create an own section + // (mirrors readTokenStore's section creation). + Object.defineProperty(store.namespaces, ns, { + value: { + CONTROL_URL: controlUrl, + ADMIN_TOKEN: token, + LABEL: typeof values.label === "string" ? values.label : previous.LABEL, + }, + writable: true, + enumerable: true, + configurable: true, + }); + // Only the first stored namespace (an empty store) auto-becomes the default, + // or an explicit --default. A later set must NOT silently steal the default + // just because it is currently null — the default may have been deliberately + // cleared by removing it from an ambiguous set. + const becameDefault = Boolean(values.default) || wasEmpty; if (becameDefault) store.defaultNs = ns; writeTokenStore(storePath, store); context.stdout( diff --git a/templates/AGENTS.md b/templates/AGENTS.md index 086ba69..a94c6ce 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -79,9 +79,11 @@ npx wrangler deploy --dry-run --outdir=.deploy-dist # bundle check npm run deploy # deploy to WDL ``` -`wdl init` bakes `--ns ` into the `deploy` script in `package.json`. When -you need environment overrides, add `[env.]` config per `env-overrides.md` -and pass `--env ` explicitly in the script. +`wdl init` bakes `--ns ` into the `deploy` script in `package.json` when you +pass it; without `--ns` the script is `wdl deploy .` and the namespace is +resolved at deploy time (`--ns`, `WDL_NS`, a project `.env`, or a `wdl token` +default). When you need environment overrides, add `[env.]` config per +`env-overrides.md` and pass `--env ` explicitly in the script. To override `vars` / `assets` / bindings / `triggers` per environment, put them in the matching `env.` block. WDL differs from Cloudflare Workers / diff --git a/tests/unit/cli-init.test.js b/tests/unit/cli-init.test.js index 70fcb00..addd67d 100644 --- a/tests/unit/cli-init.test.js +++ b/tests/unit/cli-init.test.js @@ -173,11 +173,11 @@ test("init accepts a mixed-case project name end to end", async () => { }); }); -test("init exits with an error when --ns is missing", async () => { - await withTempCwd(async () => { - const { exitCode, errOutput } = await captureExit(() => main(["demo"])); - assert.equal(exitCode, 1); - assert.match(errOutput, /missing --ns/); +test("init scaffolds without --ns; the deploy script omits the namespace", async () => { + await withTempCwd(async (cwd) => { + await main(["demo"]); + const pkg = JSON.parse(readFileSync(path.join(cwd, "demo", "package.json"), "utf8")); + assert.equal(pkg.scripts.deploy, "wdl deploy ."); }); }); diff --git a/tests/unit/cli-lifecycle.test.js b/tests/unit/cli-lifecycle.test.js index c393d05..cfd6ed8 100644 --- a/tests/unit/cli-lifecycle.test.js +++ b/tests/unit/cli-lifecycle.test.js @@ -1610,11 +1610,11 @@ async function withMockedExit(fn) { test("wdl dispatcher loads base dotenv before namespace section overlay", async () => { const calls = []; - // init's missing- CliError fires after autoload, keeping the + // secret's missing-subcommand CliError fires after autoload, keeping the // dispatch harmless without needing a control-plane mock. await withMockedExit(async () => { await assert.rejects( - () => wdlMain(["init", "--ns", "demo"], { + () => wdlMain(["secret", "--ns", "demo"], { env: {}, loadEnv: (_env, _path, options) => calls.push(options), }), @@ -1637,7 +1637,7 @@ test("wdl dispatcher overlays the LAST --ns occurrence, matching parseArgs", asy const calls = []; await withMockedExit(async () => { await assert.rejects( - () => wdlMain(["init", "--ns", "first", "--ns=last"], { + () => wdlMain(["secret", "--ns", "first", "--ns=last"], { env: {}, loadEnv: (_env, _path, options) => calls.push(options), }), diff --git a/tests/unit/cli-token.test.js b/tests/unit/cli-token.test.js index 4bd9e15..854c3e3 100644 --- a/tests/unit/cli-token.test.js +++ b/tests/unit/cli-token.test.js @@ -102,6 +102,39 @@ test("token set --default makes an existing namespace the default", async () => }); }); +test("token set creates a __proto__ namespace without hitting the prototype setter", async () => { + await withTempXdg(async (xdg) => { + await runTokenCommand( + ["set", "--ns", "__proto__", "--control-url", "https://api.example"], + deps(xdg, { + stdin: stdinFrom("tok-proto\n"), + controlFetch: async () => response({ ok: true, principal: { kind: "ns", ns: "__proto__" } }), + }).deps + ); + const store = readTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg })); + assert.equal(Object.hasOwn(store.namespaces, "__proto__"), true); + assert.equal(store.namespaces["__proto__"].ADMIN_TOKEN, "tok-proto"); + assert.equal(store.defaultNs, "__proto__"); + }); +}); + +test("token set does not claim a deliberately-cleared default in an ambiguous store", async () => { + await withTempXdg(async (xdg) => { + const p = tokenStorePath({ XDG_CONFIG_HOME: xdg }); + // Default null but 2+ namespaces (e.g. the default was removed from an + // ambiguous set); a later set without --default must not steal the default. + writeTokenStore(p, { defaultNs: null, namespaces: { acme: { ADMIN_TOKEN: "a" }, demo: { ADMIN_TOKEN: "d" } } }); + await runTokenCommand( + ["set", "--ns", "demo", "--control-url", "https://api.example"], + deps(xdg, { + stdin: stdinFrom("tok\n"), + controlFetch: async () => response({ ok: true, principal: { kind: "ns", ns: "demo" } }), + }).deps + ); + assert.equal(readTokenStore(p).defaultNs, null); + }); +}); + test("token set stores and preserves a --label", async () => { await withTempXdg(async (xdg) => { await runTokenCommand( From 5d17ddaf84903227db345807474ac7b6ff96c3b0 Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Mon, 15 Jun 2026 18:34:00 +0800 Subject: [PATCH 05/13] Address Codex round 4: tolerate a corrupt store for ns-optional commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The default-namespace lookup in loadCliControlEnv now tolerates a corrupt or unreadable token store. The default ns is the lowest-precedence, optional namespace source, so a broken ~/.config/wdl/credentials must not abort a command that needs no namespace (e.g. `wdl whoami --control-url … --token …`, which gets the namespace from /whoami). The credential gap-fill read stays strict, so a store that is the actual credential source still surfaces its corruption, and the `wdl token` commands report it directly. - writeTokenStore tightens an existing credentials file to 0600 BEFORE writing the token bytes (writeFileSync's mode only applies when it creates the file), so a pre-existing permissive (e.g. 0644) file never holds the secret while still world-readable. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/common.js | 17 ++++++++++++++--- lib/token-store.js | 12 ++++++++++-- tests/unit/cli-token-store.test.js | 8 +++++++- tests/unit/cli-token.test.js | 13 +++++++++++++ 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/lib/common.js b/lib/common.js index ae8b022..4d507fa 100644 --- a/lib/common.js +++ b/lib/common.js @@ -388,9 +388,20 @@ export function loadCliControlEnv(env, { // so the rest of the pipeline (control-URL resolution, the [ns] overlay, // resolveNamespace in callers) sees the same namespace an explicit one would. if (!ns) { - const s = getStore(); - const namespaces = s.namespaces || {}; - const def = typeof s.defaultNs === "string" ? s.defaultNs : null; + // The default namespace is the lowest-precedence, OPTIONAL namespace source, + // so a corrupt/unreadable store must not block a command that needs no + // namespace (e.g. `wdl whoami --control-url … --token …`, which gets it from + // /whoami). Tolerate a read failure here as "no default"; the gap-fill read + // below stays strict, so a store that is the actual credential source still + // surfaces its corruption. + let s; + try { + s = getStore(); + } catch { + // corrupt/unreadable store → no usable default; do not block the command + } + const namespaces = (s && s.namespaces) || {}; + const def = s && typeof s.defaultNs === "string" ? s.defaultNs : null; if (def && Object.hasOwn(namespaces, def)) { ns = def; if (env.WDL_NS == null || env.WDL_NS === "") { diff --git a/lib/token-store.js b/lib/token-store.js index f57f551..392daeb 100644 --- a/lib/token-store.js +++ b/lib/token-store.js @@ -115,9 +115,17 @@ export function writeTokenStore(storePath, store) { lines.push(""); } mkdirSync(path.dirname(storePath), { recursive: true, mode: 0o700 }); + // Tighten an existing file to 0600 BEFORE writing token bytes: writeFileSync's + // mode only applies when it creates the file, so without this a pre-existing + // permissive (e.g. 0644) credentials file would receive the secret while still + // world-readable, with a window until a post-write chmod. A non-ENOENT failure + // aborts before the token is written. + try { + chmodSync(storePath, 0o600); + } catch (err) { + if (!err || err.code !== "ENOENT") throw err; + } writeFileSync(storePath, lines.join("\n"), { mode: 0o600 }); - // writeFileSync's mode only applies on create; force perms on an existing file. - chmodSync(storePath, 0o600); } // Escape for the double-quoted dialect: backslash first, then the rest, so a diff --git a/tests/unit/cli-token-store.test.js b/tests/unit/cli-token-store.test.js index ccd6703..482a02e 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 { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { @@ -166,6 +166,12 @@ test("writeTokenStore sets 0600 file permissions", () => { // Re-write over an existing file keeps 0600. writeTokenStore(p, { namespaces: { acme: { ADMIN_TOKEN: "t2" } } }); assert.equal(statSync(p).mode & 0o777, 0o600); + // A pre-existing permissive (0644) file is tightened to 0600 before the + // token bytes are written, not after. + writeFileSync(p, "stale", { mode: 0o644 }); + chmodSync(p, 0o644); + writeTokenStore(p, { namespaces: { acme: { ADMIN_TOKEN: "t3" } } }); + assert.equal(statSync(p).mode & 0o777, 0o600); }); }); diff --git a/tests/unit/cli-token.test.js b/tests/unit/cli-token.test.js index 854c3e3..39bffa9 100644 --- a/tests/unit/cli-token.test.js +++ b/tests/unit/cli-token.test.js @@ -501,6 +501,19 @@ 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 …). + const env = /** @type {NodeJS.ProcessEnv} */ ({}); + assert.doesNotThrow(() => + loadCliControlEnv(env, { + loadEnv: () => [], + readStore: () => { throw new Error("Invalid credentials line 3"); }, + }) + ); + assert.equal(env.WDL_NS, undefined); +}); + test("a project .env endpoint is still dropped when the token comes from the store", () => { // Malicious cwd .env supplies an endpoint but no token; the store supplies the // token (and a trusted endpoint). The guard must drop the project endpoint From 56da8e5692febfb356d2dc60c792718095c8eefa Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Tue, 16 Jun 2026 00:29:07 +0800 Subject: [PATCH 06/13] Address Codex round 5: stored-token exfil guard + token set hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1: the cross-origin .env guard now treats a .env control endpoint as same-source only when the .env supplies a NON-EMPTY ADMIN_TOKEN. An empty `ADMIN_TOKEN=` placeholder no longer marks a malicious .env endpoint trusted while the gap-fill afterwards supplies the real token from the store — which would have sent the stored token to a host the .env chose. - token set validates the namespace against the section grammar (isAdminAcceptableNs) before storing, so a value containing `]` / newlines (e.g. echoed back via --ns from a misconfigured control plane) can no longer inject lines/sections and corrupt the credentials file on the next read. - token set escapes terminal control bytes in the masked token suffix it prints and in the principal-mismatch error message, so a pasted token or a malicious control plane cannot emit escape sequences to the terminal (token list already escaped via writeResult; the set path did not). Co-Authored-By: Claude Opus 4.8 (1M context) --- commands/token.js | 15 ++++++-- lib/common.js | 9 ++++- tests/unit/cli-token.test.js | 66 ++++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 4 deletions(-) diff --git a/commands/token.js b/commands/token.js index c5413c5..3e53621 100644 --- a/commands/token.js +++ b/commands/token.js @@ -19,6 +19,7 @@ import { writeResult, } from "../lib/common.js"; import { maskToken } from "../lib/config-state.js"; +import { isAdminAcceptableNs } from "../lib/ns-pattern.js"; import { fetchWhoami, namespaceFromPrincipal } from "../lib/whoami.js"; import { readTokenStore, tokenStorePath, writeTokenStore } from "../lib/token-store.js"; @@ -71,6 +72,14 @@ async function runToken({ values, positionals, context }) { async function tokenSet({ values, context }) { const ns = context.resolveNamespace(); if (!ns) throw new CliError("token set requires --ns "); + // The namespace becomes a `[section]` key in the store file, so it must match + // the same grammar store/.env sections use (tenant namespaces plus operator- + // reserved `__name__` sections). A value with `]` or newlines (e.g. echoed + // back via --ns from a misconfigured control plane) would otherwise inject + // lines/sections and corrupt the file on the next read. + if (!isAdminAcceptableNs(ns)) { + throw new CliError(`invalid namespace "${escapeTerminalText(ns)}"`); + } // The control URL comes from flags/shell only, never the store — we are // writing the store, so it cannot supply its own endpoint. Give a token-set // message rather than resolveControlUrl's generic "set it in .env" hint, @@ -103,8 +112,8 @@ async function tokenSet({ values, context }) { if (principalNs !== ns) { throw new CliError( principalNs - ? `token principal is namespace "${principalNs}", not "${ns}" — run with --ns ${principalNs}` - : `this token is not scoped to namespace "${ns}"; wdl token stores tenant tokens under their own namespace` + ? `token principal is namespace "${escapeTerminalText(principalNs)}", not "${escapeTerminalText(ns)}" — run with --ns ${escapeTerminalText(principalNs)}` + : `this token is not scoped to namespace "${escapeTerminalText(ns)}"; wdl token stores tenant tokens under their own namespace` ); } @@ -133,7 +142,7 @@ async function tokenSet({ values, context }) { if (becameDefault) store.defaultNs = ns; writeTokenStore(storePath, store); context.stdout( - `Stored token for ${escapeTerminalText(ns)} @ ${escapeTerminalText(controlUrl)} (${maskToken(token)}).` + `Stored token for ${escapeTerminalText(ns)} @ ${escapeTerminalText(controlUrl)} (${escapeTerminalText(maskToken(token))}).` ); if (becameDefault) { context.stdout(`${escapeTerminalText(ns)} is now the default namespace (used when --ns is omitted).`); diff --git a/lib/common.js b/lib/common.js index 4d507fa..6b2f557 100644 --- a/lib/common.js +++ b/lib/common.js @@ -461,7 +461,14 @@ function fillFromTokenStore(env, ns, namespaces, onLoad, covered = {}) { // .env endpoint (resolution falls back to shell/default) and warn. Same-source // .env (token + URL together, single-tenant) and shell-sourced URLs are fine. function guardCrossOriginControlEnv(env, loadedFromDotenv, tokenFromFlag, onCrossOrigin) { - const tokenIsFromDotenv = loadedFromDotenv.has("ADMIN_TOKEN") && !tokenFromFlag; + // A NON-EMPTY .env token, not merely a loaded `ADMIN_TOKEN=` key: an empty + // placeholder would otherwise mark the .env endpoint same-source while the + // real token gets gap-filled from the global store afterwards — letting an + // untrusted .env redirect a STORED token to a host it chose. + const tokenIsFromDotenv = + loadedFromDotenv.has("ADMIN_TOKEN") && + typeof env.ADMIN_TOKEN === "string" && env.ADMIN_TOKEN.length > 0 && + !tokenFromFlag; if (tokenIsFromDotenv) return; for (const key of CONTROL_ENDPOINT_KEYS) { if (!loadedFromDotenv.has(key)) continue; diff --git a/tests/unit/cli-token.test.js b/tests/unit/cli-token.test.js index 39bffa9..c5ba014 100644 --- a/tests/unit/cli-token.test.js +++ b/tests/unit/cli-token.test.js @@ -210,6 +210,49 @@ test("token set rejects a token that is not scoped to a namespace", async () => }); }); +test("token set rejects a namespace that is not a valid section name", async () => { + await withTempXdg(async (xdg) => { + await assert.rejects( + () => runTokenCommand( + ["set", "--ns", "evil]x", "--control-url", "https://api.example"], + deps(xdg, { stdin: stdinFrom("tok\n") }).deps + ), + /invalid namespace/ + ); + assert.deepEqual(readTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg })), { defaultNs: null, namespaces: {} }); + }); +}); + +test("token set escapes terminal controls in a principal-mismatch error", async () => { + await withTempXdg(async (xdg) => { + const esc = String.fromCharCode(27); + const controlFetch = async () => response({ ok: true, principal: { kind: "ns", ns: `other${esc}[2J` } }); + await assert.rejects( + () => runTokenCommand( + ["set", "--ns", "acme", "--control-url", "https://api.example"], + deps(xdg, { stdin: stdinFrom("tok\n"), controlFetch }).deps + ), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.doesNotMatch(message, new RegExp(esc), "raw ESC must not be in the error"); + assert.match(message, /token principal is namespace/); + return true; + } + ); + }); +}); + +test("token set escapes a masked token suffix containing terminal controls", async () => { + await withTempXdg(async (xdg) => { + const esc = String.fromCharCode(27); + const { lines, deps: d } = deps(xdg, { stdin: stdinFrom(`tok-secret${esc}[2J\n`) }); + await runTokenCommand(["set", "--ns", "acme", "--control-url", "https://api.example"], d); + const out = lines.join("\n"); + assert.doesNotMatch(out, new RegExp(esc), "raw ESC must not reach stdout via the masked suffix"); + assert.match(out, /Stored token for acme/); + }); +}); + test("token set warns before sending the token to a plain-http non-local host", async () => { await withTempXdg(async (xdg) => { const { warnings, deps: d } = deps(xdg, { stdin: stdinFrom("tok\n") }); @@ -514,6 +557,29 @@ test("loadCliControlEnv tolerates a corrupt store when no namespace is needed", assert.equal(env.WDL_NS, undefined); }); +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. + const env = /** @type {NodeJS.ProcessEnv} */ ({}); + const warnings = []; + loadCliControlEnv(env, { + nsFromFlag: "acme", + loadEnv: (e, _path, opts) => { + if (!opts.resolvedNs) { + e.CONTROL_URL = "https://evil.example"; + e.ADMIN_TOKEN = ""; + return ["CONTROL_URL", "ADMIN_TOKEN"]; + } + return []; + }, + readStore: () => ({ namespaces: { acme: { CONTROL_URL: "https://good.example", ADMIN_TOKEN: "store-tok" } } }), + onCrossOrigin: (line) => warnings.push(line), + }); + assert.equal(env.CONTROL_URL, "https://good.example", "evil endpoint dropped, store endpoint used"); + assert.equal(env.ADMIN_TOKEN, "store-tok"); + assert.equal(warnings.length, 1); +}); + test("a project .env endpoint is still dropped when the token comes from the store", () => { // Malicious cwd .env supplies an endpoint but no token; the store supplies the // token (and a trusted endpoint). The guard must drop the project endpoint From 79d4be010d446bdb80f5f6309c2e3752b0af117d Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Tue, 16 Jun 2026 01:15:24 +0800 Subject: [PATCH 07/13] Escape ns in token use/rm not-found errors A self-review found that `token use` / `token rm` interpolate the user-supplied namespace into their "no stored token" errors without escapeTerminalText, while token set's errors and the use/rm success messages already escape it. handleCliError prints the message raw, so a --ns / positional containing terminal control bytes could emit escape sequences. Wrap ns in escapeTerminalText, matching the rest of the command's output. Co-Authored-By: Claude Opus 4.8 (1M context) --- commands/token.js | 4 ++-- tests/unit/cli-token.test.js | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/commands/token.js b/commands/token.js index 3e53621..dad49d5 100644 --- a/commands/token.js +++ b/commands/token.js @@ -155,7 +155,7 @@ function tokenUse({ context, nsArg }) { const storePath = tokenStorePath(context.env); const store = readTokenStore(storePath); if (!Object.hasOwn(store.namespaces, ns)) { - throw new CliError(`no stored token for namespace "${ns}" — run \`wdl token set --ns ${ns}\` first`); + throw new CliError(`no stored token for namespace "${escapeTerminalText(ns)}" — run \`wdl token set --ns ${escapeTerminalText(ns)}\` first`); } store.defaultNs = ns; writeTokenStore(storePath, store); @@ -179,7 +179,7 @@ function tokenRemove({ context }) { if (!ns) throw new CliError("token rm requires --ns "); const storePath = tokenStorePath(context.env); const store = readTokenStore(storePath); - if (!Object.hasOwn(store.namespaces, ns)) throw new CliError(`no stored token for namespace "${ns}"`); + if (!Object.hasOwn(store.namespaces, ns)) throw new CliError(`no stored token for namespace "${escapeTerminalText(ns)}"`); delete store.namespaces[ns]; // Keep the "a lone stored namespace is the default" invariant after any // removal: a sole survivor becomes the default even if an earlier removal diff --git a/tests/unit/cli-token.test.js b/tests/unit/cli-token.test.js index c5ba014..4ad84a4 100644 --- a/tests/unit/cli-token.test.js +++ b/tests/unit/cli-token.test.js @@ -321,6 +321,19 @@ test("token list prints a placeholder when empty", async () => { }); }); +test("token use/rm escape terminal controls in the not-found error", async () => { + await withTempXdg(async (xdg) => { + const esc = String.fromCharCode(27); + const bad = `ghost${esc}[2J`; + const noEsc = (err) => { + assert.doesNotMatch(/** @type {Error} */ (err).message, new RegExp(esc), "raw ESC must not reach the error"); + return true; + }; + await assert.rejects(() => runTokenCommand(["use", bad], deps(xdg).deps), noEsc); + await assert.rejects(() => runTokenCommand(["rm", "--ns", bad], deps(xdg).deps), noEsc); + }); +}); + test("token rm removes a stored namespace and errors when absent", async () => { await withTempXdg(async (xdg) => { const p = tokenStorePath({ XDG_CONFIG_HOME: xdg }); From c2ec7309257395536424bf6eab903de839372e33 Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Tue, 16 Jun 2026 08:28:05 +0800 Subject: [PATCH 08/13] Refactor and harden CLI input/output helpers after the token feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behavior-preserving cleanup (342 tests / lint / typecheck green): Dedup: - `readStdin` was copied identically in `token set` and `secret put` → extracted to `readSecretStdin` in common.js (next to readTtyLine), with direct tests for the hidden-TTY / pipe-EOF / trailing-newline contract. - the dotenv encoder `quoteValue` moved from token-store.js to common.js beside its decoder (parseDotEnvValue / unescapeDoubleQuoted) so the round-trip pair lives in one file; the store imports it. - the six `typeof values[x] === "string" && length > 0` flag checks across bin/wdl.js and config-state.js → a `flagSet(values, name)` helper. - `maskToken` moved from config-state.js to common.js, so `wdl token` no longer depends on config-state for a display util. Output hardening: - added `writeStatusLine` (the non-JSON analogue of writeResult): a choke point that escapes a single human status line once, so callers interpolate raw values without per-field escaping. `wdl secret`, `wdl token`, `wdl deploy`, and `wdl r2` route their status output through it; this also neutralizes previously-raw user-controllable fields (secret's `scopeLabel`/`keyArg`, deploy's upload + `✓ … live` lines carrying `ns`/`workerName`, r2's `--out` path). Error messages and multi-line stderr notes still escape inline — they are not single-line stdout. - added `writeJsonOr`: the `--json` half of a compound command's output (emit body as machine JSON, else defer to the human path), replacing the repeated `if (json) { stdout(JSON.stringify(…)); return; }` branch in `wdl secret`. It and writeResult emit machine JSON through one `writeJson(stdout, body)` so the format can't drift. - prompts and confirmations escape at their write points: readTtyLine escapes the prompt (covering `token set` / `secret put` and every `[y/N]` confirm) and confirmAction escapes the `action` in its refusal error. This hardens the raw user args that the d1 / r2 / delete / workflows confirms previously interpolated unescaped, and lets `wdl secret` drop all per-field escaping (stdout via writeStatusLine, prompts via the choke point). Left `loadCliControlEnv` and the two INI parse loops as-is on purpose: the former is a security-ordered linear pipeline (guard before fill), the latter have genuinely different rules — merging either would obscure more than it saves. Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/wdl.js | 6 +- commands/deploy.js | 18 +++--- commands/r2.js | 3 +- commands/secret.js | 71 +++++++--------------- commands/token.js | 36 +++--------- lib/common.js | 88 ++++++++++++++++++++++++++-- lib/config-state.js | 18 ++---- lib/token-store.js | 16 +---- tests/unit/cli-config-doctor.test.js | 3 +- tests/unit/cli-lifecycle.test.js | 75 ++++++++++++++++++++++++ tests/unit/cli-token-store.test.js | 6 +- tests/unit/cli-token.test.js | 45 +++++++++++++- 12 files changed, 258 insertions(+), 127 deletions(-) diff --git a/bin/wdl.js b/bin/wdl.js index 788ee63..ca3aa71 100755 --- a/bin/wdl.js +++ b/bin/wdl.js @@ -15,7 +15,7 @@ import * as doctorCmd from "../commands/doctor.js"; import * as whoamiCmd from "../commands/whoami.js"; import * as tokenCmd from "../commands/token.js"; import { isHelpAlias } from "../lib/command.js"; -import { commonCliOptions, formatHelp, handleCliError, isMain, loadCliControlEnv } from "../lib/common.js"; +import { commonCliOptions, flagSet, formatHelp, handleCliError, isMain, loadCliControlEnv } from "../lib/common.js"; import { currentCliVersion } from "../lib/package-info.js"; import { readTokenStore, tokenStorePath } from "../lib/token-store.js"; @@ -98,10 +98,10 @@ function scanCommandArgs(commandModule, args) { // A non-empty --token means the effective credential is NOT the .env one // (an empty --token "" falls back to env), so the cross-origin guard must // distrust .env control endpoints. Matches config-state's detection. - tokenFromFlag: typeof values.token === "string" && values.token.length > 0, + tokenFromFlag: flagSet(values, "token"), // A --control-url means the store need not be consulted to fill the // endpoint, so a corrupt store cannot block a fully flag-supplied command. - controlUrlFromFlag: typeof values["control-url"] === "string" && values["control-url"].length > 0, + controlUrlFromFlag: flagSet(values, "control-url"), help: values.help === true || isHelpAlias(positionals), }; } diff --git a/commands/deploy.js b/commands/deploy.js index 0f9b074..72a531a 100644 --- a/commands/deploy.js +++ b/commands/deploy.js @@ -13,6 +13,7 @@ import { isMain, optionHelp, shellSingleQuote, + writeStatusLine, } from "../lib/common.js"; import { packWranglerProject } from "../lib/wrangler-pack.js"; @@ -45,9 +46,9 @@ export async function postArtifactToControl({ context, ns, workerName, manifest, }; const deployBody = serializeDeployManifest(manifest); - stdout(`[2/3] uploading ${workerName} → ${controlUrl}/ns/${ns}`); - // `version` comes from the control response — escape every display site, - // but keep the raw value for the promote request body. + writeStatusLine(stdout, `[2/3] uploading ${workerName} → ${controlUrl}/ns/${ns}`); + // `version` comes from the control response; keep the raw value for the + // promote request body — display sites escape via writeStatusLine. const { version, warnings } = await context.fetchJson( context.nsUrl("worker", workerName, "deploy"), { @@ -76,8 +77,7 @@ export async function postArtifactToControl({ context, ns, workerName, manifest, } } - const displayVersion = escapeTerminalText(String(version)); - stdout(`[3/3] promoting ${displayVersion}`); + writeStatusLine(stdout, `[3/3] promoting ${version}`); let promoteBody; try { promoteBody = await context.fetchJson( @@ -91,7 +91,7 @@ export async function postArtifactToControl({ context, ns, workerName, manifest, ); } catch (err) { stderr( - `note: version ${displayVersion} was uploaded and retained but NOT promoted; ` + + `note: version ${escapeTerminalText(String(version))} was uploaded and retained but NOT promoted; ` + `the previously active version still serves traffic. Re-run \`wdl deploy\` to retry.` ); throw err; @@ -168,14 +168,14 @@ async function runDeploy({ values, positionals, context }) { }); stdout(""); - stdout(`✓ ${ns}/${workerName}@${escapeTerminalText(String(version))} live`); + writeStatusLine(stdout, `✓ ${ns}/${workerName}@${version} live`); const controlHost = new URL(controlUrl).hostname; const isLocal = controlHost === "localhost" || controlHost === "127.0.0.1"; if (isLocal) { const host = `${ns}.${platformDomain || "workers.local"}`; - stdout(` curl -H ${shellSingleQuote(`Host: ${escapeTerminalText(host)}`)} http://localhost:8080/${escapeTerminalText(workerName)}/`); + writeStatusLine(stdout, ` curl -H ${shellSingleQuote(`Host: ${host}`)} http://localhost:8080/${workerName}/`); } else if (platformDomain) { - stdout(` https://${escapeTerminalText(ns)}.${escapeTerminalText(platformDomain)}/${escapeTerminalText(workerName)}/`); + writeStatusLine(stdout, ` https://${ns}.${platformDomain}/${workerName}/`); } } diff --git a/commands/r2.js b/commands/r2.js index 2b9794c..85cee4c 100644 --- a/commands/r2.js +++ b/commands/r2.js @@ -15,6 +15,7 @@ import { isMain, optionHelp, writeResult, + writeStatusLine, } from "../lib/common.js"; import { formatBucketList, formatObjectHead, formatObjectList } from "../lib/r2-format.js"; @@ -92,7 +93,7 @@ async function runR2({ values, positionals, context }) { }, "get R2 object"); if (values.out) { const bytesWritten = await writeBodyToFile(res.body, values.out); - stdout(`OK wrote ${bytesWritten} bytes to ${values.out}`); + writeStatusLine(stdout, `OK wrote ${bytesWritten} bytes to ${values.out}`); } else { await writeBodyToStdout(res.body, stdoutStream); } diff --git a/commands/secret.js b/commands/secret.js index ba530c6..4c99c09 100644 --- a/commands/secret.js +++ b/commands/secret.js @@ -7,11 +7,12 @@ import { CliError, confirmAction, defineCliOption, - escapeTerminalText, formatHelp, isMain, optionHelp, - readTtyLine, + readSecretStdin, + writeJsonOr, + writeStatusLine, } from "../lib/common.js"; const SECRET_OPTIONS = [ @@ -66,20 +67,17 @@ async function runSecret({ values, positionals, context }) { if (subcommand === "list") { const body = await context.fetchJson(context.nsUrl(...secretPath), { headers }, "list"); - if (values.json) { - stdout(JSON.stringify(body, null, 2)); - return; - } + if (writeJsonOr(values.json, body, stdout)) return; const keys = Array.isArray(body.keys) ? body.keys : []; - if (keys.length === 0) stdout("(no secrets)"); - else for (const k of keys) stdout(escapeTerminalText(String(k))); + if (keys.length === 0) writeStatusLine(stdout, "(no secrets)"); + else for (const k of keys) writeStatusLine(stdout, String(k)); return; } if (subcommand === "put") { if (!keyArg) throw new CliError("put requires a KEY argument"); // Empty string is a set secret (≠ unset), matching wrangler. - const value = await readStdin(stdin, { + const value = await readSecretStdin(stdin, { prompt: `Enter secret value for ${scopeLabel}/${keyArg} (input hidden): `, stderr, }); @@ -88,20 +86,17 @@ async function runSecret({ values, positionals, context }) { headers: { ...headers, "content-type": "application/json" }, body: JSON.stringify({ value }), }, "put"); - if (values.json) { - stdout(JSON.stringify(body, null, 2)); - return; - } + if (writeJsonOr(values.json, body, stdout)) return; const warning = pickPromoteWarning(body); if (warning) { - stdout(`⚠ ${scopeLabel}/${keyArg} set — stored, reload deferred: ${escapeTerminalText(warning.reason)}`); - stdout(` next pickup: ${escapeTerminalText(warning.nextPickup)}`); + writeStatusLine(stdout, `⚠ ${scopeLabel}/${keyArg} set — stored, reload deferred: ${warning.reason}`); + writeStatusLine(stdout, ` next pickup: ${warning.nextPickup}`); } else if (hasWorker && body.version) { - stdout(`✓ ${scopeLabel}/${keyArg} set — promoted ${escapeTerminalText(body.previousVersion)} → ${escapeTerminalText(body.version)}`); + writeStatusLine(stdout, `✓ ${scopeLabel}/${keyArg} set — promoted ${body.previousVersion} → ${body.version}`); } else if (hasWorker) { - stdout(`✓ ${scopeLabel}/${keyArg} set — stored; will apply on first deploy`); + writeStatusLine(stdout, `✓ ${scopeLabel}/${keyArg} set — stored; will apply on first deploy`); } else { - stdout(`✓ ${scopeLabel}/${keyArg} set — effect on next natural cold-load`); + writeStatusLine(stdout, `✓ ${scopeLabel}/${keyArg} set — effect on next natural cold-load`); } return; } @@ -119,23 +114,20 @@ async function runSecret({ values, positionals, context }) { method: "DELETE", headers, }, "delete"); - if (values.json) { - stdout(JSON.stringify(body, null, 2)); - return; - } + if (writeJsonOr(values.json, body, stdout)) return; const warning = pickPromoteWarning(body); - if (!body.deleted && !warning) stdout(`(${keyArg} was not set)`); + if (!body.deleted && !warning) writeStatusLine(stdout, `(${keyArg} was not set)`); else if (warning && body.deleted) { - stdout(`⚠ ${scopeLabel}/${keyArg} deleted — stored, reload deferred: ${escapeTerminalText(warning.reason)}`); - stdout(` next pickup: ${escapeTerminalText(warning.nextPickup)}`); + writeStatusLine(stdout, `⚠ ${scopeLabel}/${keyArg} deleted — stored, reload deferred: ${warning.reason}`); + writeStatusLine(stdout, ` next pickup: ${warning.nextPickup}`); } else if (warning) { - stdout(`⚠ ${scopeLabel}/${keyArg} unchanged — reload deferred: ${escapeTerminalText(warning.reason)}`); - stdout(` next pickup: ${escapeTerminalText(warning.nextPickup)}`); + writeStatusLine(stdout, `⚠ ${scopeLabel}/${keyArg} unchanged — reload deferred: ${warning.reason}`); + writeStatusLine(stdout, ` next pickup: ${warning.nextPickup}`); } - else if (hasWorker && body.version) stdout(`✓ ${scopeLabel}/${keyArg} deleted — promoted ${escapeTerminalText(body.previousVersion)} → ${escapeTerminalText(body.version)}`); - else if (hasWorker) stdout(`✓ ${scopeLabel}/${keyArg} deleted — no active worker version to promote`); - else stdout(`✓ ${scopeLabel}/${keyArg} deleted — effect on next natural cold-load`); + else if (hasWorker && body.version) writeStatusLine(stdout, `✓ ${scopeLabel}/${keyArg} deleted — promoted ${body.previousVersion} → ${body.version}`); + else if (hasWorker) writeStatusLine(stdout, `✓ ${scopeLabel}/${keyArg} deleted — no active worker version to promote`); + else writeStatusLine(stdout, `✓ ${scopeLabel}/${keyArg} deleted — effect on next natural cold-load`); return; } @@ -147,25 +139,6 @@ function pickPromoteWarning(body) { return warnings.find((w) => w?.kind === "promote_failed") || null; } -// Pipe/redirect mode reads until EOF so multi-line secrets work. TTY mode -// reads one line so typing a value and pressing Enter submits immediately. -/** - * @param {{ isTTY?: boolean, setEncoding: (encoding: string) => void, setRawMode?: (mode: boolean) => void, on: Function, off: Function, pause?: Function }} stdin - * @param {{ prompt?: string, stderr?: (text: string) => void }} [options] - */ -function readStdin(stdin, { prompt, stderr } = {}) { - // hidden: a secret value must never echo to the terminal or scrollback. - if (stdin.isTTY) return readTtyLine(stdin, { prompt, stderr, hidden: true }); - - return new Promise((resolve, reject) => { - let data = ""; - stdin.setEncoding("utf8"); - stdin.on("data", (chunk) => (data += chunk)); - stdin.on("end", () => resolve(data.replace(/\r?\n$/, ""))); - stdin.on("error", reject); - }); -} - function usageText() { return formatHelp({ usage: [ diff --git a/commands/token.js b/commands/token.js index dad49d5..8d3cc4f 100644 --- a/commands/token.js +++ b/commands/token.js @@ -12,13 +12,14 @@ import { escapeTerminalText, formatHelp, isMain, + maskToken, optionHelp, - readTtyLine, + readSecretStdin, resolveControlUrl, warnIfInsecureControlUrl, writeResult, + writeStatusLine, } from "../lib/common.js"; -import { maskToken } from "../lib/config-state.js"; import { isAdminAcceptableNs } from "../lib/ns-pattern.js"; import { fetchWhoami, namespaceFromPrincipal } from "../lib/whoami.js"; import { readTokenStore, tokenStorePath, writeTokenStore } from "../lib/token-store.js"; @@ -94,7 +95,7 @@ async function tokenSet({ values, context }) { // that sends the token. warnIfInsecureControlUrl(controlUrl, context.warn); - const token = (await readStdin(context.stdin, { + const token = (await readSecretStdin(context.stdin, { prompt: `Token for ${ns} @ ${controlUrl} (input hidden): `, stderr: context.stderr, })).trim(); @@ -141,11 +142,9 @@ async function tokenSet({ values, context }) { const becameDefault = Boolean(values.default) || wasEmpty; if (becameDefault) store.defaultNs = ns; writeTokenStore(storePath, store); - context.stdout( - `Stored token for ${escapeTerminalText(ns)} @ ${escapeTerminalText(controlUrl)} (${escapeTerminalText(maskToken(token))}).` - ); + writeStatusLine(context.stdout, `Stored token for ${ns} @ ${controlUrl} (${maskToken(token)}).`); if (becameDefault) { - context.stdout(`${escapeTerminalText(ns)} is now the default namespace (used when --ns is omitted).`); + writeStatusLine(context.stdout, `${ns} is now the default namespace (used when --ns is omitted).`); } } @@ -159,7 +158,7 @@ function tokenUse({ context, nsArg }) { } store.defaultNs = ns; writeTokenStore(storePath, store); - context.stdout(`Default namespace set to ${escapeTerminalText(ns)} (used when --ns is omitted).`); + writeStatusLine(context.stdout, `Default namespace set to ${ns} (used when --ns is omitted).`); } function tokenList({ values, context }) { @@ -189,9 +188,7 @@ function tokenRemove({ context }) { if (remaining.length === 1) store.defaultNs = remaining[0]; else if (store.defaultNs === ns) store.defaultNs = null; writeTokenStore(storePath, store); - context.stdout( - `Removed the stored token for ${escapeTerminalText(ns)}. This does not revoke it on the control plane.` - ); + writeStatusLine(context.stdout, `Removed the stored token for ${ns}. This does not revoke it on the control plane.`); } // Returns an array of lines; writeResult escapes each one at its choke point. @@ -205,23 +202,6 @@ function formatTokenList(rows) { return lines; } -// Read a single line: a TTY prompts without echo; a pipe is read to EOF. -/** - * @param {{ isTTY?: boolean, setEncoding: (encoding: string) => void, setRawMode?: (mode: boolean) => void, on: Function, off: Function, pause?: Function }} stdin - * @param {{ prompt?: string, stderr?: (text: string) => void }} [options] - */ -function readStdin(stdin, { prompt, stderr } = {}) { - // hidden: the token must never echo to the terminal or scrollback on a TTY. - if (stdin.isTTY) return readTtyLine(stdin, { prompt, stderr, hidden: true }); - return new Promise((resolve, reject) => { - let data = ""; - stdin.setEncoding("utf8"); - stdin.on("data", (chunk) => (data += chunk)); - stdin.on("end", () => resolve(data.replace(/\r?\n$/, ""))); - stdin.on("error", reject); - }); -} - function usageText() { return formatHelp({ usage: [ diff --git a/lib/common.js b/lib/common.js index 6b2f557..8f0138e 100644 --- a/lib/common.js +++ b/lib/common.js @@ -270,6 +270,14 @@ function firstNonEmptyString(...values) { return undefined; } +// A CLI flag counts as "set" only when it holds a non-empty string: an empty +// `--token ""` (or a boolean/missing flag) falls back to env, and the +// cross-origin guard / store gap-fill must tell "flag supplied" from "present +// but empty". +export function flagSet(values, name) { + return typeof values[name] === "string" && values[name].length > 0; +} + export function loadCliDotEnv( env = process.env, path = ".env", @@ -508,7 +516,7 @@ export async function confirmAction({ } = {}) { if (yes) return; if (!stdin.isTTY) { - throw new CliError(`Refusing to ${action} without interactive confirmation. Pass --yes to confirm.`); + throw new CliError(`Refusing to ${escapeTerminalText(action)} without interactive confirmation. Pass --yes to confirm.`); } const answer = await readTtyLine(stdin, { prompt, stderr }); @@ -582,13 +590,35 @@ export function readTtyLine(stdin, { prompt, stderr, hidden = false } = {}) { return failClosed(); } } - if (prompt && stderr) stderr(prompt); + // Single write point for every prompt: escape it here so callers can + // interpolate raw values (ns, keys, URLs) without per-field escaping, and a + // control byte in a user-supplied arg can't reach the terminal via a prompt. + if (prompt && stderr) stderr(escapeTerminalText(prompt)); stdin.on("data", onData); stdin.on("end", onEnd); stdin.on("error", onError); }); } +// Read a single secret value from stdin: a TTY prompts with hidden (raw-mode, +// non-echoing) input; a pipe/redirect is read to EOF with one trailing newline +// trimmed, so `printf '%s' "$SECRET" | …` works. Shared by `wdl token set` and +// `wdl secret put`. +/** + * @param {{ isTTY?: boolean, setEncoding: (encoding: string) => void, setRawMode?: (mode: boolean) => void, on: Function, off: Function, pause?: Function }} stdin + * @param {{ prompt?: string, stderr?: (text: string) => void }} [options] + */ +export function readSecretStdin(stdin, { prompt, stderr } = {}) { + if (stdin.isTTY) return readTtyLine(stdin, { prompt, stderr, hidden: true }); + return new Promise((resolve, reject) => { + let data = ""; + stdin.setEncoding("utf8"); + stdin.on("data", (chunk) => (data += chunk)); + stdin.on("end", () => resolve(data.replace(/\r?\n$/, ""))); + stdin.on("error", reject); + }); +} + export function parseDotEnvValue(value) { if (!value) return ""; const quote = value[0]; @@ -609,11 +639,26 @@ export function parseDotEnvValue(value) { return value.replace(/\s+#.*$/, ""); } +// Serialize a value into the double-quoted dialect — the inverse of +// parseDotEnvValue / unescapeDoubleQuoted, kept beside them so the round-trip +// invariant stays in one place. Backslash is escaped first so a value with +// quotes / newlines / tabs survives a read→write→read round trip. Used by the +// token store writer (a project `.env` is only ever read, never written). +export function quoteValue(value) { + const escaped = String(value) + .replaceAll("\\", "\\\\") + .replaceAll('"', '\\"') + .replaceAll("\n", "\\n") + .replaceAll("\r", "\\r") + .replaceAll("\t", "\\t"); + return `"${escaped}"`; +} + // Single-pass unescape for the double-quoted dialect. A left-to-right scan is // required: chaining replaceAll("\\n", "\n") before replaceAll("\\\\", "\\") // would turn an escaped backslash followed by a literal "n" (stored as "\\n") // into a newline, corrupting any value that legitimately contains a backslash -// (e.g. a token). Mirrors quoteValue in lib/token-store.js. +// (e.g. a token). The inverse of quoteValue above. function unescapeDoubleQuoted(s) { let out = ""; for (let i = 0; i < s.length; i += 1) { @@ -771,6 +816,16 @@ export function escapeTerminalText(value) { return escapeControlChars(text, false); } +// Mask a token for display: `****` plus the last 4 chars, but only when that +// reveals at most half the token (short tokens show no suffix). "(unset)" for +// an empty/absent token. +export function maskToken(token) { + if (!token) return "(unset)"; + const text = String(token); + const suffix = text.length <= 8 ? "" : text.slice(-4); + return `****${suffix}`; +} + // Shared escape walk. keepLayout leaves tab/newline intact for human output. function escapeControlChars(text, keepLayout) { let out = ""; @@ -818,9 +873,15 @@ export function shellSingleQuote(value) { return `'${String(value).replaceAll("'", `'\\''`)}'`; } +// Canonical machine-JSON output: one place defines the format so the JSON from +// writeResult / writeJsonOr can't drift apart. +export function writeJson(stdout, body) { + stdout(JSON.stringify(body, null, 2)); +} + export function writeResult(json, body, format, stdout) { if (json) { - stdout(JSON.stringify(body, null, 2)); + writeJson(stdout, body); return; } // Choke point: every human-readable command output flows through here, so @@ -829,6 +890,25 @@ export function writeResult(json, body, format, stdout) { for (const line of format()) stdout(escapeTerminalLines(line)); } +// Choke point for one-off human status lines — the non-JSON analogue of +// writeResult. Callers interpolate raw values and this escapes the assembled +// line once, so no interpolated field can be forgotten. escapeTerminalText (not +// -Lines): a status line is single-line, so an embedded newline is neutralized +// rather than allowed to split the line. +export function writeStatusLine(stdout, line) { + stdout(escapeTerminalText(line)); +} + +// The `--json` half of a compound command's output: emit the body as machine +// JSON (left raw, like writeResult) and return true so the caller early-returns; +// return false otherwise to let it write human status lines. Keeps the json +// branch out of every subcommand. +export function writeJsonOr(json, body, stdout) { + if (!json) return false; + writeJson(stdout, body); + return true; +} + export function encodePath(segment) { return encodeURIComponent(segment); } diff --git a/lib/config-state.js b/lib/config-state.js index d5c673d..b7313c5 100644 --- a/lib/config-state.js +++ b/lib/config-state.js @@ -1,5 +1,5 @@ import path from "node:path"; -import { CliError, loadCliControlEnv, resolveControlUrl, resolveNamespace } from "./common.js"; +import { CliError, flagSet, loadCliControlEnv, maskToken, resolveControlUrl, resolveNamespace } from "./common.js"; import { readTokenStore, tokenStorePath } from "./token-store.js"; /** @@ -37,8 +37,8 @@ export function resolveCliConfigState({ values = {}, env = process.env, cwd = pr loadCliControlEnv(workingEnv, { dotenvPath: resolvedDotenvPath, nsFromFlag: values.ns, - tokenFromFlag: typeof values.token === "string" && values.token.length > 0, - controlUrlFromFlag: typeof values["control-url"] === "string" && values["control-url"].length > 0, + tokenFromFlag: flagSet(values, "token"), + controlUrlFromFlag: flagSet(values, "control-url"), protectedKeys, readStore: (e) => readTokenStore(tokenStorePath(e)), onLoad: recordDotenvLoad, @@ -96,7 +96,7 @@ function sourceForNamespace(values, env, sources) { } function sourceForControlUrl(values, env, sources) { - if (typeof values["control-url"] === "string" && values["control-url"].length > 0) return "--control-url"; + if (flagSet(values, "control-url")) return "--control-url"; if (typeof env.CONTROL_URL === "string" && env.CONTROL_URL.length > 0) return sources.get("CONTROL_URL") || "CONTROL_URL env"; return null; } @@ -106,7 +106,7 @@ function tokenValue(values, env) { } function sourceForToken(values, env, sources) { - if (typeof values.token === "string" && values.token.length > 0) return "--token"; + if (flagSet(values, "token")) return "--token"; if (typeof env.ADMIN_TOKEN === "string" && env.ADMIN_TOKEN.length > 0) return sources.get("ADMIN_TOKEN") || "ADMIN_TOKEN env"; return null; } @@ -117,11 +117,3 @@ function firstString(...values) { } return null; } - -export function maskToken(token) { - if (!token) return "(unset)"; - const text = String(token); - // Only show a suffix when it reveals at most half of the token. - const suffix = text.length <= 8 ? "" : text.slice(-4); - return `****${suffix}`; -} diff --git a/lib/token-store.js b/lib/token-store.js index 392daeb..c99c7a7 100644 --- a/lib/token-store.js +++ b/lib/token-store.js @@ -1,7 +1,7 @@ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; -import { CliError, parseDotEnvSection, parseDotEnvValue } from "./common.js"; +import { CliError, parseDotEnvSection, parseDotEnvValue, quoteValue } from "./common.js"; // The global credential store is the lowest-precedence layer of the same // credential model as a project `.env`: a `dotenv`/INI-subset file in the @@ -127,17 +127,3 @@ export function writeTokenStore(storePath, store) { } writeFileSync(storePath, lines.join("\n"), { mode: 0o600 }); } - -// Escape for the double-quoted dialect: backslash first, then the rest, so a -// value with quotes/newlines/tabs survives a read→write→read round trip. -function quoteValue(value) { - const escaped = String(value) - .replaceAll("\\", "\\\\") - .replaceAll('"', '\\"') - .replaceAll("\n", "\\n") - .replaceAll("\r", "\\r") - .replaceAll("\t", "\\t"); - return `"${escaped}"`; -} - -export const __test__ = { quoteValue, STORE_KEYS }; diff --git a/tests/unit/cli-config-doctor.test.js b/tests/unit/cli-config-doctor.test.js index 2636f95..10dde9c 100644 --- a/tests/unit/cli-config-doctor.test.js +++ b/tests/unit/cli-config-doctor.test.js @@ -7,7 +7,8 @@ import { runConfigCommand } from "../../commands/config.js"; import { runDoctorCommand } from "../../commands/doctor.js"; import { runWhoamiCommand } from "../../commands/whoami.js"; import { main as wdlMain } from "../../bin/wdl.js"; -import { maskToken, resolveCliConfigState } from "../../lib/config-state.js"; +import { resolveCliConfigState } from "../../lib/config-state.js"; +import { maskToken } from "../../lib/common.js"; import { cliCompatibility, compareSemver } from "../../lib/whoami.js"; import { response } from "./helpers.js"; diff --git a/tests/unit/cli-lifecycle.test.js b/tests/unit/cli-lifecycle.test.js index cfd6ed8..3b31a0a 100644 --- a/tests/unit/cli-lifecycle.test.js +++ b/tests/unit/cli-lifecycle.test.js @@ -15,12 +15,15 @@ import { runWorkflowsCommand } from "../../commands/workflows.js"; import { main as wdlMain } from "../../bin/wdl.js"; import { CliError, + confirmAction, loadCliControlEnv, loadCliDotEnv, readJsonOrFail, resolveControlContext, resolveControlUrl, resolveNamespace, + writeJsonOr, + writeStatusLine, } from "../../lib/common.js"; import { LONG_CONTROL_TIMEOUT_MS, @@ -1081,6 +1084,50 @@ test("secret put reads stdin, trims one newline, and encodes key", async () => { assert.deepEqual(lines, ["✓ demo (ns)/KEY/ONE set — effect on next natural cold-load"]); }); +test("writeStatusLine escapes terminal control bytes in the assembled line", () => { + const lines = []; + writeStatusLine((l) => lines.push(l), `ok ${String.fromCharCode(27)}[2J done`); + assert.equal(lines.length, 1); + assert.doesNotMatch(lines[0], new RegExp(String.fromCharCode(27)), "raw ESC must not pass through"); +}); + +test("writeJsonOr emits JSON and reports handled, or defers to the human path", () => { + const out = []; + assert.equal(writeJsonOr(true, { a: 1 }, (l) => out.push(l)), true); + assert.equal(out[0], JSON.stringify({ a: 1 }, null, 2)); + out.length = 0; + assert.equal(writeJsonOr(false, { a: 1 }, (l) => out.push(l)), false); + assert.equal(out.length, 0, "nothing written when not json"); +}); + +test("confirmAction escapes terminal controls in its refusal message", async () => { + const esc = String.fromCharCode(27); + await assert.rejects( + () => confirmAction({ stdin: /** @type {any} */ ({ isTTY: false }), action: `delete ${esc}[2J thing` }), + (err) => { + assert.doesNotMatch(/** @type {Error} */ (err).message, new RegExp(esc), "raw ESC must not be in the refusal error"); + assert.match(/** @type {Error} */ (err).message, /Refusing to delete/); + return true; + } + ); +}); + +test("secret put escapes terminal controls from a raw keyArg in the status line", async () => { + const esc = String.fromCharCode(27); + const lines = []; + await runSecretCommand( + ["put", "--ns", "demo", "--scope", "ns", `KEY${esc}[2J`, "--control-url", "http://ctl.test"], + { + env: { ADMIN_TOKEN: "tok" }, + stdin: stdinFrom("v\n"), + stdout: (line) => lines.push(line), + controlFetch: async () => response({ deleted: false }), + } + ); + assert.equal(lines.length, 1); + assert.doesNotMatch(lines[0], new RegExp(esc), "raw ESC from keyArg must not reach stdout"); +}); + test("secret put reads one tty line without waiting for EOF", async () => { const calls = []; const prompts = []; @@ -1382,6 +1429,34 @@ test("r2 object get waits for stdout backpressure", async () => { assert.deepEqual(events, ["write:a", "drain", "write:b"]); }); +test("r2 object get --out escapes a control-char path in the success line", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-r2-out-escape-")); + try { + const esc = String.fromCharCode(27); + const outPath = path.join(dir, `file${esc}[2J.bin`); + const lines = []; + await runR2Command( + ["objects", "get", "--ns", "demo", "uploads", "file.txt", "--out", outPath, "--control-url", "http://ctl.test"], + { + env: { ADMIN_TOKEN: "tok" }, + stdout: (line) => lines.push(line), + controlFetch: async () => ({ + status: 200, + ok: true, + headers: {}, + body: Readable.from([Buffer.from("ab")]), + text: async () => "", + }), + } + ); + const out = lines.join("\n"); + assert.doesNotMatch(out, new RegExp(esc), "raw ESC from --out path must not reach stdout"); + assert.match(out, /OK wrote 2 bytes to/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("r2 object get, head, and delete reject blank keys", async () => { const deps = { env: { ADMIN_TOKEN: "tok", CONTROL_URL: "http://ctl.test" }, diff --git a/tests/unit/cli-token-store.test.js b/tests/unit/cli-token-store.test.js index 482a02e..d834b99 100644 --- a/tests/unit/cli-token-store.test.js +++ b/tests/unit/cli-token-store.test.js @@ -8,8 +8,8 @@ import { tokenStoreDir, tokenStorePath, writeTokenStore, - __test__, } from "../../lib/token-store.js"; +import { quoteValue } from "../../lib/common.js"; function withTempHome(fn) { const dir = mkdtempSync(path.join(tmpdir(), "wdl-token-store-")); @@ -200,6 +200,6 @@ test("readTokenStore ignores unknown keys and comments", () => { }); test("quoteValue escapes backslash before other sequences", () => { - assert.equal(__test__.quoteValue("a\\b"), '"a\\\\b"'); - assert.equal(__test__.quoteValue('q"q'), '"q\\"q"'); + assert.equal(quoteValue("a\\b"), '"a\\\\b"'); + assert.equal(quoteValue('q"q'), '"q\\"q"'); }); diff --git a/tests/unit/cli-token.test.js b/tests/unit/cli-token.test.js index 4ad84a4..54f36ec 100644 --- a/tests/unit/cli-token.test.js +++ b/tests/unit/cli-token.test.js @@ -5,7 +5,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { runTokenCommand } from "../../commands/token.js"; -import { loadCliControlEnv, readTtyLine } from "../../lib/common.js"; +import { loadCliControlEnv, readSecretStdin, readTtyLine } from "../../lib/common.js"; import { readTokenStore, tokenStorePath, writeTokenStore } from "../../lib/token-store.js"; import { response } from "./helpers.js"; @@ -441,6 +441,49 @@ test("readTtyLine fails closed when a TTY cannot hide input", async () => { ); }); +test("readSecretStdin reads a piped value to EOF, trimming one trailing newline", async () => { + const stdin = Object.assign(new EventEmitter(), { setEncoding() {} }); + queueMicrotask(() => { + stdin.emit("data", "sec"); + stdin.emit("data", "ret\n"); + stdin.emit("end"); + }); + assert.equal(await readSecretStdin(stdin), "secret"); +}); + +test("readSecretStdin trims only one trailing newline (multi-line value)", async () => { + const stdin = Object.assign(new EventEmitter(), { setEncoding() {} }); + queueMicrotask(() => { + stdin.emit("data", "a\nb\n\n"); + stdin.emit("end"); + }); + assert.equal(await readSecretStdin(stdin), "a\nb\n"); +}); + +test("readSecretStdin hides input on a TTY via raw mode", async () => { + const rawCalls = []; + const stdin = Object.assign(new EventEmitter(), { + isTTY: true, + setEncoding() {}, + setRawMode(v) { rawCalls.push(v); }, + pause() {}, + }); + queueMicrotask(() => { + stdin.emit("data", "tok"); + stdin.emit("data", "en\r"); + }); + assert.equal(await readSecretStdin(stdin, { stderr: () => {} }), "token"); + assert.deepEqual(rawCalls, [true, false], "raw mode (echo off) enabled, then restored"); +}); + +test("readTtyLine escapes terminal controls in the prompt at the write point", async () => { + const errs = []; + const stdin = Object.assign(new EventEmitter(), { setEncoding() {}, pause() {} }); + queueMicrotask(() => stdin.emit("data", "y\n")); + await readTtyLine(stdin, { prompt: `confirm ${String.fromCharCode(27)}[2J?`, stderr: (s) => errs.push(s) }); + assert.doesNotMatch(errs.join(""), new RegExp(String.fromCharCode(27)), "raw ESC from the prompt must not reach stderr"); +}); + // --- resolution integration (the global store as the lowest-precedence layer) --- test("loadCliControlEnv fills control URL and token from the store as a gap-filler", () => { From 93d45061f25cdc3182f233677f07cd2a7a0875ea Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Tue, 16 Jun 2026 08:47:07 +0800 Subject: [PATCH 09/13] Require explicit --ns for `token rm` and pin ws past a DoS advisory token rm took its namespace through resolveNamespace(), which falls back to the ambient WDL_NS. Since rm deletes and rewrites the credential store with no confirmation, a stray WDL_NS could make a bare `wdl token rm` silently delete a different namespace's token than the operator named. Take the namespace only from an explicit --ns (via flagSet); the convenience fallback stays for the non-destructive set/use subcommands. Pin ws to ^8.21.0 via overrides to clear GHSA-96hv-2xvq-fx4p (high) in the wrangler -> miniflare -> ws@8.20.1 chain that fails CI's `npm audit --audit-level=moderate`. The DoS needs miniflare's dev server (wrangler dev); wdl deploy only runs `wrangler deploy --dry-run` and never starts it. `npm audit fix --force` would downgrade wrangler 4 -> 3.107.3 (major, breaking), and even the latest wrangler still pins the vulnerable ws, so the override -- just past the vulnerable range, the only ws in the tree -- is the clean fix. Audit clean, suite green, a real dry-run still bundles. Co-Authored-By: Claude Opus 4.8 (1M context) --- commands/token.js | 13 +++++++++---- package-lock.json | 6 +++--- package.json | 3 ++- tests/unit/cli-token.test.js | 13 +++++++++++++ 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/commands/token.js b/commands/token.js index 8d3cc4f..aa39276 100644 --- a/commands/token.js +++ b/commands/token.js @@ -10,6 +10,7 @@ import { CliError, defineCliOption, escapeTerminalText, + flagSet, formatHelp, isMain, maskToken, @@ -64,7 +65,7 @@ async function runToken({ values, positionals, context }) { if (rest.length > 1) throw new CliError(usageText()); return tokenUse({ context, nsArg: rest[0] }); case "rm": - return tokenRemove({ context }); + return tokenRemove({ values, context }); default: throw new CliError(usageText()); } @@ -173,9 +174,13 @@ function tokenList({ values, context }) { writeResult(values.json, rows, () => formatTokenList(rows), context.stdout); } -function tokenRemove({ context }) { - const ns = context.resolveNamespace(); - if (!ns) throw new CliError("token rm requires --ns "); +function tokenRemove({ values, context }) { + // `rm` deletes and rewrites the store with no confirmation, so it takes the + // namespace only from an explicit --ns — never the ambient WDL_NS that the + // other subcommands fall back to. Otherwise a stray WDL_NS in the shell could + // silently delete a different namespace's stored token than the one named. + const ns = flagSet(values, "ns") ? values.ns : null; + if (!ns) throw new CliError("token rm requires an explicit --ns "); const storePath = tokenStorePath(context.env); const store = readTokenStore(storePath); if (!Object.hasOwn(store.namespaces, ns)) throw new CliError(`no stored token for namespace "${escapeTerminalText(ns)}"`); diff --git a/package-lock.json b/package-lock.json index 66c8b99..c8c6b54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2359,9 +2359,9 @@ } }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index f53b6ab..7d6873a 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "overrides": { "wrangler": { "esbuild": "^0.28.1" - } + }, + "ws": "^8.21.0" } } diff --git a/tests/unit/cli-token.test.js b/tests/unit/cli-token.test.js index 54f36ec..55c277c 100644 --- a/tests/unit/cli-token.test.js +++ b/tests/unit/cli-token.test.js @@ -351,6 +351,19 @@ test("token rm removes a stored namespace and errors when absent", async () => { }); }); +test("token rm requires an explicit --ns and ignores ambient WDL_NS", async () => { + await withTempXdg(async (xdg) => { + const p = tokenStorePath({ XDG_CONFIG_HOME: xdg }); + writeTokenStore(p, { defaultNs: "acme", namespaces: { acme: { ADMIN_TOKEN: "a" } } }); + + const { deps: d } = deps(xdg); + // A stray WDL_NS must NOT make a bare `rm` delete that namespace's token. + d.env.WDL_NS = "acme"; + await assert.rejects(() => runTokenCommand(["rm"], d), /requires an explicit --ns/); + assert.deepEqual(Object.keys(readTokenStore(p).namespaces), ["acme"], "store untouched"); + }); +}); + test("token rm of the default promotes a sole survivor, clears it when ambiguous", async () => { await withTempXdg(async (xdg) => { const p = tokenStorePath({ XDG_CONFIG_HOME: xdg }); From 31dd4218725d2721d77b677b109e96fcc0761207 Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Tue, 16 Jun 2026 08:59:38 +0800 Subject: [PATCH 10/13] Keep scrubbing legacy ADMIN_URL from the Wrangler child env Removing the ADMIN_URL alias also dropped it from CLI_DOTENV_KEYS, which is the same set wranglerChildEnv uses to strip WDL control-plane config before spawning Wrangler. So a user who still has a stale ADMIN_URL exported would now leak the control endpoint into the bundler and any build scripts Wrangler runs during deploy / doctor dry-runs -- a regression, since the alias used to be scrubbed alongside CONTROL_URL. Decouple the scrub denylist from the .env load allowlist: WRANGLER_SCRUB_KEYS is CLI_DOTENV_KEYS plus the legacy ADMIN_URL. The CLI no longer READS the alias, but dropping it as a config input must not turn it into a leak. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/common.js | 7 +++++++ lib/wrangler/command.js | 4 ++-- tests/unit/cli-deploy.test.js | 3 +++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/common.js b/lib/common.js index 8f0138e..d018cda 100644 --- a/lib/common.js +++ b/lib/common.js @@ -14,6 +14,13 @@ export const CLI_DOTENV_KEYS = new Set([ "WDL_NS", ]); +// Keys deleted from the Wrangler child env (lib/wrangler/command.js) so WDL +// control-plane config never reaches the bundler or the build scripts Wrangler +// runs. A superset of CLI_DOTENV_KEYS that also strips the legacy ADMIN_URL +// alias: it is no longer READ for config, but a user may still have it +// exported, and dropping an alias as an input must not turn it into a leak. +export const WRANGLER_SCRUB_KEYS = new Set([...CLI_DOTENV_KEYS, "ADMIN_URL"]); + export class CliError extends Error { constructor(message, exitCode = 1) { super(message); diff --git a/lib/wrangler/command.js b/lib/wrangler/command.js index c1cd402..1dc4b7c 100644 --- a/lib/wrangler/command.js +++ b/lib/wrangler/command.js @@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { CLI_DOTENV_KEYS, CliError } from "../common.js"; +import { WRANGLER_SCRUB_KEYS, CliError } from "../common.js"; const CLI_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); export const MIN_WRANGLER_MAJOR = 4; @@ -83,7 +83,7 @@ export function checkWranglerVersion({ execFile = execFileSync, cwd, env, wrangl export function wranglerChildEnv(env) { const childEnv = { ...env, CLOUDFLARE_API_TOKEN: "dry-run-dummy" }; - for (const key of CLI_DOTENV_KEYS) { + for (const key of WRANGLER_SCRUB_KEYS) { delete childEnv[key]; } return childEnv; diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index 539ce77..2f2dbb8 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -1366,6 +1366,9 @@ test("wranglerChildEnv strips WDL control-plane environment", () => { CONTROL_CONNECT_HOST: "ctl.connect.example", CONTROL_URL: "https://ctl.example", WDL_NS: "tenant", + // Legacy alias the CLI no longer reads, but must still scrub so a stale + // export does not leak the control endpoint into the bundler. + ADMIN_URL: "https://legacy-admin.example", CLOUDFLARE_API_TOKEN: "real-cloudflare-token", PATH: "/bin", KEEP_ME: "ok", From da74f66e45b8ee4c3c69cc6910c2291b6cbce6cd Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Tue, 16 Jun 2026 13:37:54 +0800 Subject: [PATCH 11/13] Require explicit --ns for `token set` and `token use` too Follow-up to 93d4506: the same ambient-WDL_NS hazard applies to the other two store-mutating subcommands. set/use/rm all name which global store entry to create, switch, or delete, so that target must come from an explicit --ns/positional, not a WDL_NS a user may have exported for an unrelated deploy. - set fell back to resolveNamespace(), so `token set --control-url ...` with WDL_NS exported would store (and on an empty store, default to) that namespace despite usage documenting --ns as required. - use fell back too, so a bare `token use` would silently switch the default to WDL_NS -- pointless as well as surprising, since WDL_NS already overrides the store default at resolution time. Both now take the namespace explicitly (via flagSet; use still accepts its documented positional). `token` also gets its own --ns help text, since the shared preset still advertised "(env: WDL_NS)", which no longer applies. Also de-staled rm's comment. Tests assert a stray WDL_NS cannot make a bare set/use mutate the store. Co-Authored-By: Claude Opus 4.8 (1M context) --- commands/token.js | 24 +++++++++++++++--------- tests/unit/cli-token.test.js | 26 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/commands/token.js b/commands/token.js index aa39276..0ccd085 100644 --- a/commands/token.js +++ b/commands/token.js @@ -28,7 +28,9 @@ import { readTokenStore, tokenStorePath, writeTokenStore } from "../lib/token-st const TOKEN_OPTIONS = [ defineCliOption("label", { type: "string" }, "--label ", "Human label shown by `wdl token list` (set)."), defineCliOption("default", { type: "boolean" }, "--default", "Make this the default namespace, used when --ns is omitted (set)."), - "ns", + // Custom ns option: set/use/rm mutate the global store and ignore ambient + // WDL_NS, so the shared preset's "(env: WDL_NS)" wording would mislead here. + defineCliOption("ns", { type: "string" }, "--ns ", "Namespace for set/use/rm (required; ignores WDL_NS)."), // `endpoint`, not `control`: the token is read from stdin, never a --token flag. "endpoint", // Custom json option: `list --json` prints the local store, not a control @@ -63,7 +65,7 @@ async function runToken({ values, positionals, context }) { return tokenList({ values, context }); case "use": if (rest.length > 1) throw new CliError(usageText()); - return tokenUse({ context, nsArg: rest[0] }); + return tokenUse({ values, context, nsArg: rest[0] }); case "rm": return tokenRemove({ values, context }); default: @@ -72,7 +74,10 @@ async function runToken({ values, positionals, context }) { } async function tokenSet({ values, context }) { - const ns = context.resolveNamespace(); + // set/use/rm mutate the global store, so they name the target namespace from + // an explicit --ns only -- never the ambient WDL_NS a user may have exported + // for an unrelated command. + const ns = flagSet(values, "ns") ? values.ns : null; if (!ns) throw new CliError("token set requires --ns "); // The namespace becomes a `[section]` key in the store file, so it must match // the same grammar store/.env sections use (tenant namespaces plus operator- @@ -149,8 +154,11 @@ async function tokenSet({ values, context }) { } } -function tokenUse({ context, nsArg }) { - const ns = nsArg || context.resolveNamespace(); +function tokenUse({ values, context, nsArg }) { + // For `use` the WDL_NS fallback is also pointless: it already overrides the + // store default at resolution time, so inheriting it here would only reswitch + // the default to a namespace the user did not name. + const ns = nsArg || (flagSet(values, "ns") ? values.ns : null); if (!ns) throw new CliError("token use requires a namespace: wdl token use "); const storePath = tokenStorePath(context.env); const store = readTokenStore(storePath); @@ -175,10 +183,8 @@ function tokenList({ values, context }) { } function tokenRemove({ values, context }) { - // `rm` deletes and rewrites the store with no confirmation, so it takes the - // namespace only from an explicit --ns — never the ambient WDL_NS that the - // other subcommands fall back to. Otherwise a stray WDL_NS in the shell could - // silently delete a different namespace's stored token than the one named. + // For `rm` the stakes are highest -- it deletes and rewrites with no + // confirmation -- so it likewise takes an explicit --ns only. const ns = flagSet(values, "ns") ? values.ns : null; if (!ns) throw new CliError("token rm requires an explicit --ns "); const storePath = tokenStorePath(context.env); diff --git a/tests/unit/cli-token.test.js b/tests/unit/cli-token.test.js index 55c277c..76a7eec 100644 --- a/tests/unit/cli-token.test.js +++ b/tests/unit/cli-token.test.js @@ -364,6 +364,32 @@ test("token rm requires an explicit --ns and ignores ambient WDL_NS", async () = }); }); +test("token set requires an explicit --ns and ignores ambient WDL_NS", async () => { + await withTempXdg(async (xdg) => { + // Default controlFetch authenticates the token as namespace "acme", so + // without the guard a WDL_NS=acme would let this store under acme. + const { deps: d } = deps(xdg, { stdin: stdinFrom("tok\n") }); + d.env.WDL_NS = "acme"; + await assert.rejects( + () => runTokenCommand(["set", "--control-url", "https://api.example"], d), + /requires --ns/ + ); + assert.deepEqual(readTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg })), { defaultNs: null, namespaces: {} }); + }); +}); + +test("token use requires an explicit namespace and ignores ambient WDL_NS", async () => { + await withTempXdg(async (xdg) => { + const p = tokenStorePath({ XDG_CONFIG_HOME: xdg }); + writeTokenStore(p, { defaultNs: "demo", namespaces: { acme: { ADMIN_TOKEN: "a" }, demo: { ADMIN_TOKEN: "d" } } }); + const { deps: d } = deps(xdg); + // A stray WDL_NS must NOT make a bare `use` switch the default. + d.env.WDL_NS = "acme"; + await assert.rejects(() => runTokenCommand(["use"], d), /requires a namespace/); + assert.equal(readTokenStore(p).defaultNs, "demo", "default unchanged"); + }); +}); + test("token rm of the default promotes a sole survivor, clears it when ambiguous", async () => { await withTempXdg(async (xdg) => { const p = tokenStorePath({ XDG_CONFIG_HOME: xdg }); From 738ba3b2aa6e1cd842629d8ca01d0d972ea7566c Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Tue, 16 Jun 2026 22:07:55 +0800 Subject: [PATCH 12/13] Recommend the token store across credential docs and the missing-token error docs/token.md pointed readers to deploy.md for ADMIN_TOKEN/CONTROL_URL precedence, but the deploy docs still stopped the chain at .env, said --ns is optional only with WDL_NS, and called a project .env the "recommended path" -- so readers and generated-project agents kept the token in .env files and never met the store or default namespace. AGENTS.md's Documentation Sync Policy makes the bilingual pairs + GUIDE authoritative. - deploy.md/-zh: flip the recommendation -- the `wdl token` store is now the recommended setup (0600, validated, out of every repo, default ns => no --ns); a project .env is the per-repo alternative; CI injects env vars. Extend the precedence chain with the store as the lowest layer; note --ns is also optional with a stored default. - GUIDE.md/-zh: mark the managed store as the recommended setup. - README.md/-zh: include the store in the precedence aside; split the `wdl token` command synopsis per subcommand so it no longer implies --ns is optional for set/rm (it is required for set/rm, positional/--ns for use). - token.md/-zh: state that set/use/rm are the exception to the namespace chain -- explicit --ns only, never ambient WDL_NS. - deploy.md/-zh, GUIDE.md/-zh: the "Missing admin token" troubleshooting row now leads with `wdl token set`. - lib/common.js, lib/whoami.js: the "Missing admin token" error itself now leads with `wdl token set` (recommended), then --token / ADMIN_TOKEN -- the surface a user hits directly. Existing tests match the "Missing admin token" prefix, so they still pass. The wdl-deploy skill and templates/AGENTS.md already defer to deploy.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- GUIDE-zh.md | 4 ++-- GUIDE.md | 7 ++++--- README-zh.md | 5 +++-- README.md | 6 ++++-- docs/deploy-zh.md | 16 ++++++++++------ docs/deploy.md | 39 +++++++++++++++++++++++++++------------ docs/token-zh.md | 2 ++ docs/token.md | 5 +++++ lib/common.js | 2 +- lib/whoami.js | 2 +- 10 files changed, 59 insertions(+), 29 deletions(-) diff --git a/GUIDE-zh.md b/GUIDE-zh.md index 13dc88c..4212016 100644 --- a/GUIDE-zh.md +++ b/GUIDE-zh.md @@ -83,7 +83,7 @@ ADMIN_TOKEN= CLI 只会从 `.env` 读取 WDL 平台变量:`ADMIN_TOKEN`、`CONTROL_URL`、`CONTROL_CONNECT_HOST`、`WDL_NS`。优先级是 `CLI flag > shell/CI env > [resolved-ns] section > base .env`,都没有提供时命令直接报错——没有内置默认值。namespace 解析顺序是 `--ns`,然后是 shell 或 base `.env` 里的 `WDL_NS`。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。 -这些凭证也可以来自托管存储,而不是 shell 或 `.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 ` 切换默认。详见 [token-zh.md](./docs/token-zh.md)。 用 `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` 是否可达。当 control plane 暴露 `/whoami` 时,`doctor` 可以发现 token 是否有效、principal namespace、platform version 和 CLI compatibility;更细的 capability 检查仍需要额外的 control endpoint。 @@ -766,7 +766,7 @@ wdl tail hello | 现象 | 可能原因 | 检查方式 | | --- | --- | --- | -| `Missing admin token` | 没有提供 tenant token | 设置 `ADMIN_TOKEN`,或传 `--token` | +| `Missing admin token` | 没有提供 tenant token | 运行 `wdl token set --ns --control-url `(推荐),或设 `ADMIN_TOKEN` / 传 `--token` | | `wrangler build failed` | Wrangler 无法打包 Worker 项目 | 在 Worker 项目目录执行 `npx wrangler deploy --dry-run`,先修本地构建或配置错误 | | deploy 成功但 promote 失败 | route、自定义 host 或 binding 在 promote 阶段校验失败 | 确认自定义 host 已为你的 namespace 开通,service binding 目标存在 | | Worker URL 返回 404 | URL 形态或 worker name 不对 | 使用 `https://.//`,不要漏掉 worker name 这一段路径 | diff --git a/GUIDE.md b/GUIDE.md index caf2a71..cace7bc 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -112,8 +112,9 @@ 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. -These credentials can also come from a managed store instead of your shell or a -`.env`: `wdl token set --ns --control-url ` reads the token with +The recommended setup keeps these credentials in a managed store rather than a +shell export or a project `.env`: `wdl token set --ns --control-url ` +reads the token with hidden input, validates it against `/whoami`, and stores it under the namespace in `~/.config/wdl/credentials` (so it never lands in shell history or a project file). The store is the lowest-precedence layer — flags, shell env, and a @@ -1003,7 +1004,7 @@ wdl tail hello | Symptom | Likely cause | What to check | | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| `Missing admin token` | No tenant token was provided | Set `ADMIN_TOKEN` or pass `--token` | +| `Missing admin token` | No tenant token was provided | Run `wdl token set --ns --control-url ` (recommended), set `ADMIN_TOKEN`, or pass `--token` | | `wrangler build failed` | Wrangler could not bundle the Worker project | Run `npx wrangler deploy --dry-run` inside the Worker project and fix local build/config errors | | Deploy succeeds but promote fails | Route, custom host, or binding validation failed at promotion time | Check that custom hosts are enabled for your namespace and service-binding targets exist | | Worker URL returns 404 | URL shape or worker name is wrong | Use `https://.//`; include the worker name path segment | diff --git a/README-zh.md b/README-zh.md index fa50233..7284b08 100644 --- a/README-zh.md +++ b/README-zh.md @@ -60,7 +60,7 @@ wdl tail hello # 边访问 URL 边看实时日志 Worker 此时位于 `https://./hello/`。 -凭证也可以放进带命名空间分段的 `.env` 文件——复制 [`.env.example`](https://github.com/wdl-dev/cli/blob/main/.env.example),来源优先级(flag 高于 shell env,高于 `.env`)见 [docs/deploy.md](https://github.com/wdl-dev/cli/blob/main/docs/deploy.md)。 +凭证也可以放进带命名空间分段的 `.env` 文件——复制 [`.env.example`](https://github.com/wdl-dev/cli/blob/main/.env.example),来源优先级(flag 高于 shell env,高于 `.env`,高于 `wdl token` store)见 [docs/deploy.md](https://github.com/wdl-dev/cli/blob/main/docs/deploy.md)。 ## 命令 @@ -70,7 +70,8 @@ wdl deploy [--ns ] [--env ] [--verbose] wdl tail [...] [--ns ] [--raw] wdl workers [--ns ] wdl secret (--worker | --scope ns) [KEY] [--json] -wdl token [--ns ] [--control-url ] [--label ] [--default] +wdl token set --ns [--control-url ] [--label ] [--default] +wdl token list [--json] / wdl token use / wdl token rm --ns wdl d1 ... wdl r2 buckets list / wdl r2 objects ... wdl workflows ... diff --git a/README.md b/README.md index 5d9c7cd..fcbb6b8 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,8 @@ The worker is now at `https://./hello/`. Credentials can also live in a `.env` file with per-namespace sections — copy [`.env.example`](https://github.com/wdl-dev/cli/blob/main/.env.example) and see [docs/deploy.md](https://github.com/wdl-dev/cli/blob/main/docs/deploy.md) for -source precedence (flags beat shell env, which beats `.env`). +source precedence (flags beat shell env, which beats `.env`, which beats the +`wdl token` store). ## Commands @@ -101,7 +102,8 @@ wdl deploy [--ns ] [--env ] [--verbose] wdl tail [...] [--ns ] [--raw] wdl workers [--ns ] wdl secret (--worker | --scope ns) [KEY] [--json] -wdl token [--ns ] [--control-url ] [--label ] [--default] +wdl token set --ns [--control-url ] [--label ] [--default] +wdl token list [--json] / wdl token use / wdl token rm --ns wdl d1 ... wdl r2 buckets list / wdl r2 objects ... wdl workflows ... diff --git a/docs/deploy-zh.md b/docs/deploy-zh.md index 38fd47d..babbe4f 100644 --- a/docs/deploy-zh.md +++ b/docs/deploy-zh.md @@ -16,7 +16,7 @@ wrangler 解析顺序是 `WDL_WRANGLER_BIN`、Worker 项目本地 wrangler、CLI 下面例子里,把 `wdl` 当作占位符,替换成你解析出的实际形式。 -## 凭证 —— 一次性配置 `.env` +## 凭证 —— 一次性配置 CLI 需要三个值: @@ -26,11 +26,15 @@ CLI 需要三个值: | `WDL_NS` | 租户命名空间,例如 `acme`、`demo-prod`。 | | `CONTROL_URL` | 控制面地址,由运维方提供(例如 `https://api.wdl.dev`)。CLI 没有内置默认值,必须配置。 | -**推荐路径(每个仓库一次性配置):** 复制 `.env.example` 到 `.env`,填写 `[]` 段。CLI 只读取运行 `wdl` 时所在目录下的 `./.env`(不会向上层目录查找),所以要在放置 `.env` 的目录里执行 `wdl`。之后命令只需要 `--ns `(或 `WDL_NS` 环境变量),token 不会再出现在 shell 历史中。 +**推荐路径:** `wdl token set --ns --control-url ` 在隐藏提示里读取 token、调 `/whoami` 校验后以 `0600` 存入 `~/.config/wdl/credentials` —— 它不会落在任何项目文件或 shell 历史里。第一个存入的 namespace 成为默认,之后 `wdl deploy` 不用再带 `--ns`。一份存储服务这台机器上所有项目;详见 [token-zh.md](./token-zh.md)。 + +**per-repo 备选:** 当某个项目需要锁定自己的 control URL / namespace 时,复制 `.env.example` 到 `.env`,填写 `[]` 段(提交的 `.env.example` 也向队友说明配置形状)。CLI 只读取运行 `wdl` 时所在目录下的 `./.env`(不会向上层目录查找),所以要在放置 `.env` 的目录里执行 `wdl`。token 留在被 gitignore 的 `.env` 里,绝不提交。 + +**CI / 自动化:** 把 `ADMIN_TOKEN`、`CONTROL_URL`、`WDL_NS` 作为环境变量从 CI secret store 注入 —— 不用交互式的 token store,也绝不提交 `.env`。 裸 control host 会自动补 scheme;生产 host 默认 `https://`,本地 `.test` / `.local` 或 `:8080` 默认 `http://`。如果要强制协议,直接显式写 `https://...` 或 `http://...`。 -优先级:`CLI 标志 > shell env > .env 中 [] 段 > .env 基础段`。都没有提供时命令直接报错——没有内置默认值。 +优先级:`CLI 标志 > shell env > .env 中 [] 段 > .env 基础段 > wdl token store`。都没有提供时命令直接报错——没有内置默认值。 不确定最终取了哪个值时,运行 `wdl config explain`;要确认 token 实际连到哪个 control、principal、platform version 和 URL hints,运行 `wdl whoami`;本机与远端基础排查运行 `wdl doctor`。当 control 支持 `/whoami` 时,`doctor` 会验证远端 token、principal namespace、platform version 和 CLI compatibility。 @@ -55,12 +59,12 @@ Worker 看到的路径是**剥掉 `/` 之后的路径**。除非运 | 删除 worker(预览) | `wdl delete worker --dry-run` | | 查看 Workflow 实例 | `wdl workflows instances ` | -只要 `WDL_NS` 通过 env 或 `.env` 设置了,`--ns` 就是可选的。每个子命令都实现了 `--help` —— 不知道用什么 flag 时直接跑。 +只要 `WDL_NS` 通过 env 或 `.env` 设置了,或者 `wdl token` store 有默认 namespace,`--ns` 就是可选的。每个子命令都实现了 `--help` —— 不知道用什么 flag 时直接跑。 ## 标准部署流程 1. **解析 CLI 调用形式**(上文)。 -2. **解析凭证** —— 优先用 `.env`,不要内联环境变量。 +2. **解析凭证** —— 优先用 `.env` 或 `wdl token` store,不要内联环境变量。 3. **wrangler 版本检查。** 打包步骤需要 `wrangler@^4`。如果项目 pin 了 v3,停下,告诉用户 —— 不要默默升级。 4. **安装 worker 依赖**(在 worker 目录下 `npm install`),如果 `node_modules` 不存在。 5. **预创建持久化绑定。** 读 wrangler 配置: @@ -102,7 +106,7 @@ Cron triggers 和 queue consumers 是 runtime dispatch 能力,只应声明在 | 现象 | 原因 / 修复 | | --- | --- | | `wdl: command not found` | CLI 不在 PATH。在 wdl-cli 仓库内用 `node /bin/wdl.js`;其他情况执行 `npm i -g @wdl-dev/cli`。 | -| `Missing admin token` | `ADMIN_TOKEN` 没设,且没传 `--token`。检查 `.env` 的 `[]` 段。 | +| `Missing admin token` | 没解析出 token。运行 `wdl token set --ns --control-url `(推荐),或设 `ADMIN_TOKEN` / 传 `--token` / 用 `.env` 的 `[]` 段。 | | `401 unknown_token: unauthorized` | Token 对这个控制平面 / 命名空间无效。重新检查 `ADMIN_TOKEN`。 | | `[vars] must be an object` | 用 `[vars]` 表/对象;数组不合法。 | | `[vars] : only string/number/boolean values are supported` | 移除嵌套值;敏感字符串改用 secret。 | diff --git a/docs/deploy.md b/docs/deploy.md index c511ca2..57974ab 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -25,7 +25,7 @@ Pick one in this order: In the examples below, treat `wdl` as a placeholder and substitute the form you resolved. -## Credentials — one-time `.env` setup +## Credentials — one-time setup The CLI needs three values: @@ -35,18 +35,31 @@ The CLI needs three values: | `WDL_NS` | Tenant namespace, e.g. `acme`, `demo-prod`. | | `CONTROL_URL` | Control-plane URL, provided by your operator (e.g. `https://api.wdl.dev`). The CLI has no built-in default; it must be configured. | -**Recommended path (one-time per repo):** copy `.env.example` to `.env` and fill -in the `[]` section. The CLI reads only `./.env` from the directory you run -`wdl` in (there is no upward search), so run `wdl` from the directory that holds -the `.env`. After that, commands only need `--ns ` (or the `WDL_NS` -environment variable), and the token never appears in shell history again. +**Recommended path:** `wdl token set --ns --control-url ` reads the +token at a hidden prompt, validates it against `/whoami`, and stores it `0600` in +`~/.config/wdl/credentials` — so it never lands in a project file or shell +history. The first stored namespace becomes the default, so later `wdl deploy` +needs no `--ns`. One store serves every project on the machine; see +[token.md](./token.md). + +**Per-repo alternative:** when a project should carry its own control URL / +namespace, copy `.env.example` to `.env` and fill in the `[]` section (the +committed `.env.example` also documents the shape for teammates). The CLI reads +only `./.env` from the directory you run `wdl` in (there is no upward search), so +run `wdl` from the directory that holds it. The token stays in the gitignored +`.env`, never committed. + +**CI / automation:** inject `ADMIN_TOKEN`, `CONTROL_URL`, and `WDL_NS` as +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. -Precedence: `CLI flag > shell env > .env [] section > .env base section`. If -none supplies a value, the command fails — there is no built-in default. +Precedence: `CLI flag > shell env > .env [] section > .env base section > wdl +token store`. If none supplies a value, the command fails — there is no built-in +default. When unsure which value won, run `wdl config explain`; to confirm which control the token actually reaches, plus the principal, platform version, and URL hints, @@ -78,13 +91,15 @@ not add `route` / `routes` in a first-time setup. | Delete a worker (preview) | `wdl delete worker --dry-run` | | Inspect Workflow instances | `wdl workflows instances ` | -`--ns` is optional whenever `WDL_NS` is set via env or `.env`. Every subcommand -implements `--help` — run it when you don't know which flag to use. +`--ns` is optional whenever `WDL_NS` is set via env or `.env`, or the `wdl token` +store has a default namespace. Every subcommand implements `--help` — run it when +you don't know which flag to use. ## Standard deploy flow 1. **Resolve the CLI invocation form** (above). -2. **Resolve credentials** — prefer `.env`; do not inline environment variables. +2. **Resolve credentials** — prefer `.env` or the `wdl token` store; do not + inline environment variables. 3. **Wrangler version check.** The bundling step requires `wrangler@^4`. If the project pins v3, stop and tell the user — do not silently upgrade. 4. **Install worker dependencies** (`npm install` in the worker directory) if @@ -160,7 +175,7 @@ Deleting a worker does **not** delete R2 data — see [r2.md](./r2.md). | Symptom | Cause / fix | | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | `wdl: command not found` | The CLI is not on PATH. Inside the wdl-cli repo use `node /bin/wdl.js`; otherwise run `npm i -g @wdl-dev/cli`. | -| `Missing admin token` | `ADMIN_TOKEN` is not set and `--token` was not passed. Check the `[]` section of `.env`. | +| `Missing admin token` | No token resolved. Run `wdl token set --ns --control-url ` (recommended), or set `ADMIN_TOKEN` / pass `--token` / use the `[]` section of `.env`. | | `401 unknown_token: unauthorized` | The token is invalid for this control plane / namespace. Re-check `ADMIN_TOKEN`. | | `[vars] must be an object` | Use a `[vars]` table/object; arrays are invalid. | | `[vars] : only string/number/boolean values are supported` | Remove nested values; move sensitive strings to a secret. | diff --git a/docs/token-zh.md b/docs/token-zh.md index 07765cf..25c0e30 100644 --- a/docs/token-zh.md +++ b/docs/token-zh.md @@ -61,6 +61,8 @@ CLI 标志 > shell/CI env > 项目 ./.env > 全局 token 存储 > 未配置( 所以设了存储默认后,`wdl deploy`、`wdl doctor` 等不带 `--ns` 也能跑;要换别的就传 `--ns`(或 `wdl token use `)。当 namespace 来自存储默认时,`wdl config explain` 把来源显示为 `token store default`。 +`wdl token` 子命令是这条链的例外:`set`、`use`、`rm` 会改动存储,所以它们只从显式 `--ns`(或 `use` 的位置参数)取 namespace —— 绝不取 ambient `WDL_NS` —— 以免一个游离的 shell 值写错、切错或删错条目。 + 存储是**可信**的(它在你的 home 目录、由你经 `wdl token` 写入,token 和端点同源)。项目 `.env` **不可信**:若一个 `.env` 提供了 control 端点却没同时提供 token,该端点仍会被丢弃——这样不可信的项目目录永远无法把你存的 token 重定向到它指定的主机。 ## 反模式 diff --git a/docs/token.md b/docs/token.md index ee835b4..39edc29 100644 --- a/docs/token.md +++ b/docs/token.md @@ -81,6 +81,11 @@ 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`. +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 diff --git a/lib/common.js b/lib/common.js index d018cda..240c858 100644 --- a/lib/common.js +++ b/lib/common.js @@ -221,7 +221,7 @@ function defaultSchemeForBareControlUrl(text) { export function resolveControlContext(values, env = process.env) { const token = values.token || env.ADMIN_TOKEN; if (!token) { - throw new CliError("Missing admin token. Pass --token or set ADMIN_TOKEN env."); + throw new CliError("Missing admin token. Run 'wdl token set --ns --control-url ' (recommended), pass --token , or set ADMIN_TOKEN."); } return { controlUrl: resolveControlUrl(values, env), diff --git a/lib/whoami.js b/lib/whoami.js index 5894b4c..efe0b18 100644 --- a/lib/whoami.js +++ b/lib/whoami.js @@ -107,7 +107,7 @@ function stringField(value) { export function ensureControlContextFromConfigState(state) { if (state.controlUrl.error) throw new CliError(state.controlUrl.error); - if (!state.token.value) throw new CliError("Missing admin token. Pass --token or set ADMIN_TOKEN env."); + if (!state.token.value) throw new CliError("Missing admin token. Run 'wdl token set --ns --control-url ' (recommended), pass --token , or set ADMIN_TOKEN."); return { controlUrl: state.controlUrl.value, token: state.token.value, From 6f8a4304e529484a280b53eb04e77d6e353e7c61 Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Tue, 16 Jun 2026 23:33:50 +0800 Subject: [PATCH 13/13] Fix empty-WDL_NS store-default precedence and sync the GUIDE chain Code: when WDL_NS is present-but-empty in the shell/CI environment (e.g. WDL_NS="" in a CI job), it landed in protectedKeys, so the base .env's WDL_NS was skipped; firstNonEmptyString then treated the empty value as absent, and the token-store fallback materialized the store's default namespace into env.WDL_NS. Net: a lower-precedence store default overrode the project's .env namespace, so wdl deploy could target the wrong namespace. An unset value is "absent" everywhere else here, so it must not protect its key. Extract protectedEnvKeys (protect only keys with a non-empty string value, matching firstNonEmptyString / flagSet -- so "" and an injected `undefined` both count as unset) and use it for both the loadCliControlEnv / loadCliDotEnv defaults AND resolveCliConfigState, which built its own protectedKeys with the old behavior -- otherwise config explain / doctor / whoami would resolve a different namespace/control/token than an operating command. Restores .env > store-default (also closes the same latent inversion for ADMIN_TOKEN / CONTROL_URL). Regression tests cover both the runtime loader and resolveCliConfigState. Docs: the earlier sync (738ba3b) marked the store as recommended in GUIDE but left the precedence line stopping at base .env and the namespace sentence ending at shell/.env WDL_NS. Extend both (EN + ZH) with the wdl token store as the lowest layer and its default namespace, matching deploy.md / token.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- GUIDE-zh.md | 2 +- GUIDE.md | 7 ++++--- lib/common.js | 14 ++++++++++++-- lib/config-state.js | 6 ++++-- tests/unit/cli-config-doctor.test.js | 29 ++++++++++++++++++++++++++++ tests/unit/cli-token.test.js | 29 +++++++++++++++++++++++++++- 6 files changed, 78 insertions(+), 9 deletions(-) diff --git a/GUIDE-zh.md b/GUIDE-zh.md index 4212016..4015538 100644 --- a/GUIDE-zh.md +++ b/GUIDE-zh.md @@ -81,7 +81,7 @@ 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`,都没有提供时命令直接报错——没有内置默认值。namespace 解析顺序是 `--ns`,然后是 shell 或 base `.env` 里的 `WDL_NS`。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://`;`localhost:8080` 或 `*.test:8080` 这类本地开发地址默认补 `http://`。任何不带 scheme 的 `:8080` control URL 都会按本地 HTTP 处理。需要强制使用其它协议时,显式写 scheme。 推荐的做法是把这些凭证放进托管存储,而不是 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)。 diff --git a/GUIDE.md b/GUIDE.md index cace7bc..d8dd10c 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -96,9 +96,10 @@ ADMIN_TOKEN= The CLI loads only WDL platform variables from `.env`: `ADMIN_TOKEN`, `CONTROL_URL`, `CONTROL_CONNECT_HOST`, and `WDL_NS`. Precedence is -`CLI flag > shell/CI env > [resolved-ns] section > base .env`, and if none -supplies a value the command fails — there is no built-in default. Namespace -resolution is `--ns`, then `WDL_NS` from your shell or base `.env`. Section +`CLI flag > shell/CI env > [resolved-ns] section > base .env > wdl token store`, +and if none supplies a value the command fails — there is no built-in default. +Namespace resolution is `--ns`, then `WDL_NS` from your shell or base `.env`, +then the token store's default namespace. Section names may be normal tenant 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 diff --git a/lib/common.js b/lib/common.js index 240c858..025f37f 100644 --- a/lib/common.js +++ b/lib/common.js @@ -285,6 +285,16 @@ export function flagSet(values, name) { return typeof values[name] === "string" && values[name].length > 0; } +// Shell/CI env wins over `.env`, but only when actually set. A value that is not +// a non-empty string (e.g. WDL_NS="" in CI, or undefined on an injected env +// object) counts as absent everywhere else here — like firstNonEmptyString and +// flagSet — so it must not "protect" its key: protecting an unset value would +// block the `.env` value AND then let it fall through to a lower-precedence store +// default, inverting the `.env` > store-default order. +export function protectedEnvKeys(env) { + return new Set(Object.keys(env).filter((key) => typeof env[key] === "string" && env[key] !== "")); +} + export function loadCliDotEnv( env = process.env, path = ".env", @@ -301,7 +311,7 @@ export function loadCliDotEnv( const { resolvedNs, loadBase = true, - protectedKeys = new Set(Object.keys(env)), + protectedKeys = protectedEnvKeys(env), warn = (message) => console.warn(`warning: ${message}`), onLoad = null, } = options; @@ -373,7 +383,7 @@ export function loadCliControlEnv(env, { nsFromFlag, tokenFromFlag = false, controlUrlFromFlag = false, - protectedKeys = new Set(Object.keys(env)), + protectedKeys = protectedEnvKeys(env), loadEnv = loadCliDotEnv, readStore = () => ({}), warn, diff --git a/lib/config-state.js b/lib/config-state.js index b7313c5..c28aafc 100644 --- a/lib/config-state.js +++ b/lib/config-state.js @@ -1,5 +1,5 @@ import path from "node:path"; -import { CliError, flagSet, loadCliControlEnv, maskToken, resolveControlUrl, resolveNamespace } from "./common.js"; +import { CliError, flagSet, loadCliControlEnv, maskToken, protectedEnvKeys, resolveControlUrl, resolveNamespace } from "./common.js"; import { readTokenStore, tokenStorePath } from "./token-store.js"; /** @@ -13,7 +13,9 @@ import { readTokenStore, tokenStorePath } from "./token-store.js"; */ export function resolveCliConfigState({ values = {}, env = process.env, cwd = process.cwd(), dotenvPath = ".env", warn = () => {} } = {}) { const workingEnv = { ...env }; - const protectedKeys = new Set(Object.keys(env)); + // The loader's helper, not a raw Object.keys set: diagnostics must resolve what + // an operating command would. See protectedEnvKeys. + const protectedKeys = protectedEnvKeys(env); const sources = new Map(); for (const key of Object.keys(env)) sources.set(key, `${key} env`); diff --git a/tests/unit/cli-config-doctor.test.js b/tests/unit/cli-config-doctor.test.js index 10dde9c..8a2a4fc 100644 --- a/tests/unit/cli-config-doctor.test.js +++ b/tests/unit/cli-config-doctor.test.js @@ -9,6 +9,7 @@ import { runWhoamiCommand } from "../../commands/whoami.js"; import { main as wdlMain } from "../../bin/wdl.js"; import { resolveCliConfigState } from "../../lib/config-state.js"; import { maskToken } from "../../lib/common.js"; +import { tokenStorePath, writeTokenStore } from "../../lib/token-store.js"; import { cliCompatibility, compareSemver } from "../../lib/whoami.js"; import { response } from "./helpers.js"; @@ -46,6 +47,34 @@ test("resolveCliConfigState reports .env section sources and masks token", () => }); }); +test("resolveCliConfigState resolves the .env namespace over the store default given an empty shell WDL_NS", () => { + withTempDir((cwd) => { + const xdg = path.join(cwd, "xdg"); + writeTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg }), { + defaultNs: "demo", + namespaces: { + acme: { CONTROL_URL: "https://acme.store.example", ADMIN_TOKEN: "acme-store-token" }, + demo: { CONTROL_URL: "https://demo.store.example", ADMIN_TOKEN: "demo-store-token" }, + }, + }); + writeFileSync(path.join(cwd, ".env"), [ + "WDL_NS=acme", + "", + "[acme]", + "CONTROL_URL=https://acme.env.example", + "ADMIN_TOKEN=acme-env-token", + ].join("\n")); + + const state = resolveCliConfigState({ + env: { WDL_NS: "", XDG_CONFIG_HOME: xdg }, + cwd, + }); + + assert.equal(state.namespace.display, "acme", "the .env namespace wins, not the demo store default"); + assert.equal(state.controlUrl.display, "https://acme.env.example", "control comes from the .env acme section"); + }); +}); + test("maskToken never reveals most of a short token", () => { assert.equal(maskToken("abcd"), "****"); assert.equal(maskToken("ab"), "****"); diff --git a/tests/unit/cli-token.test.js b/tests/unit/cli-token.test.js index 76a7eec..79161b0 100644 --- a/tests/unit/cli-token.test.js +++ b/tests/unit/cli-token.test.js @@ -5,7 +5,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { runTokenCommand } from "../../commands/token.js"; -import { loadCliControlEnv, readSecretStdin, readTtyLine } from "../../lib/common.js"; +import { loadCliControlEnv, protectedEnvKeys, readSecretStdin, readTtyLine } from "../../lib/common.js"; import { readTokenStore, tokenStorePath, writeTokenStore } from "../../lib/token-store.js"; import { response } from "./helpers.js"; @@ -523,6 +523,11 @@ test("readTtyLine escapes terminal controls in the prompt at the write point", a assert.doesNotMatch(errs.join(""), new RegExp(String.fromCharCode(27)), "raw ESC from the prompt must not reach stderr"); }); +test("protectedEnvKeys protects only non-empty string values", () => { + const keys = protectedEnvKeys(/** @type {any} */ ({ A: "x", EMPTY: "", MISSING: undefined, B: "y" })); + assert.deepEqual([...keys].sort(), ["A", "B"]); +}); + // --- resolution integration (the global store as the lowest-precedence layer) --- test("loadCliControlEnv fills control URL and token from the store as a gap-filler", () => { @@ -567,6 +572,28 @@ test("loadCliControlEnv lets an explicit namespace override the store default", assert.equal(env.ADMIN_TOKEN, "demo-tok"); }); +test("loadCliControlEnv lets a project .env namespace beat the store default over an empty shell WDL_NS", () => { + const env = { WDL_NS: "" }; + loadCliControlEnv(env, { + // Simulate the real loader: only set the .env WDL_NS when it is not protected. + loadEnv: (e, _path, { protectedKeys }) => { + if (protectedKeys.has("WDL_NS")) return []; + e.WDL_NS = "acme"; + return ["WDL_NS"]; + }, + readStore: () => ({ + defaultNs: "demo", + namespaces: { + acme: { CONTROL_URL: "https://acme.example", ADMIN_TOKEN: "acme-tok" }, + demo: { CONTROL_URL: "https://demo.example", ADMIN_TOKEN: "demo-tok" }, + }, + }), + }); + assert.equal(env.WDL_NS, "acme", "the project .env namespace wins over the store default"); + assert.equal(env.CONTROL_URL, "https://acme.example", "creds come from acme, not the demo default"); + assert.equal(env.ADMIN_TOKEN, "acme-tok"); +}); + test("loadCliControlEnv ignores a store default with no stored entry", () => { const env = /** @type {NodeJS.ProcessEnv} */ ({}); loadCliControlEnv(env, {