Commit 61f4c00
authored
fix: env-file ownership + response panic recovery + oauth header injection (#38)
* fix(container): chown env file back to agent runtime user
Sluice's docker exec runs the env-injection script as the image's
USER (root for the upstream openclaw and hermes images). The awk
pre-pass renames the file (new file inherits the writer's UID) and
the heredoc append writes the new managed block; both leave the
file owned by root. The agent runtime later runs as a non-root user
(UID 10000 for the upstream hermes image, configurable via
HERMES_UID), and a root-owned env file blocks agent-side write
paths like `hermes claw migrate` or in-agent secret edits with
EACCES.
Inheriting the parent directory's owner is portable: every agent's
entrypoint chowns its data dir to its runtime user before sluice
ever exec's in. The chown is best-effort — a `stat` or `chown`
failure does not abort the script (a read-only env file still
satisfies sluice's own usage), so containers without GNU stat
degrade gracefully.
The chown step is appended on the SAME line as the heredoc `<<`
operator with `&&` short-circuiting, because shell will not let
`&&` at the start of a new line attach to a command whose heredoc
body sits in between. New unit test asserts the chown is present
both when the managed block has content and when it is empty.
* fix: response handler panic guard, srm nil-input, bsd stat fallback
Three fixes that turned up while validating v0.13.1's OAuth flow on
the live deployment.
- proxy/addon: add a top-level recover in Response and
StreamResponseModifier with full stack capture. The OAuth Codex
device-code flow surfaced a "nil pointer dereference" panic from
somewhere downstream that go-mitmproxy's outer recover swallowed,
leaving the agent reading an empty response body. The panic
source itself remains unidentified, but escaping it into
go-mitmproxy means losing the response — both handlers now log
the stack and fall back to safe defaults (Response leaves
f.Response unchanged at the panic point; StreamResponseModifier
hands back the original input reader). Real tokens cannot leak:
any panic in Response runs after processOAuthResponseIfMatching's
own snapshot/rollback, so f.Response.Body is either fully
phantom-swapped or untouched at the time of the panic.
- proxy/addon: defensive nil check on the input reader for
StreamResponseModifier. go-mitmproxy normally passes a non-nil
reader, but a nil here causes io.LimitReader + io.ReadAll to
nil-deref on the first Read call. When in is nil we now return
nil, which makes go-mitmproxy's reply() skip its copyStream
branch and fall through to writing f.Response.Body (already
phantom-swapped by the Response handler).
- container/types: portable stat fallback. The chown step from the
earlier commit only worked under GNU stat (Linux containers
alpine/debian); BSD stat (macOS guests booted via the tart
backend) uses `-f`. We now try `-c` first and fall back to `-f`
on non-zero exit so the chown succeeds across both userlands.
* test(proxy): exercise response panic recovery + srm nil-input
- Add a test-only responsePanicHook field to SluiceAddon. When
non-nil it fires between processOAuthResponseIfMatching and the
DLP scan, letting tests force the downstream-of-OAuth panic
shape we observed in production without engineering a malformed
Flow that triggers a real nil deref. Always nil in production.
- TestAddonResponse_RecoversFromDownstreamPanic uses the hook to
panic between OAuth swap and DLP scan, then asserts: the test
itself doesn't see the panic propagate, real tokens stayed out
of f.Response.Body, and the phantom token from the swap is
still present.
- TestAddonStreamResponseModifier_HandlesNilInput passes nil to
StreamResponseModifier and asserts no panic + the handler
returns nil so go-mitmproxy's reply() falls through to writing
f.Response.Body.
- Drop the line-broken hyphen in the Response handler's comment
("processOAuthResponseIfMatching" was wrapping with a hyphen
inside the identifier).
* fix(proxy): extract access_token before applying header binding template
OAuth credentials are stored in the vault as JSON-marshalled
OAuthCredential structs (access_token + refresh_token + token_url +
expires_at). The header-injection path in addon.injectHeader and
quic.go was substituting the binding's `{value}` placeholder with
the entire JSON blob, producing requests like
Authorization: Bearer {"access_token":"<jwt>","refresh_token":...}
that upstream APIs reject with 401 "Could not parse your
authentication token" (reproduced live against chatgpt.com via
the OpenAI Codex device-code OAuth flow). Static credentials are
stored as plain strings so they were unaffected; the bug only
manifested for OAuth-type credentials with a header binding that
uses a template.
This bug has been latent since the bidirectional OAuth phantom
swap was introduced (commit 0be82d4); before that, OAuth tokens
were typically loaded into the vault as raw strings via `sluice
cred update` and the JSON envelope shape never reached the
injection path. The buildPhantomPairs / phantom-swap path was
already OAuth-aware via vault.IsOAuth, so phantom-token replace
in the request body kept working; only the header-injection path
was broken.
Fix: detect a JSON envelope by shape (first byte `{`, parses as
OAuthCredential, has non-empty access_token) and substitute just
the access_token. Static credentials and any other plain string
pass through unchanged. Both the buffered-HTTP path
(internal/proxy/addon.go) and the QUIC/HTTP3 path
(internal/proxy/quic.go) call the same helper.
New test asserts pass-through for static/empty/malformed values
and access_token extraction for an OAuth blob.
* fix(proxy): metadata-driven OAuth dispatch for header injection
The previous extractInjectableSecret inferred OAuth-vs-static from
the secret's JSON shape (first byte `{`, parses as OAuthCredential).
Two issues with that:
- A static credential whose value happened to be OAuth-shaped JSON
(e.g. an upstream that already stores access_token+token_url)
would be silently treated as OAuth, with only access_token
injected. Other code paths in sluice already key on
credential_meta.cred_type to avoid this exact misclassification.
- Leading whitespace or newlines before the brace bypassed the
shape check entirely, even though json.Unmarshal would still
parse it. The OAuth envelope would round-trip through the
injection unchanged.
Fix: dispatch on the credential's metadata type via the
OAuthIndex.Has(credName) lookup. The OAuthIndex is rebuilt from
credential_meta on startup and SIGHUP, so the injection path now
asks the same source-of-truth as the response interceptor. A new
free function extractInjectableSecret(idx, name, secret) takes the
index explicitly so the QUIC path (which carries its own pointer)
shares the same logic with the addon.
Also wires the QUIC proxy with its own oauthIndex pointer plus a
SetOAuthIndex setter; Server.UpdateOAuthIndex now mirrors index
updates into both the addon and the QUIC proxy so HTTP/3 header
injection follows the same dispatch rules as HTTP/1+2.
Test coverage exercises:
- Static cred with plain string -> pass-through.
- Static cred whose value is OAuth-shaped JSON -> still
pass-through (because it's not in the OAuth index).
- OAuth cred with normal JSON -> access_token extraction.
- OAuth cred with leading whitespace -> still extracts
access_token (json.Unmarshal handles whitespace).
- OAuth cred with corrupted vault payload -> falls back to
raw secret with a diagnostic log line.
- nil index -> every credential treated as static (early-startup
path before UpdateOAuthIndex fires).
* fix: copilot review round 4 on PR #38
- proxy/addon: extractInjectableSecret log uses generic [INJECT]
prefix because the helper now serves both the HTTP/1+2 addon
path (internal/proxy/addon.go) and the HTTP/3 (QUIC) path
(internal/proxy/quic.go). [ADDON-INJECT] would mislead a reader
who saw the line in a deployment that uses HTTP/3 exclusively.
- proxy/addon: StreamResponseModifier now returns http.NoBody (an
empty reader) instead of nil when the input reader is nil. The
nil case only fires under f.Stream=true (where the Response
addon callback was skipped, so f.Response.Body is empty), so a
literal nil return left reply() with no body source AND no body
bytes — undefined-length responses trip some HTTP clients.
http.NoBody is well-framed: reply() copies its EOF immediately
and the agent sees a zero-byte body. The nil-input test was
updated to assert the new behavior.
* fix(proxy): srm recover uses buffered bytes when stream is drained
The deferred recover on StreamResponseModifier previously fell back
to `out = in` on panic. After io.ReadAll consumes the input stream
(line 901), `in` is drained — a panic later in swapOAuthTokens or
the body-replacement steps would hand the agent an empty reader
even though we already had the bytes in memory.
Capture the bytes into a closure-scoped `bufferedBody` slice
immediately after the read completes. The recover prefers
bufferedBody if it is set, otherwise falls back to `in` (the read
hasn't started yet, stream is intact). Either way the agent gets a
usable body.
* fix(proxy): cache oauth metas so quic init picks them up
Two PR #38 round 6 follow-ups.
Server now caches the latest credential_meta slice it saw via
UpdateOAuthIndex. setupInjection re-applies the cache to the
QUIC proxy right after assigning s.quicProxy, so an
UpdateOAuthIndex call that arrives before the proxy exists is
not silently dropped. Without the cache, a future reorder of
the startup sequence would re-introduce the OAuth-envelope
header injection bug for HTTP/3 traffic.
Response addon also gets a nil-flow guard mirroring
StreamResponseModifier so the deferred recover does not
dereference a nil flow when tests build a minimal Response
addon path.
* fix(proxy): never fall back to raw oauth body on stream panic
Copilot round 7: the deferred recover in StreamResponseModifier
returned the buffered raw upstream bytes when a panic fired
after io.ReadAll. For an OAuth-matched token endpoint those
bytes contain real access and refresh tokens, so a panic in
swapOAuthTokens (or anywhere between the read and the swap)
would have leaked real credentials to the agent.
Replace bufferedBody with safeFallback. We assign it ONLY
after a successful swap, when the buffer is the phantom-only
modified body. A panic before that point falls back to
http.NoBody. The agent sees an empty 2xx token response and
surfaces the failure as a parse error, which is strictly
preferable to handing over a real bearer.
The non-panic error path (swap parse failure on a non-OAuth
body, e.g. an HTML error page from a misconfigured token
endpoint) keeps the historical pass-through behavior but no
longer assigns safeFallback so a late panic would still hit
http.NoBody rather than echo whatever that body was.
* fix(proxy): guard nil input before flow checks in stream modifier
Copilot round 8: the nil-input guard ran AFTER the early
return for nil flow / nil request, so a call where both `f`
and `in` were nil would skip the guard, leave `out = in` (nil),
and trip a nil io.Reader nil-deref in go-mitmproxy's downstream
copy. Move the nil-input check to the top of the function so
http.NoBody is returned regardless of the flow's shape.1 parent d27b05e commit 61f4c00
7 files changed
Lines changed: 488 additions & 39 deletions
File tree
- internal
- container
- proxy
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
242 | 242 | | |
243 | 243 | | |
244 | 244 | | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
245 | 270 | | |
246 | 271 | | |
247 | 272 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
295 | 295 | | |
296 | 296 | | |
297 | 297 | | |
298 | | - | |
299 | | - | |
300 | | - | |
301 | 298 | | |
302 | | - | |
303 | | - | |
304 | | - | |
305 | | - | |
306 | | - | |
307 | | - | |
308 | | - | |
309 | | - | |
310 | | - | |
311 | | - | |
312 | | - | |
313 | | - | |
314 | | - | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
315 | 357 | | |
316 | | - | |
317 | | - | |
318 | | - | |
319 | | - | |
320 | | - | |
321 | | - | |
322 | | - | |
323 | | - | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
324 | 367 | | |
325 | | - | |
326 | | - | |
327 | | - | |
328 | | - | |
329 | 368 | | |
330 | 369 | | |
331 | 370 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
7 | 7 | | |
8 | 8 | | |
9 | 9 | | |
| 10 | + | |
10 | 11 | | |
11 | 12 | | |
12 | 13 | | |
| |||
127 | 128 | | |
128 | 129 | | |
129 | 130 | | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
130 | 139 | | |
131 | 140 | | |
132 | 141 | | |
| |||
547 | 556 | | |
548 | 557 | | |
549 | 558 | | |
550 | | - | |
| 559 | + | |
551 | 560 | | |
552 | 561 | | |
553 | 562 | | |
554 | 563 | | |
| 564 | + | |
| 565 | + | |
| 566 | + | |
| 567 | + | |
| 568 | + | |
| 569 | + | |
| 570 | + | |
| 571 | + | |
| 572 | + | |
| 573 | + | |
| 574 | + | |
| 575 | + | |
| 576 | + | |
| 577 | + | |
| 578 | + | |
| 579 | + | |
| 580 | + | |
| 581 | + | |
| 582 | + | |
| 583 | + | |
| 584 | + | |
| 585 | + | |
| 586 | + | |
| 587 | + | |
| 588 | + | |
| 589 | + | |
| 590 | + | |
| 591 | + | |
| 592 | + | |
| 593 | + | |
| 594 | + | |
| 595 | + | |
| 596 | + | |
| 597 | + | |
| 598 | + | |
| 599 | + | |
| 600 | + | |
| 601 | + | |
| 602 | + | |
| 603 | + | |
| 604 | + | |
| 605 | + | |
555 | 606 | | |
556 | 607 | | |
557 | 608 | | |
| |||
647 | 698 | | |
648 | 699 | | |
649 | 700 | | |
| 701 | + | |
| 702 | + | |
| 703 | + | |
| 704 | + | |
| 705 | + | |
| 706 | + | |
| 707 | + | |
| 708 | + | |
| 709 | + | |
| 710 | + | |
| 711 | + | |
| 712 | + | |
| 713 | + | |
| 714 | + | |
| 715 | + | |
| 716 | + | |
| 717 | + | |
| 718 | + | |
| 719 | + | |
| 720 | + | |
| 721 | + | |
| 722 | + | |
| 723 | + | |
| 724 | + | |
| 725 | + | |
| 726 | + | |
| 727 | + | |
| 728 | + | |
| 729 | + | |
| 730 | + | |
| 731 | + | |
| 732 | + | |
650 | 733 | | |
651 | 734 | | |
652 | 735 | | |
653 | 736 | | |
654 | 737 | | |
655 | 738 | | |
| 739 | + | |
| 740 | + | |
| 741 | + | |
| 742 | + | |
| 743 | + | |
| 744 | + | |
| 745 | + | |
| 746 | + | |
656 | 747 | | |
657 | 748 | | |
658 | 749 | | |
| |||
711 | 802 | | |
712 | 803 | | |
713 | 804 | | |
714 | | - | |
715 | | - | |
716 | | - | |
717 | | - | |
| 805 | + | |
| 806 | + | |
| 807 | + | |
| 808 | + | |
| 809 | + | |
| 810 | + | |
| 811 | + | |
| 812 | + | |
| 813 | + | |
| 814 | + | |
| 815 | + | |
| 816 | + | |
| 817 | + | |
| 818 | + | |
| 819 | + | |
| 820 | + | |
| 821 | + | |
| 822 | + | |
| 823 | + | |
| 824 | + | |
| 825 | + | |
| 826 | + | |
| 827 | + | |
| 828 | + | |
| 829 | + | |
| 830 | + | |
| 831 | + | |
| 832 | + | |
| 833 | + | |
| 834 | + | |
| 835 | + | |
| 836 | + | |
| 837 | + | |
| 838 | + | |
| 839 | + | |
| 840 | + | |
| 841 | + | |
| 842 | + | |
| 843 | + | |
| 844 | + | |
| 845 | + | |
| 846 | + | |
| 847 | + | |
| 848 | + | |
| 849 | + | |
| 850 | + | |
| 851 | + | |
| 852 | + | |
| 853 | + | |
| 854 | + | |
718 | 855 | | |
719 | 856 | | |
720 | 857 | | |
| |||
782 | 919 | | |
783 | 920 | | |
784 | 921 | | |
| 922 | + | |
| 923 | + | |
| 924 | + | |
| 925 | + | |
| 926 | + | |
| 927 | + | |
| 928 | + | |
785 | 929 | | |
786 | 930 | | |
787 | 931 | | |
788 | 932 | | |
| 933 | + | |
| 934 | + | |
| 935 | + | |
789 | 936 | | |
790 | 937 | | |
791 | 938 | | |
| |||
0 commit comments