Skip to content

feat(agent): per-session pairing and the /sandbox route on the local LLM proxy - #41

Draft
LutzLange wants to merge 8 commits into
mainfrom
containment/pr3-relay-llm
Draft

LutzLange wants to merge 8 commits into
mainfrom
containment/pr3-relay-llm

Conversation

@LutzLange

Copy link
Copy Markdown
Owner

feat(agent): per-session pairing and the /sandbox route on the local LLM proxy

A launcher (or agentdesktop llm-proxy session create) registers a session over the local API and
receives a one-off pairing secret bound to a client id. Requests on the new /sandbox prefix are
accepted only with a registered, unexpired session pairing; the proxy strips every client credential,
adds the gateway identity for the session's client id and logs each request with the session id. The
device-wide pairing and the existing routes are unchanged, with one named exception: the prefix-less
route now refuses first path segments starting with sandbox.

What changes

  • llm_proxy::sessions: in-memory SessionRegistry (hashed pairings, constant-time compare, sliding
    idle timeout, bound 64, 60 s sweep), POST/GET/DELETE /v1/llm-proxy/sessions, creation-time policy
    check (allowedClientIds under controller JWT; subscription mode refused).
  • ROUTES gains /sandbox with RouteIdentity::Session; one tracing::info! line per sandbox
    request at completion (streamed bodies included).
  • CLI agentdesktop llm-proxy session create|list|revoke; client crate post_json_response,
    StatusError, delete (the first two are identical to PR 1's; whichever lands second drops its
    copy).
  • README "Sandbox route and per-session pairing" with the threat model. No config change.

Process evidence

(The spec, review and lab paths below live in the engineering workspace used to build this series,
not in this repository.)

  • Spec: specs/PR-3.md (v2 frozen after one Opus review round + delta check; 16 findings).
  • Tests first: 55 spec-derived tests by a separate agent (07224a4; four clippy-only ledgered
    changes in specs/PR-3-test-changes.md); 7 writer tests. Linux 62, Windows job 40.
  • Gate (lab/evidence/pr3/gate-v2.log): PASSED.
  • Reviews (reviews/PR-3-*.md): conformance (Sonnet), adversarial (Opus), maintainer (Opus) on v1;
    delta review v2 (Opus) pass with minors, v3 wording-only. Finding fixed: the request log marked
    every sized or empty response as aborted.
  • Lab (lab/evidence/pr3/first-run-060f9bc.md, Windows 11 VM, build = v3 + fix(agent): store Windows secrets as encrypted files keyed from Credential Manager agentdesktop-dev/agentdesktop#171, Claude Code
    2.1.300, 18-Oct): L0–L6 pass, first run by an independent agent from the checklist alone.
    • L1: claude -p in an MXC box with egress denied and host loopback allowed answers OK
      through /sandbox; daemon line session="50d37ab54ffb59f9" … status=200 … outcome="complete";
      gateway POST /v1/messages 200 for the lab user. The same box cannot reach the gateway
      directly (curl: 000). Claude Code used /v1/messages only.
    • L2: the device-wide pairing on /sandbox gets 403 pairing_invalid.
    • L3/L4: a revoked pairing and every pairing after a daemon restart get 403; a new session works.
    • L5: with a 300 s access token, requests at t0 and t0 + 380 s both pass; the gateway sees a
      refreshed token on the second.
    • L6: an invalid client id is refused at creation (400).

Known limits

🤖 Generated with Claude Code

LutzLange and others added 6 commits October 16, 2026 15:52
Failing baseline tests from specs/PR-3.md v2, plus compile-only stubs
(todo!() bodies) for the interface contract.

Tests per OS:
- Linux and Windows: 28 in llm_proxy/sessions.rs (AC 1/5 registry, AC 4
  session API over duplex), 7 in cli.rs (AC 7; 3 parse tests pass on the
  stubs, 3 session_error_message tests fail on todo!()).
- Linux only: 11 new in llm_proxy::tests (AC 2/3 route, lifecycle and
  request log; 1 pure test passes), 2 updated (route matching, route table
  for RouteIdentity and 5 ROUTES entries), 4 in crates/core model.rs (AC 6;
  3 serde round-trips pass on the declarative types), 5 in crates/client
  (StatusError, post_json_response, delete).

Stubs: llm_proxy.rs becomes llm_proxy/mod.rs + sessions.rs; RouteIdentity,
ProxyRoute.identity, the /sandbox ROUTES entry, ProxyConfig.sessions,
RequestLog/LoggedBody; the Session arm of forward is todo!(). daemon.rs
only gains pub(crate) serve_local_connection and the sessions field in its
ProxyConfig literal.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The loopback proxy gains a fifth route, /sandbox, whose pairing is a
per-session value minted over the local API instead of the device pairing:
a sandboxed program holds no credential and no device secret, reaches only
loopback, and presents the session's client id to the gateway.

- sessions.rs: SessionRegistry (SHA-256 of the pairing, constant-time
  lookup over every unexpired entry, sliding idle timeout, bound of 64,
  sweep), the session API (POST/GET /v1/llm-proxy/sessions, DELETE
  /v1/llm-proxy/sessions/{id}, plain-text errors) behind a SessionPolicy
  that checks allowedClientIds and refuses subscription-mode programs at
  creation, and the 60 s sweep task.
- forward: ProxyRoute.client_id became identity: RouteIdentity (Fixed or
  Session); a session arm looks the header up and restarts the idle
  timeout; the path split moved above the Host/Origin/OPTIONS checks so
  every refusal of a sandbox request is logged.
- Sandbox request log: one `LLM proxy sandbox request` info event per
  request (session, client_id, method, path without query, status, code,
  bytes_in, bytes_out, duration_ms, outcome), emitted by LoggedBody when
  the response body ends, fails or is dropped. Fixed routes log nothing new.
- Client crate: StatusError, post_json_response, delete. CLI:
  `agentdesktop llm-proxy session create|list|revoke`.
- daemon: one registry shared by the proxy, the merged session router and
  the sweep; api::program_uses_subscription is pub(crate).

Behaviour change for existing callers: on the prefix-less route a first
path segment starting with `sandbox` is now refused as an unknown route
(404 route_unknown), like the other managed prefixes.

Tests: 54 baseline tests by the test author (sessions 28, llm_proxy 11,
cli 6, client 5, core model 4), 0 added by the writer. Linux: all 54.
Windows job (agent lib only): 34 (sessions 28 + cli 6); the llm_proxy
route and log tests are in the Linux-only module. Three baseline tests
changed for clippy -D warnings only (see the PR 3 test-change ledger).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Documents the session API and CLI, the environment a sandboxed Claude Code
needs, idle timeout, bound and sweep, the log line, the credential-policy
and subscription rules, what a session pairing is worth (bearer on this
machine, not bound to the sandbox, device-pairing files must stay outside
the sandbox, cross-user exposure of the Windows pipe), and the prefix-less
route's new refusal of sandbox* segments.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…olicy tests; session CLI checks

- LoggedBody: hyper never polls a body past its declared end, so a
  Content-Length response finished through Drop as "aborted" and a 204 or
  HEAD answer too. Now `complete` is emitted when the inner body reports
  its end after a frame, and Drop chooses `complete` for a body that has
  reported its end; `aborted` is reserved for a body dropped before it.
- DELETE /v1/llm-proxy/sessions/{id} sweeps expired sessions first, so an
  idled-out session answers 404 like GET no longer lists it.
- CLI: `session revoke` checks the id is 16 lowercase hexadecimal
  characters before building the path; an empty-body 404 (an older daemon
  without the session API) gets its own message; the 429 text takes the
  bound from `sessions::MAX_SESSIONS`.
- Doc comments on ProxyRoute, ROUTES, ProxyConfig.pairing, the sandbox
  route constant and the pairing check in `forward` now cover the session
  route.

Tests added by the writer (all Linux and Windows unless noted):
- llm_proxy (Linux-only module): sandbox_request_log_records_sized_and_empty_responses_as_complete
- sessions: config_policy_admits_a_client_id_the_controller_policy_allows,
  config_policy_refuses_a_client_id_missing_from_allowed_client_ids,
  config_policy_does_not_check_client_ids_with_oidc_or_without_authentication,
  config_policy_refuses_a_program_in_subscription_mode
- cli: a_session_id_is_sixteen_lowercase_hex_characters,
  an_empty_404_means_an_older_daemon_without_the_session_api

Test counts, correcting the v1 message: 55 baseline tests by the test
author (sessions 28, llm_proxy 12, cli 6, client 5, core model 4) plus 7
by the writer. Linux: 62. Windows job (agent lib only): 40 (sessions 32 +
cli 8). Four baseline items changed (three tests for clippy, one helper),
see the PR 3 test-change ledger.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ion API and timeout wording

The routes table gains the /sandbox row; the pairing paragraph names the
session pairing; the allowedClientIds sentence covers session client ids;
the box is started by a launcher, not the daemon; POST answers 409 only
when the bind failed at startup (a proxy that stopped later still mints a
session with an unusable baseUrl); the idle timeout restarts on every
request that presents the pairing, forwarded or refused; a revoked
session's streaming response completes; refused requests log 0 response
bytes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ir identity after a policy change

Wording-only revision after the v2 delta review (pass with minors): the
routes intro no longer claims every prefix belongs to a Copilot program or
that no route takes its client id from the request; a note that the route
name is unrelated to the `sandbox` configuration key; the session section
says a session created before a later subscription or allowedClientIds
change keeps its identity until revoked or idle. No code change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Clock rollback can revive expired pairings, and cancellation before response headers can omit sandbox audit events.

2 open issues
What changed in this PR

Adds per-session pairing to the local LLM proxy so sandboxed clients can use gateway credentials without holding the device pairing.

Changes:

  • Adds session registration, listing, revocation and idle expiry.
  • Introduces /sandbox authentication and request logging.
  • Adds CLI commands, client helpers, tests and usage documentation.
File Description
README.md Documents sandbox setup and security limits.
crates/​core/​src/​model.rs Adds session request and response models.
crates/​client/​src/​lib.rs Adds JSON POST, DELETE and typed errors.
crates/​agent/​src/​llm_proxy/​sessions.rs Implements session registry, policy and API.
crates/​agent/​src/​llm_proxy/​mod.rs Adds sandbox routing, authentication and logging.
crates/​agent/​src/​daemon.rs Connects the registry, API and expiry sweep.
crates/​agent/​src/​cli.rs Adds session management commands.
crates/​agent/​src/​api.rs Exposes subscription-policy checking.

Review effort: Balanced

Comment thread README.md Outdated
Comment thread crates/agent/src/llm_proxy/sessions.rs
…list narrowing reaches running sessions on renewal

Fixes two issues identified in an external code review (fork PR #41, claims 3
and 4):

- sessions.rs:173-205: authorize() now removes an expired pairing when it
  observes it, preventing revival if the system clock steps backward before
  the 60 s sweep. Pre-v4: expired entries were skipped but left in the map.

- README.md:679-691: corrected the claim that narrowing allowedClientIds keeps
  existing sessions until revoked/idle. The controller re-checks its allowlist
  on every credential issuance, and the proxy caches credentials ≤60 s, so
  narrowing makes requests fail within about a minute on renewal.

The v4 commit included a .gitignore entry for a local writer environment; that
line is not carried forward.

Test added: an_expired_pairing_cannot_revive_on_a_backward_clock_step.

Co-Authored-By: Claude Sonnet 5.5 via Claude Code <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Requests canceled before response headers arrive can disappear from the sandbox request log.

No open issues 🎉

1 previously missed issue

In code that hasn't changed since last review

Medium severity Log aborted requests when clients disconnect before upstream headers

crates/​agent/​src/​llm_proxy/​mod.rs:644

If the client disconnects while forward is awaiting credentials or upstream response headers, the service future can be dropped before either result branch runs. LoggedBody does not exist yet, and dropping RequestLog emits nothing, so the sandbox request is missing from the request log. Add a drop guard that emits aborted; the existing sandbox.take() calls disarm it when the event is emitted or transferred to LoggedBody. Keep status 0 to indicate that no response status was obtained, and add a regression test that disconnects before upstream headers arrive.

2 resolved since last review

Review effort: Balanced

When a /sandbox request's client disconnects before the upstream
response arrives (during the credential fetch at llm_proxy/mod.rs:1402
or the client.request await at 1440), hyper drops the handler future
and no log line was emitted - only a debug-level "connection closed"
with no session or path. This contradicted README.md:697-702 ("Each
request on `/sandbox` leaves one line").

Fix: RequestLog::Drop (llm_proxy/mod.rs:648-656) now emits the access
line with outcome=canceled (status 0, response bytes 0) when the log is
dropped with sandbox still present. The normal paths (error arm at 606,
LoggedBody handoff at 596) take the sandbox request out of the log,
disarming the guard, so completed requests log exactly once.

Test: sandbox_request_canceled_before_upstream_headers_still_logs
(llm_proxy/mod.rs:4855) uses an upstream that never responds; the
client task is aborted after 100ms, exactly one line with
outcome=canceled is emitted. Mutation test: without RequestLog::Drop,
the test fails with "expected 1 sandbox log lines, got []".

README updated (697-704): three outcome values documented (complete,
canceled, aborted).

Finding source: external review (fork PR #41 round 3, adversarial
verification).

Co-Authored-By: Claude Sonnet 5.5 via Claude Code <noreply@anthropic.com>
@LutzLange

Copy link
Copy Markdown
Owner Author

The previously-missed item from the last review is fixed in f1a0cd2: a canceled sandbox request now leaves its access line. A Drop guard on the request log emits outcome=canceled when the handler is dropped before the response (client gone during the credential fetch or the upstream wait); the normal paths disarm it, so completed requests still log exactly once. The README now names the three outcomes (complete, canceled, aborted — the last when the client disconnects mid-body). A regression test cancels a request while the upstream stalls and asserts exactly one line.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The new bearer-secret authorization boundary and credential-routing behavior warrant final human security review.

No open issues 🎉

Review effort: Balanced

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants