Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
51 changes: 23 additions & 28 deletions gateway/docs/constant-test-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,69 +68,64 @@ 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"
```

### 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 +142,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
16 changes: 12 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,20 @@ 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):
mkdir -p -m 700 ~/.config/amw

# One-shot provisioner. The minted secret is never printed; it is written
# to --key-file (created mode 0600, refused if the path already exists).
# --key-file is required: stdout shows only non-secret identifiers (key id).
# Parent dir must exist before --key-file is written.
install -d -m 700 "$HOME/.config/amw"
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 +50,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
182 changes: 149 additions & 33 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 (reserved mode 0600 before minting, 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,43 @@ def _require(condition: bool, message: str) -> None:
raise SystemExit(f"error: {message}")


def _reserve_key_file(path: str) -> int:
"""Create PATH exclusively (mode 0600) and return its fd; refuse if it exists."""
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
try:
return 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


def _write_key_fd(fd: int, path: str, api_key: str) -> None:
"""Write the full key to the reserved fd, fsync, then close.

On any error the fd is closed and PATH is unlinked before raising,
so a partial key file is never left behind.
"""
data = api_key.encode("utf-8")
try:
view = memoryview(data)
while view:
written = os.write(fd, view)
view = view[written:]
os.fsync(fd)
except OSError as exc:
try:
os.close(fd)
except OSError:
pass
try:
os.unlink(path)
except OSError:
pass
raise SystemExit(f"error: cannot write --key-file {path}: {exc}") from exc
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 @@ -65,7 +105,7 @@ def _bootstrap_key(cli_value: str | None) -> str:
def _post(client: httpx.Client, path: str, body: dict[str, Any]) -> dict[str, Any]:
resp = client.post(path, json=body)
if resp.status_code >= 400:
raise SystemExit(f"error: POST {path} → {resp.status_code}: {resp.text[:500]}")
raise SystemExit(f"error: bootstrap request failed (HTTP {resp.status_code})")
data = resp.json()
_require(isinstance(data, dict), f"POST {path} returned non-object JSON")
return data
Expand All @@ -92,7 +132,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 +201,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,59 +267,132 @@ 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. Requires --key-file. "
"Never prints the minted secret."
),
)
parser.add_argument(
"--key-file",
default=None,
metavar="PATH",
help=(
"Required: 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. "
"The minted key is never printed."
),
)
args = parser.parse_args()

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

if args.key_only and not args.key_file:
parser.error("--key-only requires --key-file (the key is never printed)")

if args.agent_wallet_id and not args.sponsor_wallet_id:
raise SystemExit(
"error: --agent-wallet-id resume requires --sponsor-wallet-id "
"(for operator record-keeping)"
)

result = provision(
api_url=args.api_url,
bootstrap_key=_bootstrap_key(args.bootstrap_key),
sponsor_name=args.sponsor_name,
agent_id=args.agent_id,
budget_credits=args.budget_credits,
initial_credits=args.initial_credits,
key_name=args.key_name,
sponsor_wallet_id=args.sponsor_wallet_id,
agent_wallet_id=args.agent_wallet_id,
daily_limit=args.daily_limit,
expires_in_days=args.expires_in_days,
max_uses=args.max_uses,
json_mode=args.json,
)
if not args.key_file:
parser.error("--key-file is required (the minted key is never printed)")

bootstrap_key = _bootstrap_key(args.bootstrap_key)

# Reserve the key file before minting so a minted key is never discarded
# because its destination cannot be created.
key_fd = _reserve_key_file(args.key_file) if args.key_file else None

try:
result = provision(
api_url=args.api_url,
bootstrap_key=bootstrap_key,
sponsor_name=args.sponsor_name,
agent_id=args.agent_id,
budget_credits=args.budget_credits,
initial_credits=args.initial_credits,
key_name=args.key_name,
sponsor_wallet_id=args.sponsor_wallet_id,
agent_wallet_id=args.agent_wallet_id,
daily_limit=args.daily_limit,
expires_in_days=args.expires_in_days,
max_uses=args.max_uses,
json_mode=args.json,
)
except BaseException:
if key_fd is not None:
try:
os.close(key_fd)
except OSError:
pass
try:
os.unlink(args.key_file)
except OSError:
pass
raise

# 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:
assert key_fd is not None # reserved above whenever --key-file is given
try:
_write_key_fd(key_fd, key_file, api_key)
except SystemExit:
print(
f"error: failed to write --key-file {key_file} for "
f"key_id={key_id} agent_id={agent_id}; the key is active "
"and should be revoked",
file=sys.stderr,
)
raise

if args.key_only:
print(result["api_key"])
print(key_file)
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
Loading
Loading