Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f72785536a |
@@ -24,19 +24,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Run pylint
|
- name: Run pylint
|
||||||
run: |
|
run: |
|
||||||
# Pylint's normal exit code is nonzero for any emitted finding,
|
# Run pylint on all Python files in the repo
|
||||||
# regardless of --fail-under. Preserve the full report but enforce
|
find . -name '*.py' -not -path './.venv/*' -not -path './.git/*' | xargs pylint --fail-under=8.0
|
||||||
# the aggregate score this workflow promises.
|
|
||||||
set +e
|
|
||||||
find . -name '*.py' -not -path './.venv/*' -not -path './.git/*' \
|
|
||||||
| xargs pylint --fail-under=8.0 \
|
|
||||||
| tee /tmp/pylint-output.txt
|
|
||||||
set -e
|
|
||||||
SCORE=$(sed -n \
|
|
||||||
's/^Your code has been rated at \([-0-9.]*\)\/10.*/\1/p' \
|
|
||||||
/tmp/pylint-output.txt | tail -1)
|
|
||||||
test -n "$SCORE"
|
|
||||||
awk -v score="$SCORE" 'BEGIN { exit !(score >= 8.0) }'
|
|
||||||
|
|
||||||
- name: Run pyright
|
- name: Run pyright
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
name: tracker-policy
|
|
||||||
|
|
||||||
on:
|
|
||||||
issues:
|
|
||||||
types: [opened, unlabeled]
|
|
||||||
pull_request:
|
|
||||||
types: [opened, edited, reopened, synchronized, labeled, unlabeled]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
label-issue:
|
|
||||||
if: ${{ github.event_name == 'issues' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Ensure the issue has a label
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: python3 scripts/tracker_policy.py label-issue
|
|
||||||
|
|
||||||
check-pr:
|
|
||||||
if: ${{ github.event_name == 'pull_request' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
issues: read
|
|
||||||
pull-requests: read
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Require an unlabeled PR linked to an issue
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: python3 scripts/tracker_policy.py check-pr
|
|
||||||
@@ -171,15 +171,6 @@ When an outbound DLP detector matches a token, the route's `dlp.outbound_on_matc
|
|||||||
|
|
||||||
More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`.
|
More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`.
|
||||||
|
|
||||||
## Tracker policy
|
|
||||||
|
|
||||||
Issues are the canonical work items and own all tracker labels; every issue
|
|
||||||
must have at least one. Pull requests stay unlabeled and deliberately reference
|
|
||||||
an issue with `Closes #…`, `Part of #…`, or another form defined in
|
|
||||||
[`ADR 0005`](docs/decisions/0005-issues-own-tracker-metadata.md). Gitea Actions
|
|
||||||
enforces the convention for new work from 2026-07-18 onward. Earlier closed
|
|
||||||
PRs are grandfathered rather than given artificial retrospective issues.
|
|
||||||
|
|
||||||
## Trademarks
|
## Trademarks
|
||||||
|
|
||||||
bot-bottle is an independent project and is not affiliated with, endorsed by, or sponsored by Anthropic, PBC. "Claude" and "Claude Code" are trademarks of Anthropic, PBC; the project name uses "claude" descriptively to indicate that the tool runs Claude Code inside a sandbox.
|
bot-bottle is an independent project and is not affiliated with, endorsed by, or sponsored by Anthropic, PBC. "Claude" and "Claude Code" are trademarks of Anthropic, PBC; the project name uses "claude" descriptively to indicate that the tool runs Claude Code inside a sandbox.
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ PROVIDER_TEMPLATES = frozenset({PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_PI})
|
|||||||
# forward_host_credentials is enabled. Pipelock must pass these through
|
# forward_host_credentials is enabled. Pipelock must pass these through
|
||||||
# (no TLS MITM) or its header DLP blocks the injected JWT.
|
# (no TLS MITM) or its header DLP blocks the injected JWT.
|
||||||
CODEX_HOST_CREDENTIAL_HOSTS = ("api.openai.com", "chatgpt.com")
|
CODEX_HOST_CREDENTIAL_HOSTS = ("api.openai.com", "chatgpt.com")
|
||||||
|
|
||||||
|
# Host that egress injects the host Claude bearer on when Claude
|
||||||
|
# forward_host_credentials is enabled.
|
||||||
|
CLAUDE_HOST_CREDENTIAL_HOSTS = ("api.anthropic.com",)
|
||||||
PromptMode = Literal[
|
PromptMode = Literal[
|
||||||
"append_file",
|
"append_file",
|
||||||
"read_prompt_file",
|
"read_prompt_file",
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ from ...agent_provider import (
|
|||||||
provider_startup_args,
|
provider_startup_args,
|
||||||
)
|
)
|
||||||
from ...backend.docker import util as docker_mod
|
from ...backend.docker import util as docker_mod
|
||||||
from ...egress import EgressRoute
|
from ...egress import CLAUDE_HOST_CREDENTIAL_TOKEN_REF, EgressRoute
|
||||||
from ...log import die, info, warn
|
from ...log import die, info, warn
|
||||||
|
from .claude_auth import claude_host_access_token
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -118,7 +119,6 @@ class ClaudeAgentProvider(AgentProvider):
|
|||||||
color: str = "",
|
color: str = "",
|
||||||
provider_settings: dict[str, object] | None = None,
|
provider_settings: dict[str, object] | None = None,
|
||||||
) -> AgentProvisionPlan:
|
) -> AgentProvisionPlan:
|
||||||
del forward_host_credentials, host_env
|
|
||||||
resolved_guest_env = dict(guest_env or {})
|
resolved_guest_env = dict(guest_env or {})
|
||||||
startup_args = provider_startup_args(provider_settings)
|
startup_args = provider_startup_args(provider_settings)
|
||||||
guest_home = self.guest_home
|
guest_home = self.guest_home
|
||||||
@@ -180,13 +180,24 @@ class ClaudeAgentProvider(AgentProvider):
|
|||||||
claude_settings,
|
claude_settings,
|
||||||
f"{guest_home}/.claude/settings.json",
|
f"{guest_home}/.claude/settings.json",
|
||||||
))
|
))
|
||||||
|
provisioned_env: dict[str, str] = {}
|
||||||
|
if forward_host_credentials:
|
||||||
|
_host_env = host_env or dict(os.environ)
|
||||||
|
provisioned_env[CLAUDE_HOST_CREDENTIAL_TOKEN_REF] = (
|
||||||
|
claude_host_access_token(_host_env)
|
||||||
|
)
|
||||||
|
|
||||||
|
cred_token_ref = (
|
||||||
|
CLAUDE_HOST_CREDENTIAL_TOKEN_REF if forward_host_credentials
|
||||||
|
else auth_token
|
||||||
|
)
|
||||||
egress_routes = (EgressRoute(
|
egress_routes = (EgressRoute(
|
||||||
host="api.anthropic.com",
|
host="api.anthropic.com",
|
||||||
auth_scheme="Bearer" if auth_token else "",
|
auth_scheme="Bearer" if (auth_token or forward_host_credentials) else "",
|
||||||
token_ref=auth_token,
|
token_ref=cred_token_ref,
|
||||||
),)
|
),)
|
||||||
hidden_env_names: frozenset[str] = frozenset()
|
hidden_env_names: frozenset[str] = frozenset()
|
||||||
if auth_token:
|
if auth_token or forward_host_credentials:
|
||||||
env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
|
env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
|
||||||
hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"})
|
hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"})
|
||||||
|
|
||||||
@@ -208,6 +219,7 @@ class ClaudeAgentProvider(AgentProvider):
|
|||||||
files=tuple(files),
|
files=tuple(files),
|
||||||
egress_routes=egress_routes,
|
egress_routes=egress_routes,
|
||||||
hidden_env_names=hidden_env_names,
|
hidden_env_names=hidden_env_names,
|
||||||
|
provisioned_env=provisioned_env,
|
||||||
)
|
)
|
||||||
|
|
||||||
def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Host Claude auth helpers.
|
||||||
|
|
||||||
|
Reads the host's Claude Code credentials and returns only the access
|
||||||
|
token needed by egress. Does not expose refresh tokens or raw payloads.
|
||||||
|
|
||||||
|
Credential storage by platform:
|
||||||
|
Linux — ~/.claude/.credentials.json
|
||||||
|
macOS — macOS Keychain, service "Claude Code-credentials"
|
||||||
|
(file path is tried first; Keychain is the fallback)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ...log import die
|
||||||
|
|
||||||
|
|
||||||
|
_KEYCHAIN_SERVICE = "Claude Code-credentials"
|
||||||
|
|
||||||
|
|
||||||
|
def claude_auth_path(host_env: dict[str, str] | None = None) -> Path:
|
||||||
|
env = os.environ if host_env is None else host_env
|
||||||
|
home = env.get("HOME")
|
||||||
|
if home:
|
||||||
|
return Path(home) / ".claude" / ".credentials.json"
|
||||||
|
return Path.home() / ".claude" / ".credentials.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_keychain() -> dict[str, object] | None:
|
||||||
|
"""Try the macOS Keychain. Returns parsed JSON dict or None."""
|
||||||
|
if sys.platform != "darwin":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["security", "find-generic-password", "-s", _KEYCHAIN_SERVICE, "-w"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
return None
|
||||||
|
if result.returncode != 0 or not result.stdout.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
raw = json.loads(result.stdout.strip())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
return raw if isinstance(raw, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def claude_host_access_token(
|
||||||
|
host_env: dict[str, str] | None = None,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> str:
|
||||||
|
path = claude_auth_path(host_env)
|
||||||
|
raw: dict[str, object] | None = None
|
||||||
|
|
||||||
|
if path.is_file():
|
||||||
|
try:
|
||||||
|
raw = json.loads(path.read_text())
|
||||||
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
|
die(f"claude host credentials: could not read valid JSON at {path}: {e}")
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
die(f"claude host credentials: {path} must contain a JSON object")
|
||||||
|
else:
|
||||||
|
raw = _read_keychain()
|
||||||
|
if raw is None:
|
||||||
|
die(
|
||||||
|
f"claude host credentials: auth file missing at {path} and "
|
||||||
|
f"macOS Keychain lookup for '{_KEYCHAIN_SERVICE}' failed. "
|
||||||
|
"Run `claude login` on the host or disable "
|
||||||
|
"agent_provider.forward_host_credentials."
|
||||||
|
)
|
||||||
|
|
||||||
|
oauth = raw.get("claudeAiOauth")
|
||||||
|
if not isinstance(oauth, dict):
|
||||||
|
die(
|
||||||
|
"claude host credentials: claudeAiOauth is missing from credentials. "
|
||||||
|
"Run `claude login` on the host or disable "
|
||||||
|
"agent_provider.forward_host_credentials."
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = oauth.get("accessToken")
|
||||||
|
if not isinstance(access_token, str) or not access_token:
|
||||||
|
die(
|
||||||
|
"claude host credentials: claudeAiOauth.accessToken is missing or empty. "
|
||||||
|
"Run `claude login` on the host and restart the bottle."
|
||||||
|
)
|
||||||
|
|
||||||
|
# expiresAt is in milliseconds
|
||||||
|
expires_at = oauth.get("expiresAt")
|
||||||
|
if isinstance(expires_at, (int, float)):
|
||||||
|
check_now = now or datetime.now(timezone.utc)
|
||||||
|
exp_dt = datetime.fromtimestamp(float(expires_at) / 1000.0, timezone.utc)
|
||||||
|
if exp_dt <= check_now:
|
||||||
|
die(
|
||||||
|
"claude host credentials: host Claude access token is expired. "
|
||||||
|
"Run `claude login` on the host and restart the bottle."
|
||||||
|
)
|
||||||
|
|
||||||
|
return access_token
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"claude_auth_path",
|
||||||
|
"claude_host_access_token",
|
||||||
|
]
|
||||||
@@ -30,6 +30,7 @@ if TYPE_CHECKING:
|
|||||||
from .manifest import ManifestBottle
|
from .manifest import ManifestBottle
|
||||||
|
|
||||||
CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
|
CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
|
||||||
|
CLAUDE_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CLAUDE_HOST_ACCESS_TOKEN"
|
||||||
|
|
||||||
EGRESS_HOSTNAME = "egress"
|
EGRESS_HOSTNAME = "egress"
|
||||||
|
|
||||||
@@ -400,6 +401,7 @@ class Egress(ABC):
|
|||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"CLAUDE_HOST_CREDENTIAL_TOKEN_REF",
|
||||||
"CODEX_HOST_CREDENTIAL_TOKEN_REF",
|
"CODEX_HOST_CREDENTIAL_TOKEN_REF",
|
||||||
"EGRESS_HOSTNAME",
|
"EGRESS_HOSTNAME",
|
||||||
"EGRESS_ROUTES_FILENAME",
|
"EGRESS_ROUTES_FILENAME",
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ class ManifestAgentProvider:
|
|||||||
header, and sets a placeholder CLAUDE_CODE_OAUTH_TOKEN in the agent
|
header, and sets a placeholder CLAUDE_CODE_OAUTH_TOKEN in the agent
|
||||||
so the Claude Code CLI starts.
|
so the Claude Code CLI starts.
|
||||||
|
|
||||||
`forward_host_credentials` forwards the host Codex auth token into
|
`forward_host_credentials` forwards the host provider auth token into
|
||||||
the egress daemon (Codex only).
|
the egress sidecar (Codex and Claude). For Codex this reads
|
||||||
|
`~/.codex/auth.json`; for Claude it reads `~/.claude/.credentials.json`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
template: str = "claude"
|
template: str = "claude"
|
||||||
@@ -92,10 +93,15 @@ class ManifestAgentProvider:
|
|||||||
f"is only supported for built-in templates "
|
f"is only supported for built-in templates "
|
||||||
f"({', '.join(sorted(PROVIDER_TEMPLATES))})"
|
f"({', '.join(sorted(PROVIDER_TEMPLATES))})"
|
||||||
)
|
)
|
||||||
if forward_host_credentials and template != "codex":
|
if forward_host_credentials and template not in {"codex", "claude"}:
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
|
f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
|
||||||
"is currently only supported for template 'codex'"
|
"is only supported for templates 'codex' and 'claude'"
|
||||||
|
)
|
||||||
|
if forward_host_credentials and auth_token:
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
|
||||||
|
"and auth_token both set; use one or the other"
|
||||||
)
|
)
|
||||||
settings = _parse_provider_settings(bottle_name, template, d.get("settings"))
|
settings = _parse_provider_settings(bottle_name, template, d.get("settings"))
|
||||||
return cls(
|
return cls(
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
# ADR 0005: Keep tracker metadata on issues
|
|
||||||
|
|
||||||
- **Status:** Accepted
|
|
||||||
- **Date:** 2026-07-18
|
|
||||||
- **Deciders:** didericis
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Gitea exposes labels on both issues and pull requests. Applying the same labels
|
|
||||||
to both copies planning metadata, creates a synchronization obligation, and
|
|
||||||
makes disagreements between the two records possible. At the same time,
|
|
||||||
unlabelled objects look accidental unless the repository states which object
|
|
||||||
owns the metadata.
|
|
||||||
|
|
||||||
The repository already uses issues as work items and PRs as implementations of
|
|
||||||
those work items. At this decision's cutoff, all open PRs reference issues, but
|
|
||||||
121 of 219 historically merged PRs do not. Manufacturing retrospective issues
|
|
||||||
for that history would create records that never participated in planning and
|
|
||||||
would make the issue history less truthful.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Issues are the canonical tracker records and own labels. Every issue has at
|
|
||||||
least one label. An issue opened or left without labels receives
|
|
||||||
`Status/Needs Triage` automatically until it is classified.
|
|
||||||
|
|
||||||
Pull requests carry no labels. Every new PR deliberately references at least
|
|
||||||
one existing issue in its title or description with one of these forms:
|
|
||||||
|
|
||||||
- `Closes #123`, `Fixes #123`, or `Resolves #123` when merging completes it.
|
|
||||||
- `Part of #123`, `Related to #123`, `Refs #123`, or `References #123` when it
|
|
||||||
contributes without completing it.
|
|
||||||
|
|
||||||
Gitea Actions enforces both PR rules as a status check and repairs the empty
|
|
||||||
issue-label state. Branch protection makes the PR policy check required.
|
|
||||||
|
|
||||||
The policy applies from 2026-07-18 onward. Existing issues may be labelled as
|
|
||||||
they are encountered, but closed PRs are grandfathered: no retrospective
|
|
||||||
issues or PR labels are created solely to make history conform.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- Classification, priority, and workflow metadata have one source of truth.
|
|
||||||
- A PR's issue link is the navigation path to its planning metadata.
|
|
||||||
- Multi-PR issues do not require copied or synchronized labels.
|
|
||||||
- `Status/Needs Triage` is an intentional fallback, not a final
|
|
||||||
classification.
|
|
||||||
- Direct issue creation remains convenient; automation repairs a missing label
|
|
||||||
immediately after creation because Gitea has no native required-label rule.
|
|
||||||
- The required check must be configured in branch protection after this
|
|
||||||
workflow lands.
|
|
||||||
|
|
||||||
## Links
|
|
||||||
|
|
||||||
- Issue #405.
|
|
||||||
- `.gitea/workflows/tracker-policy.yml`.
|
|
||||||
- `scripts/tracker_policy.py`.
|
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# PRD prd-new: Claude forward_host_credentials
|
||||||
|
|
||||||
|
- **Status:** Draft
|
||||||
|
- **Author:** claude
|
||||||
|
- **Created:** 2026-07-01
|
||||||
|
- **Issue:** #325
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add `agent_provider.forward_host_credentials: true` support for the
|
||||||
|
`claude` template, mirroring the existing Codex flow. When enabled,
|
||||||
|
bot-bottle reads the host's Claude OAuth session key from
|
||||||
|
`~/.claude/.credentials.json` at launch, forwards it only to the egress sidecar,
|
||||||
|
and injects a placeholder `CLAUDE_CODE_OAUTH_TOKEN` into the agent so
|
||||||
|
Claude Code starts without ever seeing the real credential.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Running a Claude agent in a container today requires the operator to
|
||||||
|
manually extract a long-lived OAuth token (`claude setup-token`), export
|
||||||
|
it as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`, and reference it explicitly in
|
||||||
|
the manifest with `agent_provider.auth_token:
|
||||||
|
"BOT_BOTTLE_CLAUDE_OAUTH_TOKEN"`. This is a two-step manual ceremony
|
||||||
|
that is easy to skip or do incorrectly.
|
||||||
|
|
||||||
|
The host already stores a valid Claude session in `~/.claude/.credentials.json`
|
||||||
|
after `claude login`. Codex already automates an
|
||||||
|
equivalent extraction from `~/.codex/auth.json`. There is no reason
|
||||||
|
Claude bottles cannot do the same.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
- A Claude bottle with `forward_host_credentials: true` in the manifest
|
||||||
|
uses the host's `~/.claude/.credentials.json` session key at launch with no
|
||||||
|
additional operator steps.
|
||||||
|
- The agent container receives only `CLAUDE_CODE_OAUTH_TOKEN=egress-placeholder`
|
||||||
|
— never the real token.
|
||||||
|
- The real session key lives only in the egress sidecar's environment.
|
||||||
|
- Missing, malformed, or expired host Claude auth fails launch with a
|
||||||
|
clear operator-facing message.
|
||||||
|
- Existing `auth_token` behavior is unchanged.
|
||||||
|
- `forward_host_credentials: true` is rejected in the manifest when both
|
||||||
|
`auth_token` and `forward_host_credentials` are set, since they serve
|
||||||
|
the same purpose.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Refreshing Claude OAuth tokens in the sidecar.
|
||||||
|
- Writing a dummy `~/.claude.json` auth state to the agent (unlike the
|
||||||
|
Codex flow, Claude Code reads its credential from `CLAUDE_CODE_OAUTH_TOKEN`
|
||||||
|
in env, not from an auth file — no guest-side auth marker is needed).
|
||||||
|
- Supporting `forward_host_credentials` for providers other than `codex`
|
||||||
|
and `claude`.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Manifest schema
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
agent_provider:
|
||||||
|
template: claude
|
||||||
|
forward_host_credentials: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Rejects in manifest validation when:
|
||||||
|
- Template is not `codex` or `claude`.
|
||||||
|
- Both `auth_token` and `forward_host_credentials` are set.
|
||||||
|
|
||||||
|
### Host auth extraction (`contrib/claude/claude_auth.py`)
|
||||||
|
|
||||||
|
Claude Code credential storage varies by platform:
|
||||||
|
|
||||||
|
- **Linux**: `~/.claude/.credentials.json`
|
||||||
|
- **macOS**: macOS Keychain, service `"Claude Code-credentials"`
|
||||||
|
(the file path is tried first; Keychain is the fallback when the file
|
||||||
|
is absent)
|
||||||
|
|
||||||
|
`~/.claude.json` contains only UI state and profile metadata — no token.
|
||||||
|
|
||||||
|
The credentials JSON schema (same whether from file or Keychain):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"claudeAiOauth": {
|
||||||
|
"accessToken": "<access-token>",
|
||||||
|
"refreshToken": "<refresh-token>",
|
||||||
|
"expiresAt": 1748276587173,
|
||||||
|
"scopes": ["user:inference", "user:profile"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`expiresAt` is in **milliseconds** (not seconds).
|
||||||
|
|
||||||
|
At prepare/launch time, when `forward_host_credentials: true`:
|
||||||
|
|
||||||
|
1. Try `~/.claude/.credentials.json`; on macOS, if absent, run
|
||||||
|
`security find-generic-password -s "Claude Code-credentials" -w`
|
||||||
|
and parse its stdout as JSON.
|
||||||
|
2. Require a `claudeAiOauth` dict.
|
||||||
|
3. Require a non-empty `claudeAiOauth.accessToken` string.
|
||||||
|
4. If `claudeAiOauth.expiresAt` is present, divide by 1000 and require
|
||||||
|
the result to be in the future.
|
||||||
|
5. Return only the access token to the launch path.
|
||||||
|
|
||||||
|
Errors name the missing or invalid condition and point the operator at
|
||||||
|
`claude login`, without printing token values.
|
||||||
|
|
||||||
|
### Egress route
|
||||||
|
|
||||||
|
When `forward_host_credentials: true`:
|
||||||
|
|
||||||
|
- Provision the session key in `provisioned_env` under
|
||||||
|
`BOT_BOTTLE_CLAUDE_HOST_ACCESS_TOKEN` (new constant in `egress.py`).
|
||||||
|
- Set up the `api.anthropic.com` egress route with `auth_scheme: Bearer`
|
||||||
|
and `token_ref: BOT_BOTTLE_CLAUDE_HOST_ACCESS_TOKEN`.
|
||||||
|
- Set `CLAUDE_CODE_OAUTH_TOKEN=egress-placeholder` in the agent env and
|
||||||
|
add it to `hidden_env_names`.
|
||||||
|
|
||||||
|
No dummy auth file and no `verify` step are needed — Claude Code reads
|
||||||
|
the credential from the env var, not from a file.
|
||||||
|
|
||||||
|
### Constants
|
||||||
|
|
||||||
|
- `CLAUDE_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CLAUDE_HOST_ACCESS_TOKEN"`
|
||||||
|
in `egress.py` (alongside the existing `CODEX_HOST_CREDENTIAL_TOKEN_REF`).
|
||||||
|
- `CLAUDE_HOST_CREDENTIAL_HOSTS = ("api.anthropic.com",)` in
|
||||||
|
`agent_provider.py` (alongside the existing `CODEX_HOST_CREDENTIAL_HOSTS`).
|
||||||
|
|
||||||
|
### Data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Host ~/.claude/.credentials.json → bot-bottle launch
|
||||||
|
│
|
||||||
|
├──► egress sidecar env (real token only)
|
||||||
|
│
|
||||||
|
└──► agent env: CLAUDE_CODE_OAUTH_TOKEN=egress-placeholder
|
||||||
|
|
||||||
|
Agent → HTTPS to api.anthropic.com (via egress)
|
||||||
|
Egress → injects Authorization: Bearer <real token>
|
||||||
|
Egress → forwards to api.anthropic.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
None — the Codex precedent makes the design clear.
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Enforce the repository's issue/PR metadata policy in Gitea Actions."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
ISSUE_REFERENCE = re.compile(
|
|
||||||
r"(?im)\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|part\s+of|"
|
|
||||||
r"related\s+to|refs?|references)\s+#(\d+)\b"
|
|
||||||
)
|
|
||||||
TRIAGE_LABEL = "Status/Needs Triage"
|
|
||||||
|
|
||||||
|
|
||||||
def deliberate_issue_numbers(title: str, body: str) -> set[int]:
|
|
||||||
"""Return same-repository issue numbers referenced intentionally."""
|
|
||||||
return {int(match) for match in ISSUE_REFERENCE.findall(f"{title}\n{body}")}
|
|
||||||
|
|
||||||
|
|
||||||
class GiteaApi:
|
|
||||||
"""Small API client using the Actions-provided repository token."""
|
|
||||||
|
|
||||||
def __init__(self, api_url: str, repository: str, token: str) -> None:
|
|
||||||
self.base = f"{api_url.rstrip('/')}/repos/{repository}"
|
|
||||||
self.token = token
|
|
||||||
|
|
||||||
def request(self, method: str, path: str, payload: object | None = None) -> Any:
|
|
||||||
data = None if payload is None else json.dumps(payload).encode()
|
|
||||||
request = urllib.request.Request(
|
|
||||||
f"{self.base}{path}",
|
|
||||||
data=data,
|
|
||||||
method=method,
|
|
||||||
headers={
|
|
||||||
"Authorization": f"token {self.token}",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(request, timeout=15) as response:
|
|
||||||
if response.status == 204:
|
|
||||||
return None
|
|
||||||
return json.load(response)
|
|
||||||
|
|
||||||
|
|
||||||
def check_pull_request(event: dict[str, Any], api: GiteaApi) -> list[str]:
|
|
||||||
"""Return policy violations for a pull_request event."""
|
|
||||||
pull = event["pull_request"]
|
|
||||||
errors: list[str] = []
|
|
||||||
labels = pull.get("labels") or []
|
|
||||||
if labels:
|
|
||||||
errors.append(
|
|
||||||
"PRs must be unlabeled; put tracker metadata on the linked issue "
|
|
||||||
f"(found: {', '.join(label['name'] for label in labels)})."
|
|
||||||
)
|
|
||||||
|
|
||||||
numbers = deliberate_issue_numbers(pull.get("title", ""), pull.get("body", ""))
|
|
||||||
if not numbers:
|
|
||||||
errors.append(
|
|
||||||
"PR must reference an issue with Closes/Fixes/Resolves #N, "
|
|
||||||
"Part of #N, Related to #N, Refs #N, or References #N."
|
|
||||||
)
|
|
||||||
return errors
|
|
||||||
|
|
||||||
real_issues = 0
|
|
||||||
for number in sorted(numbers):
|
|
||||||
try:
|
|
||||||
item = api.request("GET", f"/issues/{number}")
|
|
||||||
except urllib.error.HTTPError as error:
|
|
||||||
if error.code == 404:
|
|
||||||
errors.append(f"Referenced issue #{number} does not exist.")
|
|
||||||
continue
|
|
||||||
raise
|
|
||||||
if item.get("pull_request") is not None:
|
|
||||||
errors.append(f"#{number} is a pull request, not an issue.")
|
|
||||||
else:
|
|
||||||
real_issues += 1
|
|
||||||
|
|
||||||
if not real_issues and not errors:
|
|
||||||
errors.append("PR must reference at least one real issue.")
|
|
||||||
return errors
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_issue_label(event: dict[str, Any], api: GiteaApi) -> bool:
|
|
||||||
"""Apply the triage label if an issue event leaves the issue unlabeled."""
|
|
||||||
issue = event["issue"]
|
|
||||||
if issue.get("pull_request") is not None or issue.get("labels"):
|
|
||||||
return False
|
|
||||||
labels = api.request("GET", "/labels?limit=100")
|
|
||||||
triage = next((label for label in labels if label["name"] == TRIAGE_LABEL), None)
|
|
||||||
if triage is None:
|
|
||||||
raise RuntimeError(f"repository label {TRIAGE_LABEL!r} does not exist")
|
|
||||||
api.request("POST", f"/issues/{issue['number']}/labels", {"labels": [triage["id"]]})
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _load_event(path: str) -> dict[str, Any]:
|
|
||||||
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("command", choices=("check-pr", "label-issue"))
|
|
||||||
parser.add_argument("--event", default=os.environ.get("GITHUB_EVENT_PATH"))
|
|
||||||
args = parser.parse_args()
|
|
||||||
if not args.event:
|
|
||||||
parser.error("--event or GITHUB_EVENT_PATH is required")
|
|
||||||
|
|
||||||
api = GiteaApi(
|
|
||||||
os.environ["GITHUB_API_URL"],
|
|
||||||
os.environ["GITHUB_REPOSITORY"],
|
|
||||||
os.environ["GITHUB_TOKEN"],
|
|
||||||
)
|
|
||||||
event = _load_event(args.event)
|
|
||||||
if args.command == "check-pr":
|
|
||||||
errors = check_pull_request(event, api)
|
|
||||||
if errors:
|
|
||||||
print("\n".join(f"::error::{error}" for error in errors))
|
|
||||||
return 1
|
|
||||||
print("PR tracker policy passed.")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
changed = ensure_issue_label(event, api)
|
|
||||||
print(f"Applied {TRIAGE_LABEL}." if changed else "Issue already has a label.")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -9,11 +9,15 @@ import unittest
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from bot_bottle.agent_provider import (
|
from bot_bottle.agent_provider import (
|
||||||
|
CLAUDE_HOST_CREDENTIAL_HOSTS,
|
||||||
CODEX_HOST_CREDENTIAL_HOSTS,
|
CODEX_HOST_CREDENTIAL_HOSTS,
|
||||||
build_agent_provision_plan,
|
build_agent_provision_plan,
|
||||||
prompt_args,
|
prompt_args,
|
||||||
)
|
)
|
||||||
from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF
|
from bot_bottle.egress import (
|
||||||
|
CLAUDE_HOST_CREDENTIAL_TOKEN_REF,
|
||||||
|
CODEX_HOST_CREDENTIAL_TOKEN_REF,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _jwt(exp: int) -> str:
|
def _jwt(exp: int) -> str:
|
||||||
@@ -292,6 +296,67 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual({}, plan.provisioned_env)
|
self.assertEqual({}, plan.provisioned_env)
|
||||||
|
|
||||||
|
def test_claude_forward_host_credentials_populates_egress_route(self):
|
||||||
|
access_token = "sk-ant-oat01-test-key" # gitleaks:allow
|
||||||
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
|
home = Path(tmp) / "host-claude"
|
||||||
|
cred_dir = home / ".claude"
|
||||||
|
cred_dir.mkdir(parents=True)
|
||||||
|
(cred_dir / ".credentials.json").write_text(json.dumps({
|
||||||
|
"claudeAiOauth": {"accessToken": access_token},
|
||||||
|
}))
|
||||||
|
plan = build_agent_provision_plan(
|
||||||
|
template="claude",
|
||||||
|
dockerfile="",
|
||||||
|
state_dir=Path(tmp),
|
||||||
|
instance_name="bot-bottle-test",
|
||||||
|
prompt_file=Path(tmp) / "prompt.txt",
|
||||||
|
forward_host_credentials=True,
|
||||||
|
host_env={"HOME": str(home)},
|
||||||
|
)
|
||||||
|
self.assertEqual(1, len(plan.egress_routes))
|
||||||
|
route = plan.egress_routes[0]
|
||||||
|
self.assertIn(route.host, CLAUDE_HOST_CREDENTIAL_HOSTS)
|
||||||
|
self.assertEqual("Bearer", route.auth_scheme)
|
||||||
|
self.assertEqual(CLAUDE_HOST_CREDENTIAL_TOKEN_REF, route.token_ref)
|
||||||
|
self.assertEqual("egress-placeholder", plan.env_vars["CLAUDE_CODE_OAUTH_TOKEN"])
|
||||||
|
self.assertEqual(frozenset({"CLAUDE_CODE_OAUTH_TOKEN"}), plan.hidden_env_names)
|
||||||
|
|
||||||
|
def test_claude_forward_host_credentials_populates_provisioned_env(self):
|
||||||
|
access_token = "sk-ant-oat01-test-key" # gitleaks:allow
|
||||||
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
|
home = Path(tmp) / "host-claude"
|
||||||
|
cred_dir = home / ".claude"
|
||||||
|
cred_dir.mkdir(parents=True)
|
||||||
|
(cred_dir / ".credentials.json").write_text(json.dumps({
|
||||||
|
"claudeAiOauth": {"accessToken": access_token},
|
||||||
|
}))
|
||||||
|
plan = build_agent_provision_plan(
|
||||||
|
template="claude",
|
||||||
|
dockerfile="",
|
||||||
|
state_dir=Path(tmp),
|
||||||
|
instance_name="bot-bottle-test",
|
||||||
|
prompt_file=Path(tmp) / "prompt.txt",
|
||||||
|
forward_host_credentials=True,
|
||||||
|
host_env={"HOME": str(home)},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{CLAUDE_HOST_CREDENTIAL_TOKEN_REF: access_token},
|
||||||
|
plan.provisioned_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_claude_without_forward_host_credentials_has_empty_provisioned_env(self):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
|
plan = build_agent_provision_plan(
|
||||||
|
template="claude",
|
||||||
|
dockerfile="",
|
||||||
|
state_dir=Path(tmp),
|
||||||
|
instance_name="bot-bottle-test",
|
||||||
|
prompt_file=Path(tmp) / "prompt.txt",
|
||||||
|
forward_host_credentials=False,
|
||||||
|
)
|
||||||
|
self.assertEqual({}, plan.provisioned_env)
|
||||||
|
|
||||||
def test_pi_plan_writes_default_ollama_models(self):
|
def test_pi_plan_writes_default_ollama_models(self):
|
||||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
plan = build_agent_provision_plan(
|
plan = build_agent_provision_plan(
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
"""Unit: host Claude auth extraction."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from bot_bottle.contrib.claude.claude_auth import (
|
||||||
|
claude_auth_path,
|
||||||
|
claude_host_access_token,
|
||||||
|
)
|
||||||
|
from bot_bottle.log import Die
|
||||||
|
|
||||||
|
|
||||||
|
def _cred_json(access_token: str, **extra: object) -> str:
|
||||||
|
payload: dict[str, object] = {"claudeAiOauth": {"accessToken": access_token, **extra}}
|
||||||
|
return json.dumps(payload)
|
||||||
|
|
||||||
|
|
||||||
|
class TestClaudeHostAccessToken(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory(prefix="bb-claude-auth.")
|
||||||
|
self.home = Path(self.tmp.name)
|
||||||
|
self.cred_dir = self.home / ".claude"
|
||||||
|
self.cred_dir.mkdir()
|
||||||
|
self.auth_path = self.cred_dir / ".credentials.json"
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def _write(self, payload: dict) -> None: # type: ignore[no-untyped-def]
|
||||||
|
self.auth_path.write_text(json.dumps(payload))
|
||||||
|
|
||||||
|
def test_auth_path_uses_home_env(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self.auth_path,
|
||||||
|
claude_auth_path({"HOME": str(self.home)}),
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- file-based (Linux) ---
|
||||||
|
|
||||||
|
def test_file_returns_access_token(self):
|
||||||
|
key = "sk-ant-oat01-real-key" # gitleaks:allow
|
||||||
|
self._write({"claudeAiOauth": {"accessToken": key}})
|
||||||
|
out = claude_host_access_token({"HOME": str(self.home)})
|
||||||
|
self.assertEqual(key, out)
|
||||||
|
|
||||||
|
def test_file_missing_claude_ai_oauth_dies(self):
|
||||||
|
self._write({"hasCompletedOnboarding": True})
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
claude_host_access_token({"HOME": str(self.home)})
|
||||||
|
|
||||||
|
def test_file_missing_access_token_dies(self):
|
||||||
|
self._write({"claudeAiOauth": {"expiresAt": 2000000000000}})
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
claude_host_access_token({"HOME": str(self.home)})
|
||||||
|
|
||||||
|
def test_file_empty_access_token_dies(self):
|
||||||
|
self._write({"claudeAiOauth": {"accessToken": ""}})
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
claude_host_access_token({"HOME": str(self.home)})
|
||||||
|
|
||||||
|
def test_file_expired_token_dies(self):
|
||||||
|
# expiresAt is milliseconds; 1_000_000 ms is year 1970
|
||||||
|
self._write({
|
||||||
|
"claudeAiOauth": {"accessToken": "sk-ant-oat01-x", "expiresAt": 1_000_000}, # gitleaks:allow
|
||||||
|
})
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
claude_host_access_token(
|
||||||
|
{"HOME": str(self.home)},
|
||||||
|
now=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_file_future_expiry_is_accepted(self):
|
||||||
|
key = "sk-ant-oat01-y" # gitleaks:allow
|
||||||
|
# 2_000_000_000_000 ms ≈ year 2033
|
||||||
|
self._write({
|
||||||
|
"claudeAiOauth": {"accessToken": key, "expiresAt": 2_000_000_000_000},
|
||||||
|
})
|
||||||
|
out = claude_host_access_token(
|
||||||
|
{"HOME": str(self.home)},
|
||||||
|
now=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
self.assertEqual(key, out)
|
||||||
|
|
||||||
|
def test_file_absent_expiry_is_accepted(self):
|
||||||
|
key = "sk-ant-oat01-z" # gitleaks:allow
|
||||||
|
self._write({"claudeAiOauth": {"accessToken": key}})
|
||||||
|
out = claude_host_access_token({"HOME": str(self.home)})
|
||||||
|
self.assertEqual(key, out)
|
||||||
|
|
||||||
|
def test_file_non_json_dies(self):
|
||||||
|
self.auth_path.write_text("not json {{{")
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
claude_host_access_token({"HOME": str(self.home)})
|
||||||
|
|
||||||
|
def test_file_json_array_root_dies(self):
|
||||||
|
self.auth_path.write_text("[]")
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
claude_host_access_token({"HOME": str(self.home)})
|
||||||
|
|
||||||
|
def test_file_extra_fields_are_ignored(self):
|
||||||
|
key = "sk-ant-oat01-real" # gitleaks:allow
|
||||||
|
self._write({
|
||||||
|
"claudeAiOauth": {
|
||||||
|
"accessToken": key,
|
||||||
|
"refreshToken": "sk-ant-ort01-secret", # gitleaks:allow
|
||||||
|
"scopes": ["user:inference"],
|
||||||
|
"expiresAt": 2_000_000_000_000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
out = claude_host_access_token({"HOME": str(self.home)})
|
||||||
|
self.assertEqual(key, out)
|
||||||
|
|
||||||
|
# --- macOS Keychain fallback ---
|
||||||
|
|
||||||
|
def _home_without_creds(self) -> Path:
|
||||||
|
"""A home dir that has .claude/ but no .credentials.json."""
|
||||||
|
empty = self.home / "no-creds"
|
||||||
|
(empty / ".claude").mkdir(parents=True)
|
||||||
|
return empty
|
||||||
|
|
||||||
|
def _mock_keychain(self, stdout: str, returncode: int = 0) -> MagicMock:
|
||||||
|
mock = MagicMock()
|
||||||
|
mock.returncode = returncode
|
||||||
|
mock.stdout = stdout
|
||||||
|
return mock
|
||||||
|
|
||||||
|
def test_keychain_used_when_file_absent(self):
|
||||||
|
key = "sk-ant-oat01-keychain" # gitleaks:allow
|
||||||
|
home = self._home_without_creds()
|
||||||
|
with patch(
|
||||||
|
"bot_bottle.contrib.claude.claude_auth.subprocess.run",
|
||||||
|
return_value=self._mock_keychain(_cred_json(key)),
|
||||||
|
), patch(
|
||||||
|
"bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin",
|
||||||
|
):
|
||||||
|
out = claude_host_access_token({"HOME": str(home)})
|
||||||
|
self.assertEqual(key, out)
|
||||||
|
|
||||||
|
def test_keychain_failure_when_file_absent_dies(self):
|
||||||
|
home = self._home_without_creds()
|
||||||
|
with patch(
|
||||||
|
"bot_bottle.contrib.claude.claude_auth.subprocess.run",
|
||||||
|
return_value=self._mock_keychain("", returncode=44),
|
||||||
|
), patch(
|
||||||
|
"bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin",
|
||||||
|
):
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
claude_host_access_token({"HOME": str(home)})
|
||||||
|
|
||||||
|
def test_no_file_no_keychain_on_linux_dies(self):
|
||||||
|
home = self._home_without_creds()
|
||||||
|
with patch("bot_bottle.contrib.claude.claude_auth.sys.platform", "linux"):
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
claude_host_access_token({"HOME": str(home)})
|
||||||
|
|
||||||
|
def test_keychain_non_json_dies(self):
|
||||||
|
home = self._home_without_creds()
|
||||||
|
with patch(
|
||||||
|
"bot_bottle.contrib.claude.claude_auth.subprocess.run",
|
||||||
|
return_value=self._mock_keychain("not-json"),
|
||||||
|
), patch(
|
||||||
|
"bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin",
|
||||||
|
):
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
claude_host_access_token({"HOME": str(home)})
|
||||||
|
|
||||||
|
def test_keychain_security_not_found_dies(self):
|
||||||
|
home = self._home_without_creds()
|
||||||
|
with patch(
|
||||||
|
"bot_bottle.contrib.claude.claude_auth.subprocess.run",
|
||||||
|
side_effect=FileNotFoundError,
|
||||||
|
), patch(
|
||||||
|
"bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin",
|
||||||
|
):
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
claude_host_access_token({"HOME": str(home)})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -80,11 +80,19 @@ class TestAgentProviderHostCredentials(unittest.TestCase):
|
|||||||
"forward_host_credentials": "yes",
|
"forward_host_credentials": "yes",
|
||||||
})
|
})
|
||||||
|
|
||||||
def test_forward_host_credentials_rejected_for_claude(self):
|
def test_forward_host_credentials_allowed_for_claude(self):
|
||||||
|
b = _provider_config_bottle({
|
||||||
|
"template": "claude",
|
||||||
|
"forward_host_credentials": True,
|
||||||
|
})
|
||||||
|
self.assertTrue(b.agent_provider.forward_host_credentials)
|
||||||
|
|
||||||
|
def test_forward_host_credentials_and_auth_token_rejected_together(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
_provider_config_bottle({
|
_provider_config_bottle({
|
||||||
"template": "claude",
|
"template": "claude",
|
||||||
"forward_host_credentials": True,
|
"forward_host_credentials": True,
|
||||||
|
"auth_token": "SOME_TOKEN",
|
||||||
})
|
})
|
||||||
|
|
||||||
def test_auth_token_defaults_empty(self):
|
def test_auth_token_defaults_empty(self):
|
||||||
|
|||||||
@@ -86,10 +86,22 @@ class TestAgentProviderValidation(unittest.TestCase):
|
|||||||
"b", {"forward_host_credentials": True, "template": "weird"}
|
"b", {"forward_host_credentials": True, "template": "weird"}
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_forward_creds_non_codex_template(self) -> None:
|
def test_forward_creds_pi_template_rejected(self) -> None:
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
ManifestAgentProvider.from_dict(
|
ManifestAgentProvider.from_dict(
|
||||||
"b", {"forward_host_credentials": True, "template": "claude"}
|
"b", {"forward_host_credentials": True, "template": "pi"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_forward_creds_claude_allowed(self) -> None:
|
||||||
|
p = ManifestAgentProvider.from_dict(
|
||||||
|
"b", {"forward_host_credentials": True, "template": "claude"}
|
||||||
|
)
|
||||||
|
self.assertTrue(p.forward_host_credentials)
|
||||||
|
|
||||||
|
def test_forward_creds_and_auth_token_rejected(self) -> None:
|
||||||
|
with self.assertRaises(ManifestError):
|
||||||
|
ManifestAgentProvider.from_dict(
|
||||||
|
"b", {"forward_host_credentials": True, "auth_token": "T", "template": "claude"}
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_valid_claude_auth_token(self) -> None:
|
def test_valid_claude_auth_token(self) -> None:
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
import unittest
|
|
||||||
from unittest.mock import Mock
|
|
||||||
|
|
||||||
from scripts.tracker_policy import (
|
|
||||||
TRIAGE_LABEL,
|
|
||||||
check_pull_request,
|
|
||||||
deliberate_issue_numbers,
|
|
||||||
ensure_issue_label,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestDeliberateIssueNumbers(unittest.TestCase):
|
|
||||||
def test_accepts_completing_and_noncompleting_forms(self):
|
|
||||||
self.assertEqual(
|
|
||||||
deliberate_issue_numbers("Fixes #12", "Part of #14; refs #15"),
|
|
||||||
{12, 14, 15},
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_does_not_treat_incidental_number_as_link(self):
|
|
||||||
self.assertEqual(deliberate_issue_numbers("Audit #12", "See PR #14"), set())
|
|
||||||
|
|
||||||
|
|
||||||
class TestCheckPullRequest(unittest.TestCase):
|
|
||||||
def test_accepts_unlabelled_pr_linked_to_real_issue(self):
|
|
||||||
api = Mock()
|
|
||||||
api.request.return_value = {"number": 12, "pull_request": None}
|
|
||||||
event = {"pull_request": {"title": "Change", "body": "Part of #12", "labels": []}}
|
|
||||||
self.assertEqual(check_pull_request(event, api), [])
|
|
||||||
|
|
||||||
def test_rejects_labels_and_pr_reference(self):
|
|
||||||
api = Mock()
|
|
||||||
api.request.return_value = {"number": 12, "pull_request": {}}
|
|
||||||
event = {
|
|
||||||
"pull_request": {
|
|
||||||
"title": "Change",
|
|
||||||
"body": "Closes #12",
|
|
||||||
"labels": [{"name": "Kind/Bug"}],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
errors = check_pull_request(event, api)
|
|
||||||
self.assertEqual(len(errors), 2)
|
|
||||||
self.assertIn("unlabeled", errors[0])
|
|
||||||
self.assertIn("not an issue", errors[1])
|
|
||||||
|
|
||||||
|
|
||||||
class TestEnsureIssueLabel(unittest.TestCase):
|
|
||||||
def test_adds_triage_label_to_unlabelled_issue(self):
|
|
||||||
api = Mock()
|
|
||||||
api.request.side_effect = [[{"id": 55, "name": TRIAGE_LABEL}], None]
|
|
||||||
event = {"issue": {"number": 405, "labels": [], "pull_request": None}}
|
|
||||||
self.assertTrue(ensure_issue_label(event, api))
|
|
||||||
api.request.assert_any_call("POST", "/issues/405/labels", {"labels": [55]})
|
|
||||||
|
|
||||||
def test_leaves_labelled_issue_unchanged(self):
|
|
||||||
api = Mock()
|
|
||||||
event = {"issue": {"number": 405, "labels": [{"name": "Kind/Documentation"}]}}
|
|
||||||
self.assertFalse(ensure_issue_label(event, api))
|
|
||||||
api.request.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
Reference in New Issue
Block a user