You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat(agent): per-session pairing and the /sandbox route on the local LLM proxy - #41
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.
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.
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
A session pairing is a bearer secret on this machine (loopback is machine-wide until MXC's allowedProxyPeer is packaged); the box must not read any file holding the device pairing.
Policy is checked at session creation only; all gateway paths are allowed on /sandbox.
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>
…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>
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
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.
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>
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.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
feat(agent): per-session pairing and the
/sandboxroute on the local LLM proxyA launcher (or
agentdesktop llm-proxy session create) registers a session over the local API andreceives a one-off pairing secret bound to a client id. Requests on the new
/sandboxprefix areaccepted 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-memorySessionRegistry(hashed pairings, constant-time compare, slidingidle timeout, bound 64, 60 s sweep),
POST/GET/DELETE /v1/llm-proxy/sessions, creation-time policycheck (
allowedClientIdsunder controller JWT; subscription mode refused).ROUTESgains/sandboxwithRouteIdentity::Session; onetracing::info!line per sandboxrequest at completion (streamed bodies included).
agentdesktop llm-proxy session create|list|revoke; client cratepost_json_response,StatusError,delete(the first two are identical to PR 1's; whichever lands second drops itscopy).
Process evidence
(The spec, review and lab paths below live in the engineering workspace used to build this series,
not in this repository.)
specs/PR-3.md(v2 frozen after one Opus review round + delta check; 16 findings).changes in
specs/PR-3-test-changes.md); 7 writer tests. Linux 62, Windows job 40.lab/evidence/pr3/gate-v2.log): PASSED.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/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 Code2.1.300, 18-Oct): L0–L6 pass, first run by an independent agent from the checklist alone.
claude -pin an MXC box with egress denied and host loopback allowed answersOKthrough
/sandbox; daemon linesession="50d37ab54ffb59f9" … status=200 … outcome="complete";gateway
POST /v1/messages 200for the lab user. The same box cannot reach the gatewaydirectly (
curl: 000). Claude Code used/v1/messagesonly./sandboxgets403 pairing_invalid.403; a new session works.refreshed token on the second.
400).Known limits
allowedProxyPeeris packaged); the box must not read any file holding the device pairing./sandbox.🤖 Generated with Claude Code