Skip to content

Add scripts/gen-api-key.py to generate/rotate the served-API bearer key - #37

Merged
OriNachum merged 2 commits into
mainfrom
feat/api-key-gen-script
Jun 9, 2026
Merged

Add scripts/gen-api-key.py to generate/rotate the served-API bearer key#37
OriNachum merged 2 commits into
mainfrom
feat/api-key-gen-script

Conversation

@OriNachum

@OriNachum OriNachum commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

PR Summary by Qodo

Add gen-api-key script to mint/rotate CULTURE_VLLM_API_KEY safely
✨ Enhancement 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 20-40 Minutes

Grey Divider

Walkthroughs

User Description

What

Adds scripts/gen-api-key.py — generate or rotate the bearer key
(CULTURE_VLLM_API_KEY) that gates the served vLLM API.

Why

CULTURE_VLLM_API_KEY is the gate for exposing the API publicly via model tunnel (#35), but there was no first-class, repeatable way to mint it — leaving
ad-hoc openssl rand one-liners that risk leaking the secret into shell history
or committed config.

Open-source-safe by construction

  • The secret is generated with the stdlib secrets module and is never
    hardcoded
    — the script carries no secret, so it's safe in this public repo.
  • The key only ever lands in the gitignored deployment .env, written 0o600.
  • Hidden by default (no echo into logs/scrollback); --show prints it, --force
    rotates an existing key.
  • Resolves the deployment dir like the model CLI (--dir$MODEL_GEAR_DIR
    ~/.model-gear); stdlib-only, no model_gear import, so it runs from a wheel
    install too.

Tests

tests/test_gen_api_key.py covers set-when-absent, refuse-overwrite-without-force,
--force rotate, missing-dir env error, --show matches .env, no-leak by
default, and 0o600 perms.

Docs

README "Expose the API from anywhere" section now points at the script. Version
bumped 0.18.0 → 0.18.1.

  • model-gear (Claude)
AI Description
• Add a stdlib-only script to generate/rotate CULTURE_VLLM_API_KEY into gitignored .env.
• Prevent accidental secret leakage by default; require --show to print and --force to rotate.
• Add tests for overwrite rules, output behavior, missing dir handling, and 0o600 file perms.
Diagram
graph TD
  U["Operator"] --> S["scripts/gen-api-key.py"] --> E[("Deployment .env")]
  E --> M["model serve --apply"] --> V["vLLM API"]
  V --> T["model tunnel"] --> C["Public client"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Add a first-class `model api-key` subcommand
  • ➕ Discovers naturally via existing CLI help and docs
  • ➕ Can reuse existing deployment-dir resolution and error codes
  • ➕ Consistent UX/JSON output conventions with other commands
  • ➖ Requires importing package code; no longer a trivial standalone script
  • ➖ Slightly higher maintenance surface (CLI plumbing, backwards compatibility)
2. Generate the key during `model init --apply` (opt-in)
  • ➕ Makes the secure path the default during initial scaffold
  • ➕ Avoids separate operational step before model tunnel
  • ➖ Doesn't help with later rotation workflows unless additional commands are added
  • init may be undesirable to mutate secrets automatically without explicit intent
3. Reuse `model_gear.runtime._compose.resolve_deployment_dir` in the script
  • ➕ Guaranteed identical directory resolution and error semantics as the CLI
  • ➖ Introduces a package dependency/import, reducing the 'wheel install + stdlib-only' portability goal

Recommendation: The standalone stdlib-only script is a good fit for a public repo and wheel installs: it keeps secret generation isolated, avoids accidental printing, and writes only into the gitignored .env with 0600. If this workflow becomes central for operators, consider later adding a model api-key subcommand that wraps the same logic for better discoverability and consistent CLI ergonomics, while keeping the script as a thin shim (or documented fallback).

Grey Divider

File Changes

Enhancement (1)
gen-api-key.py Add standalone generator/rotator for CULTURE_VLLM_API_KEY +117/-0

Add standalone generator/rotator for CULTURE_VLLM_API_KEY

• Introduces a stdlib-only CLI to set or rotate 'CULTURE_VLLM_API_KEY' in the deployment '.env', refusing overwrite unless '--force' is used. By default it does not print the secret (unless '--show') and enforces '0o600' permissions on the '.env' file.

scripts/gen-api-key.py


Tests (1)
test_gen_api_key.py Add unit tests for gen-api-key script behavior and safety +74/-0

Add unit tests for gen-api-key script behavior and safety

• Adds tests that load the script by file path and validate set-when-absent behavior, overwrite refusal without '--force', rotation with '--force', missing deployment dir error handling, stdout behavior for '--show', no secret leakage by default, and '0o600' permissions enforcement.

tests/test_gen_api_key.py


Documentation (2)
CHANGELOG.md Document new API key generation script in 0.18.1 notes +13/-0

Document new API key generation script in 0.18.1 notes

• Adds a 0.18.1 release entry describing the new 'scripts/gen-api-key.py' behavior, safety properties, and where it’s referenced in documentation.

CHANGELOG.md


README.md Point tunnel docs at gen-api-key script for bearer key setup +5/-2

Point tunnel docs at gen-api-key script for bearer key setup

• Updates the "Expose the API" guidance to use 'python3 scripts/gen-api-key.py' for generating/rotating 'CULTURE_VLLM_API_KEY', including '--show' and '--force' hints and the need to apply via 'model serve --apply'.

README.md


Other (1)
pyproject.toml Bump project version to 0.18.1 +1/-1

Bump project version to 0.18.1

• Increments the package version from 0.18.0 to 0.18.1 to reflect the added script, tests, and docs updates.

pyproject.toml


Grey Divider

Qodo Logo

`CULTURE_VLLM_API_KEY` gates the vLLM API (and is mandatory before exposing it
via `model tunnel`), but there was no first-class way to mint it. This adds a
small, stdlib-only generator that:

- creates the key with `secrets.token_urlsafe` and NEVER hardcodes a secret, so
  the script is safe in the open-source repo; the key only lands in the
  gitignored deployment `.env` (written 0o600);
- hides the key by default (no echo into logs/scrollback); `--show` prints it,
  `--force` rotates an existing key;
- resolves the deployment dir like the `model` CLI (`--dir` > $MODEL_GEAR_DIR >
  ~/.model-gear), and runs from a wheel install (no model_gear import).

Tests cover set/rotate/refuse-overwrite/missing-dir/show/no-leak/0o600. README
"Expose the API" section now points at it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@OriNachum

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

qodo-code-review Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code Review by Qodo

Grey Divider

New Review Started

This review has been superseded by a new analysis

Grey Divider

Qodo Logo

@qodo-code-review

qodo-code-review Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)

Context used
✅ Compliance rules (platform): 35 rules

Grey Divider


Action required

1. .env unreadable crash ✓ Resolved 🐞 Bug ☼ Reliability
Description
_read_key() returns None on OSError, but _write_key() still calls env_path.read_text() whenever the
path exists; if .env exists but is unreadable (or is a directory), the script crashes with an
uncaught exception. This creates a confusing failure mode exactly when the deployment env has
permission/type issues.
Code

scripts/gen-api-key.py[R45-60]

+def _read_key(env_path: Path) -> str | None:
+    try:
+        text = env_path.read_text(encoding="utf-8")
+    except OSError:
+        return None
+    prefix = KEY + "="
+    for line in text.splitlines():
+        if line.startswith(prefix):
+            value = line[len(prefix) :]
+            return value or None
+    return None
+
+
+def _write_key(env_path: Path, value: str) -> None:
+    lines = env_path.read_text(encoding="utf-8").splitlines() if env_path.exists() else []
+    out: list[str] = []
Evidence
The script’s read path explicitly swallows OSError, but the write path will still attempt a read
when the file exists and can raise. The repo’s env reader shows that unreadable .env is an
anticipated scenario and should not crash tooling.

scripts/gen-api-key.py[45-60]
scripts/gen-api-key.py[58-72]
model_gear/runtime/_env.py[16-31]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`_read_key()` tolerates unreadable `.env` by returning `None`, but `_write_key()` reads the file if it exists and does not catch exceptions. This can lead to an uncaught exception when `.env` exists but cannot be read (permissions) or is not a regular file.

### Issue Context
The runtime env reader (`model_gear.runtime._env.read_env`) treats unreadable env files as a normal error case and degrades gracefully.

### Fix
Add explicit preflight/error handling before attempting to rotate:
- If `.env` exists and is not a regular file, print a clear error and exit non-zero.
- Wrap the read/update/write sequence in `try/except OSError` and return a structured error code/message instead of crashing.

### Fix Focus Areas
- scripts/gen-api-key.py[45-72]
- scripts/gen-api-key.py[88-105]
- model_gear/runtime/_env.py[16-31]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Changelog uses ~/.model-gear ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
CHANGELOG.md includes a per-user dotfile path ~/.model-gear, which violates the docs/config
portability rule for dotfile references. This can mislead users and breaks the repository policy
that only allows specific dotfile carve-outs.
Code

CHANGELOG.md[17]

+  the `model` CLI (`--dir` → `$MODEL_GEAR_DIR` → `~/.model-gear`). Referenced from
Evidence
The rule forbids ~/\.[A-Za-z] dotfile references in markdown unless they match specific
carve-outs. The new changelog entry contains ~/.model-gear, which matches the forbidden pattern
and is not an allowed carve-out.

Rule 796119: No per-user dotfile config references in docs/configs (with specified carve-outs)
CHANGELOG.md[7-18]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`CHANGELOG.md` contains a per-user dotfile reference (`~/.model-gear`) that is disallowed by policy (only `~/.claude/skills/<name>/scripts/` and `~/.culture/` are permitted).

## Issue Context
The entry currently documents deploy-dir precedence using `~/.model-gear`. Update the text to use a portable form like `$HOME/.model-gear`, `${HOME}/.model-gear`, or refer to `$MODEL_GEAR_DIR` + “defaults to the user home directory’s `.model-gear`” without the `~/` form.

## Fix Focus Areas
- CHANGELOG.md[7-18]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Unhandled chmod failure ✓ Resolved 🐞 Bug ☼ Reliability
Description
scripts/gen-api-key.py calls os.chmod() without handling OSError, so the script can crash after
writing the secret on platforms/filesystems where chmod is unsupported or denied. Elsewhere in the
codebase, chmod is treated as best-effort to avoid breaking workflows.
Code

scripts/gen-api-key.py[R70-71]

+    env_path.write_text("\n".join(out) + "\n", encoding="utf-8")
+    os.chmod(env_path, 0o600)  # the .env holds a secret — keep it owner-only
Evidence
The script performs an unconditional chmod, while the existing deployment scaffolder explicitly
guards chmod with try/except because it can fail on some systems; this indicates the script should
handle the same failure mode.

scripts/gen-api-key.py[58-72]
model_gear/runtime/_compose.py[149-156]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`scripts/gen-api-key.py` unconditionally calls `os.chmod(env_path, 0o600)`. If chmod fails (unsupported filesystem, Windows semantics, permission issues), the script crashes even though the `.env` write may have succeeded.

### Issue Context
The repo already treats `.env` permission hardening as best-effort during scaffolding to avoid platform-specific failures.

### Fix
Wrap the chmod call in `try/except OSError` and either:
- silently ignore (consistent with scaffold behavior), or
- emit a non-fatal warning to stderr.

### Fix Focus Areas
- scripts/gen-api-key.py[58-72]
- model_gear/runtime/_compose.py[149-156]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Version bump likely under-scoped ✓ Resolved 📘 Rule violation ≡ Correctness
Description
The PR adds a new user-facing script (scripts/gen-api-key.py) and tests/docs, which is a new
feature, but the version bump is only patch (0.18.00.18.1). This may violate the required
mapping between change type and SemVer bump type.
Code

pyproject.toml[3]

+version = "0.18.1"
Evidence
pyproject.toml shows a patch bump to 0.18.1, while the changelog and added script indicate a
newly added capability. Under the rule’s criteria, new features should be a minor bump rather than
patch.

Rule 796064: Version bump type must match the nature of the changes
pyproject.toml[1-4]
scripts/gen-api-key.py[1-22]
CHANGELOG.md[7-18]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A new feature (new script/behavior exposed to users) was added, but the PR bumps only the patch version.

## Issue Context
The compliance rule requires minor bumps for new features/new modules. This PR introduces `scripts/gen-api-key.py` and documents it.

## Fix Focus Areas
- pyproject.toml[1-4]
- CHANGELOG.md[7-18]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Unvalidated bytes parameter ✓ Resolved 🐞 Bug ≡ Correctness
Description
--bytes is passed directly into secrets.token_urlsafe(); negative values raise ValueError (uncaught)
and 0 produces a token with no entropy beyond the fixed prefix. This allows accidental generation of
a weak key or a stack-trace failure on invalid input.
Code

scripts/gen-api-key.py[R79-104]

+    parser.add_argument("--force", action="store_true", help="Rotate even if a key already exists.")
+    parser.add_argument(
+        "--bytes", type=int, default=32, help="Token entropy in bytes (default: 32)."
+    )
+    parser.add_argument(
+        "--show", action="store_true", help="Print the key on stdout (else hidden)."
+    )
+    args = parser.parse_args(argv)
+
+    env_path = _deploy_dir(args.dir) / ".env"
+    if not env_path.parent.is_dir():
+        print(
+            f"error: deployment dir {env_path.parent} not found",
+            file=sys.stderr,
+        )
+        print("hint: run 'model init --apply' to scaffold it first", file=sys.stderr)
+        return 2
+
+    existing = _read_key(env_path)
+    if existing and not args.force:
+        print(f"error: {KEY} is already set in {env_path}", file=sys.stderr)
+        print("hint: pass --force to rotate it", file=sys.stderr)
+        return 1
+
+    token = PREFIX + secrets.token_urlsafe(args.bytes)
+    _write_key(env_path, token)
Evidence
The CLI defines --bytes and then uses it directly in secrets.token_urlsafe(args.bytes) without
any range checks or exception handling, so invalid or too-small values can cause failures or weak
output.

scripts/gen-api-key.py[74-105]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The script accepts `--bytes` as any integer and passes it to `secrets.token_urlsafe()`. Negative values can raise `ValueError` and `0` yields an effectively empty-entropy token (only the constant prefix).

### Issue Context
This script is intended to produce a strong bearer key by default; input validation prevents accidental insecure keys and avoids unhandled exceptions.

### Fix
Before generating the token:
- Validate `args.bytes` is an integer > 0.
- Consider enforcing a minimum (e.g., >= 16) and emitting a clear error message + non-zero exit code when violated.
- Optionally catch `ValueError` from `token_urlsafe` and convert it into a friendly error.

### Fix Focus Areas
- scripts/gen-api-key.py[74-105]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread CHANGELOG.md Outdated
Comment thread scripts/gen-api-key.py
Triage of Qodo findings on #37:

- #1 (rule 796119): CHANGELOG `~/.model-gear` → `$HOME/.model-gear`. (The .py
  --help/docstring keep `~/.model-gear` to match the repo-wide CLI help text;
  Qodo's docs/config rule only flags the markdown.)
- #2 (unreadable/dir .env crash): main() preflights that .env is a regular file
  and wraps the read/update/write in try/except OSError -> EXIT_ENV_ERROR,
  matching _read_key()'s graceful degradation.
- #3 (under-scoped bump): a new documented capability is a minor, not a patch —
  0.18.1 -> 0.19.0.
- #4 (unhandled chmod): os.chmod is now best-effort (try/except OSError with a
  note), so a chmod-unsupported FS doesn't crash after a successful write.
