From 7766598d7ca90f36377dc9f702835181bcef93bf Mon Sep 17 00:00:00 2001 From: lmoresi Date: Tue, 5 May 2026 08:10:01 +1000 Subject: [PATCH 1/2] uw: add claude-set-token subcommand + dev-env activation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual setup of CLAUDE_CODE_OAUTH_TOKEN on a remote machine is fiddly: run claude setup-token on a browser host, copy the sk-ant-oat01-... token, then export it from ~/.bashrc on the remote and remember to keep it in sync. Two pieces work together to remove the rc-file dance: 1. scripts/activate-claude-auth.sh — sourced via [feature.dev.activation] in pixi.toml. Auto-exports CLAUDE_CODE_OAUTH_TOKEN from ~/.claude/uw-token whenever a dev pixi env is activated. Token is scoped to dev envs only, not leaked into non-dev shells or non-pixi sessions. 2. ./uw claude-set-token [TOKEN] — accepts the token from arg, stdin (pipe), or interactive prompt (input hidden), validates the sk-ant-oat01- prefix, and writes ~/.claude/uw-token with mode 0600. Has its own --help describing the flow end-to-end. Listed under Setup in ./uw --help. Net workflow on a fresh remote: ./uw claude-set-token # paste token once ./uw shell # token auto-loads claude # just works Same security posture as exporting from a shell rc — token on disk in $HOME, mode 0600. Doesn't help users on a real secrets manager. Underworld development team with AI support from Claude Code (https://claude.com/claude-code) --- pixi.toml | 3 ++ scripts/activate-claude-auth.sh | 14 ++++++ uw | 89 +++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100755 scripts/activate-claude-auth.sh diff --git a/pixi.toml b/pixi.toml index e2436c07e..7d3a2d4d6 100644 --- a/pixi.toml +++ b/pixi.toml @@ -323,6 +323,9 @@ anthropic = "*" sphinx-math-dollar = "*" sphinxcontrib-mermaid = "*" +[feature.dev.activation] +scripts = ["scripts/activate-claude-auth.sh"] + [feature.dev.tasks] install-claude = "npm install -g @anthropic-ai/claude-code" claude = "claude" diff --git a/scripts/activate-claude-auth.sh b/scripts/activate-claude-auth.sh new file mode 100755 index 000000000..93977cee2 --- /dev/null +++ b/scripts/activate-claude-auth.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# activate-claude-auth.sh — sourced by pixi on dev-environment activation. +# +# Auto-exports CLAUDE_CODE_OAUTH_TOKEN from a user-managed file if it +# exists. Lets users skip the manual `export CLAUDE_CODE_OAUTH_TOKEN=...` +# in shell rcs and avoids leaking the token outside dev pixi envs. +# +# Token file is written by `./uw claude-set-token` (mode 0600). +# To remove: rm ~/.claude/uw-token + +if [ -f "$HOME/.claude/uw-token" ]; then + CLAUDE_CODE_OAUTH_TOKEN="$(cat "$HOME/.claude/uw-token")" + export CLAUDE_CODE_OAUTH_TOKEN +fi diff --git a/uw b/uw index 02b34835f..97d46593b 100755 --- a/uw +++ b/uw @@ -669,6 +669,91 @@ else: echo " Includes: git, gh, pixi, build tools, desktop notifications (macOS + Linux)" } +# Help for `./uw claude-set-token` +claude_set_token_usage() { + cat << 'EOF' +./uw claude-set-token — store a Claude Code OAuth token for use in dev pixi envs. + +Usage: + ./uw claude-set-token [TOKEN] + ./uw claude-set-token --help + + TOKEN may be passed as an argument, piped on stdin, or entered at the + interactive prompt (with input hidden). Tokens look like + sk-ant-oat01-... and are obtained on a machine with a browser by + running: claude setup-token (Pro/Max subscription required). + + The token is written to ~/.claude/uw-token (mode 0600). The dev pixi + env's activation script (scripts/activate-claude-auth.sh) auto-exports + it as CLAUDE_CODE_OAUTH_TOKEN whenever you enter the env via + ./uw shell or pixi run -e . The token is NOT exported in + non-dev envs or in shells outside pixi. + + To remove the stored token: rm ~/.claude/uw-token +EOF +} + +# Write a Claude Code OAuth token to ~/.claude/uw-token (mode 0600). +# Token may come from $1, stdin (if piped), or an interactive prompt. +run_claude_set_token() { + if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then + claude_set_token_usage + return 0 + fi + + local token="$1" + + if [ -z "$token" ]; then + if [ ! -t 0 ]; then + # stdin is a pipe — read one line + read -r token + else + echo -e "${BOLD}Set Claude Code OAuth token${NC}" + echo " Get one with 'claude setup-token' on a machine with a browser." + echo " See './uw claude-set-token --help' for details." + echo "" + read -r -s -p "Token (input hidden): " token + echo "" + fi + fi + + # Strip whitespace (paste artifacts often include trailing newlines) + token="$(printf '%s' "$token" | tr -d '[:space:]')" + + if [ -z "$token" ]; then + echo -e "${YELLOW}No token provided.${NC}" >&2 + return 1 + fi + + if [[ "$token" != sk-ant-oat01-* ]]; then + echo -e "${YELLOW}Token doesn't look like a Claude Code OAuth token (expected sk-ant-oat01-... prefix).${NC}" >&2 + echo " Get one with 'claude setup-token' on a machine with a browser." >&2 + return 1 + fi + + mkdir -p "$HOME/.claude" + chmod 700 "$HOME/.claude" 2>/dev/null || true + + local token_file="$HOME/.claude/uw-token" + if [ -f "$token_file" ]; then + echo -e " ${YELLOW}Overwriting existing token at $token_file${NC}" + fi + + # Write atomically with strict perms + (umask 077 && printf '%s' "$token" > "$token_file") || { + echo -e "${YELLOW}Failed to write $token_file${NC}" >&2 + return 1 + } + chmod 600 "$token_file" + + echo -e " ${GREEN}✓${NC} Token written to $token_file (mode 0600)" + echo "" + echo "Activate it by entering a dev pixi env:" + echo " ./uw shell" + echo "" + echo "The dev-env activation script auto-exports CLAUDE_CODE_OAUTH_TOKEN." +} + # Interactive setup wizard run_setup() { # Ensure pixi is available @@ -972,6 +1057,7 @@ COMMANDS set-env NAME Change environment directly ai-tools Configure external AI instruction paths claude-perms Configure Claude Code permissions (safe defaults) + claude-set-token Store Claude OAuth token for auto-export in dev envs (--help for details) install-claude Install Claude Code CLI into the dev pixi env (run via ./uw claude) Building: @@ -1692,6 +1778,9 @@ case "${1:-}" in claude-perms) configure_claude_permissions "$(get_env)" ;; + claude-set-token) + run_claude_set_token "${2:-}" + ;; install-claude) # The install-claude pixi task lives under [feature.dev.tasks], so it # only resolves in dev-feature envs (dev, amr-dev, *-dev). Guard the From ac0949b474b875f861b730037c42a292e8d5da5c Mon Sep 17 00:00:00 2001 From: lmoresi Date: Tue, 5 May 2026 10:35:36 +1000 Subject: [PATCH 2/2] uw: harden claude-set-token (atomic write, EOF, whitespace) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot review on PR #169: 1. Atomic write + symlink safety. The original `printf > file` was not atomic and would happily follow a pre-existing symlink at the destination, clobbering whatever it pointed at regardless of umask. Switch to: refuse if the destination is a symlink, then write to a sibling temp file via mktemp (chmod 0600) and `mv` it into place (atomic on the same filesystem). Cleanup on every failure path. 2. `read` + `set -e`. Bare `read` returns non-zero on EOF (empty pipe input or Ctrl-D at the prompt), and with `set -e` in effect at script scope that killed the whole script before the "No token provided" handler could run. Add `|| true` so the empty-check now handles it cleanly. Verified with `printf '' | ./uw claude-set-token` → prints the diagnostic and exits 1. 3. Activation script trailing whitespace. `$(cat file)` only strips trailing newlines. A manually-edited token file with \r\n endings, leading spaces, or extra whitespace would have leaked into CLAUDE_CODE_OAUTH_TOKEN and broken auth. Read via `tr -d '[:space:]' < file` and only export when the result is non-empty. Underworld development team with AI support from Claude Code (https://claude.com/claude-code) --- scripts/activate-claude-auth.sh | 11 ++++++++-- uw | 38 +++++++++++++++++++++++++++------ 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/scripts/activate-claude-auth.sh b/scripts/activate-claude-auth.sh index 93977cee2..bc511ae6a 100755 --- a/scripts/activate-claude-auth.sh +++ b/scripts/activate-claude-auth.sh @@ -9,6 +9,13 @@ # To remove: rm ~/.claude/uw-token if [ -f "$HOME/.claude/uw-token" ]; then - CLAUDE_CODE_OAUTH_TOKEN="$(cat "$HOME/.claude/uw-token")" - export CLAUDE_CODE_OAUTH_TOKEN + # Strip ALL whitespace (covers \r\n line endings, accidental leading + # spaces, and any whitespace a manual editor leaves behind). $() alone + # only trims trailing newlines, which is not enough here. + _uw_claude_token="$(tr -d '[:space:]' < "$HOME/.claude/uw-token")" + if [ -n "$_uw_claude_token" ]; then + CLAUDE_CODE_OAUTH_TOKEN="$_uw_claude_token" + export CLAUDE_CODE_OAUTH_TOKEN + fi + unset _uw_claude_token fi diff --git a/uw b/uw index 97d46593b..94087fdb4 100755 --- a/uw +++ b/uw @@ -704,15 +704,18 @@ run_claude_set_token() { local token="$1" if [ -z "$token" ]; then + # `set -e` is in effect at script scope; without `|| true` a `read` + # that hits EOF (empty pipe input, or Ctrl-D at the prompt) would + # exit the whole script and bypass the "No token provided" handler. if [ ! -t 0 ]; then # stdin is a pipe — read one line - read -r token + read -r token || true else echo -e "${BOLD}Set Claude Code OAuth token${NC}" echo " Get one with 'claude setup-token' on a machine with a browser." echo " See './uw claude-set-token --help' for details." echo "" - read -r -s -p "Token (input hidden): " token + read -r -s -p "Token (input hidden): " token || true echo "" fi fi @@ -735,16 +738,39 @@ run_claude_set_token() { chmod 700 "$HOME/.claude" 2>/dev/null || true local token_file="$HOME/.claude/uw-token" + + # Refuse to follow a symlink at the destination — a redirect through one + # would clobber whatever it points at, regardless of how strict our umask is. + if [ -L "$token_file" ]; then + echo -e "${YELLOW}Refusing to write through symlink at $token_file${NC}" >&2 + echo " Remove or replace it manually if this is intentional." >&2 + return 1 + fi + if [ -f "$token_file" ]; then echo -e " ${YELLOW}Overwriting existing token at $token_file${NC}" fi - # Write atomically with strict perms - (umask 077 && printf '%s' "$token" > "$token_file") || { - echo -e "${YELLOW}Failed to write $token_file${NC}" >&2 + # Atomic replace: write to a sibling temp file (same filesystem), set + # mode 0600, then `mv` into place. mktemp creates the temp file 0600 + # already; the umask 077 in the redirect subshell is belt-and-braces + # against any weird default mask environments. + local tmp_file + tmp_file="$(mktemp "$HOME/.claude/.uw-token.XXXXXX")" || { + echo -e "${YELLOW}Failed to create temp file in $HOME/.claude/${NC}" >&2 + return 1 + } + (umask 077 && printf '%s' "$token" > "$tmp_file") || { + rm -f "$tmp_file" + echo -e "${YELLOW}Failed to write temp file $tmp_file${NC}" >&2 + return 1 + } + chmod 600 "$tmp_file" + mv -f "$tmp_file" "$token_file" || { + rm -f "$tmp_file" + echo -e "${YELLOW}Failed to move temp file into place${NC}" >&2 return 1 } - chmod 600 "$token_file" echo -e " ${GREEN}✓${NC} Token written to $token_file (mode 0600)" echo ""