Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 24 additions & 28 deletions gateway/docs/constant-test-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,69 +68,65 @@ looks like the trust plane failing.
Set `CI_SMOKE_AGENT_KEY` for a pre-provisioned agent credential. Optionally provide `CI_SMOKE_WALLET_ID` and `CI_SMOKE_KEY_ID` for faster startup (the script will fetch them from the API if not provided):

```bash
# Provision an agent key once (using bootstrap key) and extract credentials in one pipeline
# Provision an agent key once (using bootstrap key). The minted secret is
# never printed; --key-file writes it (mode 0600, path must not already exist).
export BOOTSTRAP_KEY="amw_live_..."

# Provision once; keep every identity field from the same response.
# Disable shell tracing while handling credentials. Do not print this JSON.
set +x
KEY_FILE="${TMPDIR:-/tmp}/amw-ci-smoke-key.$$"
PROVISION_JSON="$(python scripts/partner_api_key_bootstrap.py \
--api-url https://api.thisisatest.tech \
--agent-id ci-smoke-agent \
--key-name constant-test-loop \
--budget-credits 5000 \
--json)"
export CI_SMOKE_AGENT_KEY="$(printf '%s' "$PROVISION_JSON" | jq -er .api_key)"
--json \
--key-file "$KEY_FILE")"
export CI_SMOKE_AGENT_KEY="$(cat "$KEY_FILE")"
export CI_SMOKE_WALLET_ID="$(printf '%s' "$PROVISION_JSON" | jq -er .wallet_id)"
export CI_SMOKE_KEY_ID="$(printf '%s' "$PROVISION_JSON" | jq -er .key_id)"
rm -f "$KEY_FILE"
unset PROVISION_JSON

# Or use a restrictive temporary file if needed
KEY_FILE="$(mktemp)"
umask 077
python scripts/partner_api_key_bootstrap.py \
--api-url https://api.thisisatest.tech \
--agent-id ci-smoke-agent \
--key-name constant-test-loop \
--budget-credits 5000 \
--json > "$KEY_FILE"
chmod 600 "$KEY_FILE"
export CI_SMOKE_AGENT_KEY="$(jq -r .api_key "$KEY_FILE")"
export CI_SMOKE_WALLET_ID="$(jq -r .wallet_id "$KEY_FILE")"
export CI_SMOKE_KEY_ID="$(jq -r .key_id "$KEY_FILE")"
rm "$KEY_FILE" # Clean up immediately

# Run the constant test
API_URL=https://api.thisisatest.tech python scripts/constant_test_loop.py
```

## Machine-Readable Bootstrap Output

`partner_api_key_bootstrap.py --json` prints JSON to stdout so the minted key can be piped directly:
`partner_api_key_bootstrap.py --json` prints JSON identity fields to stdout
without the secret. Persist the secret with `--key-file` (do not
pre-create the path; the script refuses an existing file):

```bash
# Pipe to jq to extract the agent API key
KEY_FILE="${TMPDIR:-/tmp}/amw-ci-smoke-key.$$"

# JSON identity fields on stdout; full key only in KEY_FILE
BOOTSTRAP_KEY="..." python scripts/partner_api_key_bootstrap.py \
--api-url https://api.thisisatest.tech \
--agent-id ci-smoke-agent \
--key-name test-key \
--budget-credits 5000 \
--json | jq -r .api_key
--json \
--key-file "$KEY_FILE"

# Or pipe directly to gh secret set
# Or print only the key-file path, then load it into a secret store
KEY_FILE="${TMPDIR:-/tmp}/amw-ci-smoke-key.$$"
BOOTSTRAP_KEY="..." python scripts/partner_api_key_bootstrap.py \
--api-url https://api.thisisatest.tech \
--agent-id ci-smoke-agent \
--key-name constant-test-loop \
--budget-credits 5000 \
--json | jq -r .api_key | \
gh secret set CI_SMOKE_AGENT_KEY --repo agentmiddleware/agent-middleware
--key-only \
--key-file "$KEY_FILE"
gh secret set CI_SMOKE_AGENT_KEY --repo agentmiddleware/agent-middleware < "$KEY_FILE"
rm -f "$KEY_FILE"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
```

### Security Properties

- Human/status text goes to stderr (never stdout in `--json` mode)
- Bootstrap/admin key is never printed to stdout or stderr
- The minted agent key is never printed to stdout or stderr; `--key-file` stores it
- Agent key is never logged or printed during constant test execution
- All secrets read from environment variables, never hardcoded

Expand All @@ -147,9 +143,9 @@ pytest tests/test_constant_test_loop.py -v
```

Covers:
- `--json` mode produces pipeable stdout
- `--json` mode produces pipeable stdout (identity fields only, never `api_key`)
- Bootstrap key never leaks to stdout or stderr
- `jq -r .api_key` pipeline works
- `--key-file` persists the minted secret at mode 0600
- Constant test loop runs against local instance
- Self-provisioning when no key provided
- Agent key never logged during execution
12 changes: 8 additions & 4 deletions gateway/docs/partner-api-key-bootstrap.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,16 @@ Never commit `VALID_API_KEYS` to git.
export API_URL="${PUBLIC_URL:-https://api.thisisatest.tech}"
export BOOTSTRAP_KEY=... # from secret manager / Railway variables — not a demo string

# One-shot provisioner (prints agent key once to stdout):
# One-shot provisioner. The minted secret is never printed; it is written
# to --key-file (created mode 0600, refused if the path already exists).
# Without --key-file, stdout shows only non-secret identifiers (key id).
uv run --with-requirements requirements.txt \
python scripts/partner_api_key_bootstrap.py \
--api-url "$API_URL" \
--sponsor-name "Partner Co" \
--agent-id "partner-agent-001" \
--budget-credits 1000
--budget-credits 1000 \
--key-file "$HOME/.config/amw/partner-agent-001.key"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
```

Optional bounds on what you mint (all default to unlimited):
Expand All @@ -43,10 +46,11 @@ uv run --with-requirements requirements.txt \
python scripts/partner_api_key_bootstrap.py \
--api-url "$API_URL" \
--sponsor-wallet-id "$SPONSOR_WALLET_ID" \
--agent-wallet-id "$AGENT_WALLET_ID"
--agent-wallet-id "$AGENT_WALLET_ID" \
--key-file "$HOME/.config/amw/partner-agent-001.key"
```

Hand the **agent** key (and wallet id) to the partner over a secure channel.
Read the agent key from that file and hand it (and the wallet id) to the partner over a secure channel.
Revoke when the engagement ends:

```bash
Expand Down
98 changes: 81 additions & 17 deletions gateway/scripts/partner_api_key_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
Uses a bootstrap/admin key (VALID_API_KEYS) only to provision:
sponsor wallet → agent wallet → DB-scoped agent API key.

The agent secret is printed once. Requires network access to --api-url.
The minted agent secret is never printed to stdout or stderr. Pass --key-file
PATH to write it (created mode 0600, fails if PATH exists); stdout shows
only non-secret identifiers (key id, wallet ids, key prefix).
Requires network access to --api-url.
Does not mint keys without a bootstrap secret (fail closed).

Resume: pass --sponsor-wallet-id / --agent-wallet-id to skip earlier creates
Expand All @@ -28,6 +31,23 @@ def _require(condition: bool, message: str) -> None:
raise SystemExit(f"error: {message}")


def _write_key_file(path: str, api_key: str) -> None:
"""Write the minted key to PATH created 0600; refuse if it already exists."""
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
try:
fd = os.open(path, flags, 0o600)
except FileExistsError:
raise SystemExit(f"error: --key-file {path} already exists")
except OSError as exc:
raise SystemExit(f"error: cannot write --key-file {path}: {exc}") from exc
try:
os.write(fd, api_key.encode("utf-8"))
except OSError as exc:
raise SystemExit(f"error: cannot write --key-file {path}: {exc}") from exc
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
finally:
os.close(fd)


def _require_safe_api_url(api_url: str) -> str:
"""Require HTTPS for remote hosts; allow http:// for loopback only."""
base = api_url.strip().rstrip("/")
Expand Down Expand Up @@ -92,7 +112,8 @@ def provision(
Args:
json_mode: If True, print only machine-readable status to stderr (never
the api_key or bootstrap_key). Human text goes to stderr so
`jq -r .api_key` works on stdout JSON output.
stdout JSON stays pipeable. The minted secret is never in that JSON;
use --key-file to persist it.
"""
base = _require_safe_api_url(api_url)
headers = {
Expand Down Expand Up @@ -160,7 +181,9 @@ def provision(
key_body["max_uses"] = max_uses
key = _post(client, "/v1/api-keys", key_body)
api_key = key.get("api_key")
_require(bool(api_key), "api_key missing (shown only once)")
_require(
bool(api_key), "api_key missing (write with --key-file; never printed)"
)

return {
"api_url": base,
Expand Down Expand Up @@ -224,22 +247,37 @@ def main() -> int:
parser.add_argument(
"--json",
action="store_true",
help="Print full JSON including api_key (default: human summary)",
help=(
"Print JSON identity fields to stdout without the secret "
"(and key_file when --key-file is given). Default: human summary."
),
)
parser.add_argument(
"--key-only",
action="store_true",
help=(
"Print only the minted api_key to stdout. Lets the secret be "
"piped straight into a secret store without a jq dependency, and "
"without the surrounding metadata landing wherever stdout goes."
"Print only the --key-file path, or the key_id when --key-file "
"is omitted. Never prints the minted secret."
),
)
parser.add_argument(
"--key-file",
default=None,
metavar="PATH",
help=(
"Write the minted api_key to PATH (created mode 0600 with "
"O_CREAT|O_EXCL|O_WRONLY; refused if PATH already exists). "
"Stdout prints PATH in --key-only mode, and JSON includes key_file."
),
)
args = parser.parse_args()

if args.key_only and args.json:
raise SystemExit("error: --key-only and --json are mutually exclusive")

if args.key_file and os.path.exists(args.key_file):
raise SystemExit(f"error: --key-file {args.key_file} already exists")

if args.agent_wallet_id and not args.sponsor_wallet_id:
raise SystemExit(
"error: --agent-wallet-id resume requires --sponsor-wallet-id "
Expand All @@ -262,21 +300,47 @@ def main() -> int:
json_mode=args.json,
)

# Non-secret identifiers for stdout. The minted secret below flows
# only to --key-file and never to print/log.
api_url = str(result["api_url"])
sponsor_wallet_id = str(result["sponsor_wallet_id"])
agent_wallet_id = str(result["agent_wallet_id"])
wallet_id = str(result["wallet_id"])
agent_id = str(result["agent_id"])
key_id = result.get("key_id")
key_prefix = result.get("key_prefix")
api_key = str(result["api_key"])
key_file = args.key_file
if key_file:
_write_key_file(key_file, api_key)
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

if args.key_only:
print(result["api_key"])
print(key_file if key_file else key_id)
elif args.json:
# Machine-readable: JSON to stdout, all status to stderr.
# Never print bootstrap_key. This lets `jq -r .api_key` work.
print(json.dumps(result, indent=2))
# Never print bootstrap_key or the minted api_key.
payload: dict[str, Any] = {
"api_url": api_url,
"sponsor_wallet_id": sponsor_wallet_id,
"agent_wallet_id": agent_wallet_id,
"wallet_id": wallet_id,
"agent_id": agent_id,
"key_id": key_id,
"key_prefix": key_prefix,
}
if key_file:
payload["key_file"] = key_file
print(json.dumps(payload, indent=2))
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
else:
# Human-readable summary to stdout (legacy behavior).
# Human-readable summary to stdout. Non-secret identifiers only.
print("Partner API key bootstrap OK")
print(f" api_url: {result['api_url']}")
print(f" sponsor_wallet_id: {result['sponsor_wallet_id']}")
print(f" agent_wallet_id: {result['agent_wallet_id']}")
print(f" key_id: {result['key_id']}")
print(f" key_prefix: {result['key_prefix']}")
print(f" api_key (once): {result['api_key']}")
print(f" api_url: {api_url}")
print(f" sponsor_wallet_id: {sponsor_wallet_id}")
print(f" agent_wallet_id: {agent_wallet_id}")
print(f" key_id: {key_id}")
print(f" key_prefix: {key_prefix}")
if key_file:
print(f" key_file: {key_file}")
return 0


Expand Down
21 changes: 18 additions & 3 deletions gateway/tests/test_onboarding_doc_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
import json
from pathlib import Path
import re
import secrets
import shutil
import subprocess
import uuid


ROOT = Path(__file__).resolve().parents[1]
Expand Down Expand Up @@ -64,22 +66,35 @@ def test_review_claim_names_local_receipt_exception():
def test_ci_recipe_exports_one_provisioned_identity(tmp_path):
doc = read("constant-test-loop.md")
recipe = doc.split("# Provision an agent key once", 1)[1].split(
"# Or use a restrictive", 1
"# Run the constant test", 1
)[0]
recipe = "# Provision an agent key once" + recipe
# Runtime-generated values only: the fake provisioner writes the secret
# to --key-file and prints identity JSON (no api_key on stdout).
agent_key = f"agt-{secrets.token_hex(16)}"
wallet_id = f"wal-{uuid.uuid4().hex[:12]}"
key_id = f"key-{uuid.uuid4().hex[:12]}"
fake_python = tmp_path / "python"
fake_python.write_text(
"#!/bin/sh\n"
'count=0; [ ! -f "$COUNT_FILE" ] || count=$(cat "$COUNT_FILE")\n'
'count=$((count + 1)); printf "%s" "$count" > "$COUNT_FILE"\n'
'printf \'{"api_key":"synthetic-%s","wallet_id":"wallet-%s","key_id":"key-%s"}\\n\' "$count" "$count" "$count"\n'
'key_file=""; prev=""; for arg in "$@"; do\n'
' if [ "$prev" = "--key-file" ]; then key_file="$arg"; fi\n'
' prev="$arg"\n'
"done\n"
'if [ -n "$key_file" ]; then printf "%s" "$FAKE_AGENT_KEY" > "$key_file"; fi\n'
'printf \'{"wallet_id":"%s","key_id":"%s"}\\n\' "$FAKE_WALLET_ID" "$FAKE_KEY_ID"\n'
)
fake_python.chmod(0o755)
jq = shutil.which("jq")
assert jq, "jq is needed to verify this documented recipe"
env = {
"PATH": f"{tmp_path}:{Path(jq).parent}:/usr/bin:/bin",
"COUNT_FILE": str(tmp_path / "count"),
"FAKE_AGENT_KEY": agent_key,
"FAKE_WALLET_ID": wallet_id,
"FAKE_KEY_ID": key_id,
}
result = subprocess.run(
[
Expand All @@ -93,7 +108,7 @@ def test_ci_recipe_exports_one_provisioned_identity(tmp_path):
capture_output=True,
check=True,
)
assert result.stdout.splitlines() == ["synthetic-1", "wallet-1", "key-1"]
assert result.stdout.splitlines() == [agent_key, wallet_id, key_id]
assert (tmp_path / "count").read_text() == "1"


Expand Down
Loading
Loading