From f725cd2f0218135fe2d2bebb3cef87f20ea9b664 Mon Sep 17 00:00:00 2001 From: Claude CoWork Date: Fri, 8 May 2026 23:00:55 -0700 Subject: [PATCH 1/2] codex-shell: AGENT_MODE=auth-init for slot OAuth provisioning (WOVED-126 + WOVED-128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Successor to closed PR #18 (auto-closed when its base branch feat/woved-126-worker-mode merged via #17). Adds the third entrypoint mode — one-shot init pod that drives `claude /login` under operator supervision via the woveD Manager callback API. Lifecycle: 1. Spawn `claude` under a PTY (Claude Code's OAuth flow expects a TTY). 2. Watch stdout for the OAuth device-code URL. 3. POST URL + best-effort user_code to Manager: POST /slots//auth-init/url with X-Slot-Init-Token header (WOVED-128). 4. Long-poll Manager for the operator-submitted code: GET /slots//auth-init/code also with X-Slot-Init-Token. 2s backoff, 30min cap. 5. Pipe the code into the running CLI's PTY. 6. Wait for agent exit. Verify ~/.claude/ has auth state. Exit 0. WOVED-128 auth: every callback request includes the per-slot bearer token in the X-Slot-Init-Token header. Token is generated by the Manager when the init Pod is spawned and injected as the WOVED_SLOT_INIT_TOKEN env. A sibling pod that can reach the Manager service can NOT poll another slot's URL or consume its code without the matching token. The script also fails fast if the env var is missing (validated alongside the other required env vars). Pairs with woved#52 (Manager-side SlotAuthStore + callback endpoints + WOVED-128 token authn) and woved#55 (Spawner.init_slot — needs a small follow-up commit to actually generate + register the token when spawning the init Pod). What lands: - bin/auth_init.py — stdlib-only Python (pty, select, urllib) PTY- driven OAuth dance with token-authenticated callbacks - bin/entrypoint.sh — third case branch (auth-init); error message on unknown mode now lists all three options - Dockerfile — COPY bin/auth_init.py into the image First-draft caveats (TODOs in the code) — `claude /login`'s exact CLI shape + stdout patterns may need adjustment after a real-pod test pass: - Whether `claude` auto-prompts OAuth on no-auth-state startup, or requires `/login` typed into the REPL - Exact format of the device-code URL line in stdout (regex is lenient by design) - Exact filename(s) Claude Code writes under `~/.claude/` that indicate successful login The script's structure (PTY spawn, regex extraction, token-authn callback round-trip, code injection, exit verification) is the part worth reviewing now. The exact CLI mechanics will firm up once we run it against a live pod. Co-Authored-By: Claude Opus 4.7 (1M context) --- Dockerfile | 6 + bin/auth_init.py | 351 ++++++++++++++++++++++++++++++++++++++++++++++ bin/entrypoint.sh | 21 ++- 3 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 bin/auth_init.py diff --git a/Dockerfile b/Dockerfile index 64892c7..2cc754c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -287,6 +287,12 @@ COPY --chmod=0755 bin/entrypoint.sh /usr/local/bin/entrypoint.sh # Worker mode (WOVED-126): headless task execution path. The entrypoint # dispatches to this when AGENT_MODE=worker; ttyd is bypassed entirely. COPY --chmod=0755 bin/worker.py /usr/local/bin/worker.py +# Slot OAuth init mode (WOVED-126): operator-driven device-code dance. +# The entrypoint dispatches to this when AGENT_MODE=auth-init; one-shot +# init pod that exits once the slot's PVC carries valid auth state. +# Per-slot bearer token (WOVED-128) authenticates callbacks to the +# Manager — see bin/auth_init.py. +COPY --chmod=0755 bin/auth_init.py /usr/local/bin/auth_init.py COPY --chown=${AGENT}:${AGENT} profile/.bashrc /home/${AGENT}/.bashrc COPY --chown=${AGENT}:${AGENT} profile/.tmux.conf /home/${AGENT}/.tmux.conf diff --git a/bin/auth_init.py b/bin/auth_init.py new file mode 100644 index 0000000..6e25332 --- /dev/null +++ b/bin/auth_init.py @@ -0,0 +1,351 @@ +#!/usr/bin/env python3 +"""Slot OAuth init mode for codex-shell pods (WOVED-126 + WOVED-128). + +One-shot init pod that drives the Claude Code device-code OAuth flow +under operator supervision via the woveD Manager. This is the agent-pod +half of the dance described in Confluence page 65961985 and implemented +on the Manager side in `slot_auth.py` + the `/slots//auth-init/*` +callback endpoints. + +Lifecycle: + + 1. Spawn `claude` in a pseudo-terminal so it behaves interactively + the way it does at a real shell. + 2. Watch its stdout for the OAuth device-code URL pattern. + Anthropic's CLI prints something like: + To continue, please visit: https://... + and enter the code: ABCD-1234 + 3. POST URL + user_code to Manager at + /slots//auth-init/url with X-Slot-Init-Token header. + 4. Poll /slots//auth-init/code (also with token) until + the operator submits the code via the dashboard. Backoff: 2s + between polls; cap at AUTH_INIT_TIMEOUT_S total. + 5. Type the code into the PTY so `claude` can complete login. + 6. Wait for the agent process to exit. Verify ~/.claude/ has auth + state. Exit 0 on success. + +WOVED-128: every callback request includes the per-slot bearer token +in the X-Slot-Init-Token header. Without it the Manager returns 401. +The token is generated by the Manager when the init pod is spawned +and injected as WOVED_SLOT_INIT_TOKEN env. A sibling pod that can +reach the Manager service can NOT poll another slot's URL or consume +its code without the matching token. + +Stdlib only — no extra deps to vendor into the image. Uses `pty` + +`os.read`/`os.write` because that's what works portably; `pexpect` +would be slightly nicer but adds a dep that's not worth pinning into +the image just for one script. + +Operator failure modes: + - Timeout — operator never pastes the code. Init pod exits + non-zero; Manager reconciler marks the slot ERROR. + - Bad code — claude rejects, prints the URL again. The script + treats this as a fresh URL surface and re-posts it (idempotent). + - Agent crashes — stdout closes; script exits with the agent's + return code. + +This script is the FIRST DRAFT — `claude /login`'s exact CLI shape + +prompt patterns may need adjustment after a real-pod test pass. The +TODO markers below call out the parts most likely to need iteration. +""" + +from __future__ import annotations + +import json +import os +import pty +import re +import select +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +# Env contract — set by the chart's slot-init Pod manifest at spawn time. +SLOT_ID = os.environ.get("WOVED_SLOT_ID", "") +TASK_AGENT = os.environ.get("WOVED_TASK_AGENT", os.environ.get("AGENT", "")) +CALLBACK_URL = os.environ.get("WOVED_MANAGER_CALLBACK_URL", "").rstrip("/") +SLOT_INIT_TOKEN = os.environ.get("WOVED_SLOT_INIT_TOKEN", "") +AUTH_INIT_TIMEOUT_S = int(os.environ.get("WOVED_AUTH_INIT_TIMEOUT_S", "1800")) # 30 min default +POLL_INTERVAL_S = int(os.environ.get("WOVED_AUTH_INIT_POLL_S", "2")) + +# OAuth URL extraction. Anthropic's flow prints the device URL as a +# plain https link; we accept any https URL on a line that mentions +# `code` or `visit` or `verify` to stay tolerant of CLI version drift. +# TODO(WOVED-126): tighten this once we've seen real `claude /login` +# output and know the exact format. +URL_PATTERN = re.compile(rb"(https://[^\s\"'<>)]+)", re.IGNORECASE) +USER_CODE_PATTERN = re.compile(rb"\b([A-Z0-9]{4,8}-?[A-Z0-9]{4,8})\b") + +# Per-agent invocation. Empty for codex — codex doesn't use OAuth, so +# this script is claude-only. If/when Gemini gets an OAuth path, add it +# here with its CLI shape. +AGENT_INIT_CMDS = { + # TODO(WOVED-126): confirm this is the right invocation. If + # `claude /login` is a slash command (REPL only), spawn `claude` + # bare and type `/login\n` after the REPL prompt appears. + "claude": ["claude"], +} + + +def _die(msg: str, code: int = 1) -> None: + print(f"auth-init: FATAL: {msg}", file=sys.stderr) + sys.exit(code) + + +def _validate_env() -> None: + missing = [ + name + for name, val in ( + ("WOVED_SLOT_ID", SLOT_ID), + ("WOVED_TASK_AGENT", TASK_AGENT), + ("WOVED_MANAGER_CALLBACK_URL", CALLBACK_URL), + ("WOVED_SLOT_INIT_TOKEN", SLOT_INIT_TOKEN), + ) + if not val + ] + if missing: + _die(f"missing required env vars: {', '.join(missing)}") + if TASK_AGENT not in AGENT_INIT_CMDS: + _die( + f"agent {TASK_AGENT!r} does not need OAuth init " + f"(supported: {sorted(AGENT_INIT_CMDS)})" + ) + + +def _http_post(url: str, body: dict) -> int: # type: ignore[type-arg] + """POST with the per-slot bearer token in the X-Slot-Init-Token + header. Required by Manager-side auth (WOVED-128); without it the + Manager returns 401.""" + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request( + url, + data=data, + method="POST", + headers={ + "Content-Type": "application/json", + "X-Slot-Init-Token": SLOT_INIT_TOKEN, + }, + ) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + resp.read() + return resp.status + except urllib.error.HTTPError as exc: + return exc.code + except urllib.error.URLError as exc: + print(f"auth-init: POST {url} → transport error: {exc}", file=sys.stderr) + return 0 + + +def _http_get(url: str) -> tuple[int, dict | None]: # type: ignore[type-arg] + """GET with the per-slot bearer token in the X-Slot-Init-Token + header. WOVED-128 requirement same as POST.""" + req = urllib.request.Request( + url, + headers={"X-Slot-Init-Token": SLOT_INIT_TOKEN}, + ) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + return resp.status, json.loads(resp.read()) + except urllib.error.HTTPError as exc: + try: + return exc.code, json.loads(exc.read()) + except (json.JSONDecodeError, ValueError): + return exc.code, None + except urllib.error.URLError as exc: + print(f"auth-init: GET {url} → transport error: {exc}", file=sys.stderr) + return 0, None + + +def _post_url(url: str, user_code: str | None) -> None: + body: dict = {"url": url} # type: ignore[type-arg] + if user_code: + body["user_code"] = user_code + target = f"{CALLBACK_URL}/slots/{urllib.parse.quote(SLOT_ID, safe='')}/auth-init/url" + rc = _http_post(target, body) + if rc not in (200, 204): + print(f"auth-init: WARN: POST URL → HTTP {rc}", file=sys.stderr) + else: + print(f"auth-init: posted URL to {target}", file=sys.stderr) + + +def _poll_for_code(deadline: float) -> str | None: + """Long-poll the Manager for the operator-submitted auth code. + + Returns the code string when received. Returns None on timeout — + caller should kill the agent process + exit non-zero so the + Manager reconciler marks the slot ERROR. + """ + target = f"{CALLBACK_URL}/slots/{urllib.parse.quote(SLOT_ID, safe='')}/auth-init/code" + while time.time() < deadline: + status, body = _http_get(target) + if status == 200 and body and "code" in body: + print("auth-init: received code from Manager", file=sys.stderr) + return str(body["code"]) + if status == 401: + # Token rejected — likely env wiring bug. Logging it loudly + # so an operator sees this in the Job log and knows to look + # at the Pod manifest's WOVED_SLOT_INIT_TOKEN env. + print( + "auth-init: WARN: Manager returned 401 — check " + "WOVED_SLOT_INIT_TOKEN env vs Manager's registered token", + file=sys.stderr, + ) + time.sleep(POLL_INTERVAL_S) + continue + if status == 404: + time.sleep(POLL_INTERVAL_S) + continue + # Any other status: log + back off + retry. + print(f"auth-init: WARN: GET code → HTTP {status}, retrying", file=sys.stderr) + time.sleep(POLL_INTERVAL_S) + print(f"auth-init: timed out waiting for code after {AUTH_INIT_TIMEOUT_S}s", file=sys.stderr) + return None + + +def _drive_login() -> int: + """Spawn `claude` under a PTY, walk the OAuth dance, return its + exit code. Long function on purpose — the state machine is + inherently sequential and splitting it makes the flow harder to + follow than the inline form.""" + cmd = AGENT_INIT_CMDS[TASK_AGENT] + print(f"auth-init: spawning {cmd}", file=sys.stderr) + + pid, fd = pty.fork() + if pid == 0: # child + # exec the agent CLI; if it fails, the parent reads EOF. + os.execvp(cmd[0], cmd) + return 0 # unreachable + + # Parent: drive the dance. + deadline = time.time() + AUTH_INIT_TIMEOUT_S + url_posted = False + code_typed = False + buffer = b"" + rep_prompt_seen = False + + try: + while True: + # Has the child died? + wpid, status = os.waitpid(pid, os.WNOHANG) + if wpid != 0: + rc = os.WEXITSTATUS(status) if os.WIFEXITED(status) else 1 + print(f"auth-init: agent process exited rc={rc}", file=sys.stderr) + return rc + + # Bound the wait so we can re-check waitpid + deadline. + ready, _, _ = select.select([fd], [], [], 0.5) + if fd in ready: + try: + chunk = os.read(fd, 4096) + except OSError: + chunk = b"" + if not chunk: + # PTY closed; child exited. + continue + # Tee to our stderr so the Job log captures it. + sys.stderr.buffer.write(chunk) + sys.stderr.flush() + buffer += chunk + + if not url_posted: + # Look for the URL. + url_match = URL_PATTERN.search(buffer) + if url_match: + url = url_match.group(1).decode("utf-8", errors="replace") + # Look for an adjacent user code (best-effort). + code_match = USER_CODE_PATTERN.search(buffer) + user_code = ( + code_match.group(1).decode("utf-8", errors="replace") + if code_match + else None + ) + _post_url(url, user_code) + url_posted = True + buffer = b"" # clear so we don't re-match the same URL + + # If claude /login is a REPL slash command, look for the + # first prompt and inject /login. TODO(WOVED-126): + # confirm whether this is needed; remove the branch if + # `claude` auto-prompts OAuth on no-auth-state startup. + if not rep_prompt_seen and not url_posted and (b">" in buffer or b"$" in buffer): + print("auth-init: REPL prompt detected, sending /login", file=sys.stderr) + os.write(fd, b"/login\n") + rep_prompt_seen = True + buffer = b"" + + if url_posted and not code_typed: + code = _poll_for_code(deadline) + if code is None: + # Timed out. Kill the agent and bubble up. + try: + os.kill(pid, 15) # SIGTERM + os.waitpid(pid, 0) + except ProcessLookupError: + pass + return 124 # conventional timeout exit + # Type the code into the PTY. Trailing \n delivers it. + os.write(fd, code.encode("utf-8") + b"\n") + code_typed = True + print("auth-init: code piped into agent stdin", file=sys.stderr) + + if time.time() > deadline: + print("auth-init: deadline exceeded", file=sys.stderr) + try: + os.kill(pid, 15) + os.waitpid(pid, 0) + except ProcessLookupError: + pass + return 124 + finally: + try: + os.close(fd) + except OSError: + pass + + +def _verify_auth_landed() -> bool: + """Sanity-check that ~/.claude/ now has auth state. The exact + file(s) Claude Code writes vary across versions; we accept any + non-empty presence under ~/.claude/ that wasn't there before init. + + TODO(WOVED-126): tighten this once we know the exact filenames + (likely `credentials.json` or similar). For now, presence of any + file under ~/.claude/ that isn't the symlinked CLAUDE.md + instructions file is treated as success.""" + claude_dir = os.path.expanduser("~/.claude") + if not os.path.isdir(claude_dir): + return False + for entry in os.listdir(claude_dir): + path = os.path.join(claude_dir, entry) + if os.path.islink(path): + continue # CLAUDE.md is a symlink to agent-config + if os.path.isfile(path) and os.path.getsize(path) > 0: + return True + if os.path.isdir(path): + for _ in os.listdir(path): + return True + return False + + +def main() -> int: + _validate_env() + rc = _drive_login() + if rc != 0: + print(f"auth-init: agent exited non-zero ({rc})", file=sys.stderr) + return rc + if not _verify_auth_landed(): + print( + "auth-init: agent exited 0 but no auth state found under ~/.claude/ — " + "treating as failure", + file=sys.stderr, + ) + return 1 + print("auth-init: success — slot is ready for tasks", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bin/entrypoint.sh b/bin/entrypoint.sh index 118ff14..09328da 100644 --- a/bin/entrypoint.sh +++ b/bin/entrypoint.sh @@ -205,6 +205,11 @@ EOF # exits. Spawned by woveD Manager # as an ephemeral Job per task. # +# AGENT_MODE=auth-init — one-shot slot OAuth provisioning. +# Drives `claude /login` under +# operator supervision via Manager +# callback (per-slot bearer token). +# # Future modes (WOVED-126 follow-up): `auth-init` for slot OAuth provisioning. AGENT_MODE="${AGENT_MODE:-interactive}" @@ -217,6 +222,20 @@ worker) # WOVED_TASK_AGENT, WOVED_MANAGER_CALLBACK_URL. exec /usr/local/bin/worker.py ;; +auth-init) + # Slot OAuth provisioning (WOVED-126). One-shot init pod that + # drives `claude /login` under operator supervision: surfaces + # the OAuth URL via Manager callback, polls for the operator- + # submitted code, pipes it into the running CLI, exits 0 once + # auth state lands on the slot's PVC. Required env vars: + # WOVED_SLOT_ID — e.g. "claude-1" + # WOVED_TASK_AGENT — currently must be "claude" + # WOVED_MANAGER_CALLBACK_URL — Manager's slot auth-init base URL + # WOVED_SLOT_INIT_TOKEN — per-slot bearer token (WOVED-128) + # Optional: WOVED_AUTH_INIT_TIMEOUT_S (default 1800), + # WOVED_AUTH_INIT_POLL_S (default 2). + exec /usr/local/bin/auth_init.py + ;; interactive) # ttyd flags: # --writable : input enabled @@ -236,7 +255,7 @@ interactive) bash -lc "${AGENT_LAUNCH_CMD}" ;; *) - echo "FATAL: unknown AGENT_MODE=${AGENT_MODE} (expected interactive|worker)" >&2 + echo "FATAL: unknown AGENT_MODE=${AGENT_MODE} (expected interactive|worker|auth-init)" >&2 exit 1 ;; esac From 534a00f814e046404e9709a22b9b70fbe860cd04 Mon Sep 17 00:00:00 2001 From: Claude CoWork Date: Fri, 8 May 2026 23:12:27 -0700 Subject: [PATCH 2/2] WOVED-131: auth-init verifies new credentials, not pre-populated defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P1 cross-review of codex-shell#19: `_verify_auth_landed()` treated any non-empty file or directory under ~/.claude/ as successful auth. The entrypoint pre-populates that directory from defaults/config + the agent-config CLAUDE.md symlink BEFORE AGENT_MODE=auth-init runs, so an init pod could report success even when claude exited without actually writing credentials. Fix: snapshot-diff. `_snapshot_claude_dir()` walks ~/.claude/ and returns {relpath: (size, mtime)}; main() takes a `before` snapshot right after env validation, runs the login dance, takes an `after` snapshot, and `_verify_new_auth_artifacts(before, after)` returns True iff the after-set has new files OR existing files with changed size/mtime. Symlinks excluded from the snapshot — CLAUDE.md is a stable symlink to agent-config that would otherwise show false differences across runs (mtime jitters when the entrypoint re-runs the agent-config clone). This is robust to the WOVED-126 TODO uncertainty around exact Claude Code credential filenames: ANYTHING new or modified after the login dance counts as success, no need to hardcode filenames that may drift across CLI versions. Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/auth_init.py | 91 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 69 insertions(+), 22 deletions(-) diff --git a/bin/auth_init.py b/bin/auth_init.py index 6e25332..eb1f601 100644 --- a/bin/auth_init.py +++ b/bin/auth_init.py @@ -306,40 +306,87 @@ def _drive_login() -> int: pass -def _verify_auth_landed() -> bool: - """Sanity-check that ~/.claude/ now has auth state. The exact - file(s) Claude Code writes vary across versions; we accept any - non-empty presence under ~/.claude/ that wasn't there before init. - - TODO(WOVED-126): tighten this once we know the exact filenames - (likely `credentials.json` or similar). For now, presence of any - file under ~/.claude/ that isn't the symlinked CLAUDE.md - instructions file is treated as success.""" +def _snapshot_claude_dir() -> dict[str, tuple[int, float]]: + """Return a snapshot of ~/.claude/ contents: {relpath: (size, mtime)}. + + WOVED-131: the entrypoint pre-populates ~/.claude/ from + image-baked defaults + the agent-config CLAUDE.md symlink BEFORE + AGENT_MODE=auth-init runs. A naive "any file under ~/.claude/ + means success" check would false-positive even when claude exits + without writing real credentials. The fix is to snapshot + before + after the login dance and assert the after-set differs + from the before-set (new files OR existing files with changed + size/mtime). Symlinks are excluded — CLAUDE.md is a stable + symlink to agent-config that would otherwise show false + differences across runs.""" claude_dir = os.path.expanduser("~/.claude") + snapshot: dict[str, tuple[int, float]] = {} if not os.path.isdir(claude_dir): - return False - for entry in os.listdir(claude_dir): - path = os.path.join(claude_dir, entry) - if os.path.islink(path): - continue # CLAUDE.md is a symlink to agent-config - if os.path.isfile(path) and os.path.getsize(path) > 0: - return True - if os.path.isdir(path): - for _ in os.listdir(path): - return True + return snapshot + for root, dirs, files in os.walk(claude_dir, followlinks=False): + # Skip the dir itself if it's a symlink (defense in depth). + for name in files: + full = os.path.join(root, name) + try: + st = os.lstat(full) + except OSError: + continue + # Skip symlinks — CLAUDE.md is a symlink to agent-config + # that mtime-jitters on every boot. + import stat as _stat + if _stat.S_ISLNK(st.st_mode): + continue + rel = os.path.relpath(full, claude_dir) + snapshot[rel] = (st.st_size, st.st_mtime) + return snapshot + + +def _verify_new_auth_artifacts( + before: dict[str, tuple[int, float]], + after: dict[str, tuple[int, float]], +) -> bool: + """True iff `after` contains real evidence of a successful login — + a file that wasn't in `before`, OR an existing file whose size/mtime + changed. Catches the WOVED-131 false-positive where pre-populated + config files would otherwise pass a naive presence check. + + A successful Claude Code OAuth login writes credentials to + ~/.claude/ (exact filename TBD per CLI version — typically + something like `credentials.json` or `.credentials/`). The + snapshot-diff approach is robust to that uncertainty: anything + new or modified relative to the pre-login state counts.""" + new_files = set(after) - set(before) + if new_files: + print(f"auth-init: new file(s) under ~/.claude/: {sorted(new_files)}", file=sys.stderr) + return True + modified = [ + rel + for rel in set(after) & set(before) + if after[rel] != before[rel] + ] + if modified: + print(f"auth-init: modified file(s) under ~/.claude/: {sorted(modified)}", file=sys.stderr) + return True return False def main() -> int: _validate_env() + # WOVED-131: snapshot ~/.claude/ before the login dance so the + # post-login verify can prove that real credential files appeared + # (vs. just the entrypoint's pre-populated defaults). + before = _snapshot_claude_dir() rc = _drive_login() if rc != 0: print(f"auth-init: agent exited non-zero ({rc})", file=sys.stderr) return rc - if not _verify_auth_landed(): + after = _snapshot_claude_dir() + if not _verify_new_auth_artifacts(before, after): print( - "auth-init: agent exited 0 but no auth state found under ~/.claude/ — " - "treating as failure", + "auth-init: agent exited 0 but no NEW or MODIFIED files under " + "~/.claude/ — treating as failure (entrypoint pre-populates " + "defaults; WOVED-131 requires that the login dance produce " + "evidence beyond what was already there)", file=sys.stderr, ) return 1