- #5 (unvalidated --bytes): reject `< 16` (128-bit floor) with a user error
  before generating, so no weak key or token_urlsafe stack trace.

New tests: too-few-bytes and non-regular-file .env.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@OriNachum

Copy link
Copy Markdown
Contributor Author

Addressed the summary-only findings in 154b0bb:

#3 — version bump under-scoped. Agreed: a new documented capability is a minor, not a patch. Bumped 0.18.1 → 0.19.0 (pyproject + CHANGELOG + uv.lock).

#4 — unhandled chmod failure. os.chmod(.env, 0o600) is now best-effort (try/except OSError with a >> note:), so a chmod-unsupported filesystem doesn't crash after a successful write.

#5 — unvalidated --bytes. Now rejected when < 16 (a 128-bit floor) with a user error before any key is generated — no weak key and no secrets.token_urlsafe stack trace on bad input. New test_rejects_too_few_bytes.

Gate after the fixes: 263 tests pass, black/isort/flake8 clean, bandit 0/0/0, markdownlint 0, afi cli doctor . --strict exit 0.

  • model-gear (Claude)

@sonarqubecloud

sonarqubecloud Bot commented Jun 9, 2026

Copy link
Copy Markdown

@OriNachum
OriNachum merged commit f4c9f10 into main Jun 9, 2026
10 of 11 checks passed
@OriNachum
OriNachum deleted the feat/api-key-gen-script branch June 9, 2026 05:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant