Skip to content

examples: Muse → Discord reference client for #6 - #7

Open
ttmuse wants to merge 10 commits into
openabdev:mainfrom
ttmuse:feat/muse-discord-reference-client
Open

ttmuse wants to merge 10 commits into
openabdev:mainfrom
ttmuse:feat/muse-discord-reference-client

Conversation

@ttmuse

@ttmuse ttmuse commented Nov 12, 2026

Copy link
Copy Markdown

Implements the caller side of #6 as a self-contained example under examples/muse-discord/. Nothing here touches the proxy core.

What's inside

  • identity.toml — example config in the real schema (crates/ap-core/src/config.rs): a muse identity (config holds only the caller key's sha256), a discord route, a credential store reference, and grants. Reads (GET on the channel/guild prefixes) are granted. Write grants are included but commented out: Phase 1 policy only allows or denies, so enabling them today would execute writes immediately — they are meant to be enabled together with require_approval from FR: require_approval policy action — human-in-the-loop approval for gated requests #5.
  • caller.py — reference caller (Python, stdlib only) in the exact wire shape of ap-client/the pipeline: /ap/<caller-key>/<route><path>, surrogate as Bearer aps_... in the route's surrogate header. It also implements the proposed FR: require_approval policy action — human-in-the-loop approval for gated requests #5 outcome flow (202 + request id, poll the caller-scoped outcome endpoint) — against a Phase 1 proxy nothing returns 202, so that path simply never triggers.
  • mock_proxy.py — a stand-in proxy implementing the caller-facing contract (caller-key digest check, surrogate form, parked/executed/denied/expired) so the example runs without a deployment. No real Discord call ever happens; the owner side is represented by clearly-marked test-only admin endpoints.
  • test_caller.py — end-to-end tests: immediate read, park → approve → executed, park → denied, park → expired, bad surrogate refused. All five pass.
  • README.md — the integration guide: protocol summary, policy walkthrough, session issuance/revocation for a Muse caller (registrar on the owner's device, per the Support Muse as a caller: route custom-connector outbound operations through auth-proxy for audit and approval #6 discussion), and security notes.

Finding worth flagging: credential scheme

The forwarder always sets the credential header as Bearer <secret> (crates/ap-proxy/src/forward.rs, credential_value). Discord bot tokens must be sent as Bot <token>, so a live Discord upstream cannot authenticate against Phase 1 as shipped — reads and writes alike; storing Bot <token> as the secret doesn't help because the prefix is prepended unconditionally. The README documents this and proposes the minimal fix (a per-route credential_scheme, default Bearer, e.g. "Bot"). Deliberately not in this PR since it touches the proxy core — filing as its own change for review.

Refs #5, #6.

@thepagent

Copy link
Copy Markdown
Contributor

/review

@chaodu-agent

Copy link
Copy Markdown
Contributor

Important

CHANGES REQUESTED ⚠️ - Useful proposal for #6, but as submitted it is a mock of an unagreed approval contract, not a runnable Muse -> Discord client, and it commits reusable credential material.

What This PR Does

Adds examples/muse-discord/, a reference caller showing how a Muse custom-connector helper would route Discord operations through auth-proxy (issue #6). It ships a Python caller, an example identity config, an in-memory mock proxy, and a test script that drives the caller against the mock. No crate code changes.

How It Works

Findings

# Severity Finding Location
1 🟡 Client and mock hard-code an approval contract (202 + request_id + /requests/<id> polling) that does not exist in the proxy and is still open in #5; any upstream 202 is also misread as a parked request caller.py:16-21, caller.py:86-97, mock_proxy.py
2 🟡 The example cannot reach Discord end-to-end today: runtime_pubkey unset, forwarder always sends Bearer (Discord needs Bot), loopback-only listener identity.toml:23-34, crates/ap-proxy/src/forward.rs:79-93
3 🟡 The documented per-channel POST grant fails config validation: non-root grant paths must end in / identity.toml:53-71, crates/ap-core/src/config.rs:231-248
4 🟡 A reusable caller key and surrogate are committed in the README, mock, and test; identity.toml carries the matching caller-key sha256 README.md:127-134, mock_proxy.py:40-47, test_caller.py:23-26, identity.toml:20-22
5 🟡 CLI and env input is not validated at the caller boundary (IndexError, raw query/path injection, uncaught JSON and float parsing) caller.py:62-63, caller.py:132-153
6 🟡 Tests only exercise the mock, which omits canonicalization, policy evaluation, registry, audit, and forwarding; green tests say little about the real proxy test_caller.py, mock_proxy.py:15-18
7 🟢 Honest disclosure of the Bot/Bearer gap and of the mock's simplifications README.md:90-102, mock_proxy.py:15-18
8 🟢 Fail-safe defaults: read-only grants, writes commented out with a clear warning, credential is a store reference, no crate changes identity.toml
Finding Details

🟡 F1: Approval contract is speculative

require_approval, request_id, and the /ap/<key>/requests/<id> endpoint appear nowhere in crates/. The caller and mock therefore define the contract instead of following it. Merging them before #5 settles the response marker, result schema, poll authorization, expiry/revocation, and response headers risks freezing the wrong shape.

The caller also treats every 202 as an approval envelope (caller.py:86-97). A legitimate upstream 202 would be reported as "proxy answered 202 without a request_id" and exit 2.

Requested change: either drop the approval path until #5 is agreed, or gate it on an unambiguous proxy-owned marker (for example a dedicated response header) rather than the bare status code. Once #5 lands, derive the mock from that spec.

🟡 F2: Not runnable against Discord as submitted

The README discloses these gaps, but the PR title calls this a "reference client". Requested change: retitle/scope it as a proposal plus mock, or land it after a per-route credential scheme exists so at least the read path works end-to-end.

🟡 F3: Write-grant guidance fails config validation

valid_path (crates/ap-core/src/config.rs:231-248) rejects any non-root path that does not end in / ("exact paths other than / are not allowed"), covered by rejects_exact_path_that_is_not_slash_terminated (config.rs:844-848). The commented POST grant uses /api/v10/channels/1111111111111111111/messages, and the comment states "one exact path per channel". Uncommenting it makes the proxy refuse to load the config.

Requested change: use the prefix form /api/v10/channels/<id>/messages/ (and note it also matches sub-paths such as message edits), or document that exact-path grants need a config change first.

🟡 F4: Committed credential material

The README, mock_proxy.py, and test_caller.py all contain the same apk_... caller key and aps_... surrogate, and sha256(apk_...) equals the caller_key_sha256 in identity.toml (verified). A registered runtime key is still required, so this is not a direct bypass, but a deployment that copies identity.toml without replacing the digest would accept a publicly known caller key.

Requested change: use obvious placeholders in identity.toml and the README; have test_caller.py generate ephemeral values (secrets.token_hex(32)) and pass them to the mock at startup.

🟡 F5: Caller input validation

  • --reply-to as the last argument raises IndexError instead of exiting 64.
  • limit is interpolated straight into the query string, and request <path> accepts any string, including paths without a leading /, ?/#, or control characters.
  • Invalid JSON for request and non-numeric AP_POLL_TIMEOUT/AP_POLL_INTERVAL raise tracebacks (exit 1); negative values silently break polling.

The proxy rejects much of this later, but an example that others copy should fail at its own boundary. Requested change: validate snowflake IDs and limit as digits, require an origin-form path starting with / with no fragment or control characters, map parse errors to die(..., 64), and require finite non-negative poll values. Consider dropping the generic request command in favor of typed operations.

🟡 F6: Mock-only test coverage

test_caller.py only talks to mock_proxy.py, which by its own docstring skips canonicalization, audit, registry behavior, real policy evaluation, and upstream forwarding. The mock can stay green while drifting from pipeline.rs in exactly the security-relevant parts. Requested change: add at least one test that runs the caller against the real proxy (Phase 1 allow/deny with a stub upstream), or label the mock clearly as a contract sketch for #5.

🟢 F7: Clear disclosure

The README states the Bot/Bearer gap and the mock's limitations up front instead of hiding them.

🟢 F8: Fail-safe defaults

Writes are disabled with an explicit warning about Phase 1 allow/deny semantics, the bot token is a store reference that never reaches the caller, and the PR touches no crate code.

Baseline Check
  • PR opened: 2026-11-12
  • Reviewed head: 9c311bb4c30a409644aac83643d9381fa86fc6f0
  • Base: main at fb68e88dfe1fb37fdb43f0c062aa4cfce5ed3f0a (merge-base identical; not stacked)
  • Diff: 5 files, +683/-0, all under examples/muse-discord/
  • Main already has: the session plane (caller key in path, surrogate header), allow/deny grants, and the forwarder with a fixed Bearer scheme
  • Net-new value: a documented caller shape for custom connectors and a concrete sketch of the FR: require_approval policy action — human-in-the-loop approval for gated requests #5 approval flow; no new proxy capability
  • Validation: git diff --check clean; caller-key digest matched with sha256sum; valid_path and credential_value read from source; repository search found no approval implementation. The Python tests were not executed in the review environment (no Python interpreter available), and no CI job ran for this PR (poll-and-review skipped).
What's Good (🟢)
  • Honest, detailed README about what works today and what does not
  • Conservative defaults: read-only grants, write grants commented out with a clear warning
  • Wire format matches the current session plane (caller key in path, surrogate as Bearer aps_...)
  • Distinct exit codes for each approval outcome make the caller easy to script

Addressing External Reviewer Feedback

@thepagent

/review

✅ Addressed: this comment is the requested review.

5. Three Reasons We Might Not Need This PR

  1. No working Discord path yet - session registration, the credential scheme, write policy, and remote transport all need work outside this PR.
  2. The approval contract belongs in FR: require_approval policy action — human-in-the-loop approval for gated requests #5 first - client code and a mock written ahead of the spec tend to become the spec by default.
  3. The mock may cost more than it is worth - it can pass while diverging from the real pipeline; the same effort could go into a contract test against ap-proxy.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants