Skip to content

Commit 041c38c

Browse files
claudeJanDeDobbeleer
authored andcommitted
fix(serve): forward the full environment to the streaming daemon
When streaming is enabled, the daemon only saw environment variable changes for a hardcoded whitelist (PATH, POSH_* variables, VIRTUAL_ENV, CONDA_PROMPT_MODIFIER), forwarded per-prompt by the shell integration scripts. Anything outside that whitelist - like a variable direnv exports - stayed pinned to whatever value existed when the daemon started, so `{{ .Env.XXX }}` templates never picked up live changes. The daemon now reads the shell's complete environment on every prompt instead: each request's JSON header is unconditionally followed by a raw "KEY=VALUE\0" record stream terminated by an empty record, which the daemon parses with readEnvBlob before applying it via the existing overlay/unset machinery. This wire format needs no escaping (env values can never contain a NUL byte on any OS), fixing latent correctness bugs in the shell-side JSON escapers it replaces (fish silently dropped embedded newlines; zsh's control-character stripper only handled the first stray character due to a single-substitution bug) and is cheaper to produce than the JSON it replaces, particularly in fish. All four shell integrations (fish, zsh, pwsh, cmd/Clink) were updated to send the full env this way instead of a whitelist, including at their abort/quit call sites, which now also send the (empty) blob every request line requires to keep the stream in sync. Fixes #7792.
1 parent 648b291 commit 041c38c

7 files changed

Lines changed: 334 additions & 133 deletions

File tree

src/cli/serve.go

Lines changed: 103 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,15 @@ func init() {
2626
RootCmd.AddCommand(serveCmd)
2727
}
2828

29-
// One JSON object per line on stdin. Unknown fields are ignored by
30-
// encoding/json by default, giving forward compatibility for free.
29+
// One JSON object per line on stdin, immediately followed - for every
30+
// command, not just render - by a raw "KEY=VALUE\x00" record stream
31+
// terminated by an empty record (a bare NUL); see readEnvBlob. Unknown JSON
32+
// fields are ignored by encoding/json by default, giving forward
33+
// compatibility for free.
3134
type serveRequest struct {
32-
Env map[string]string `json:"env"`
35+
// Env is never part of the JSON header - it comes from the raw record
36+
// stream that follows every request line, parsed by readEnvBlob.
37+
Env map[string]string `json:"-"`
3338
Command string `json:"command"`
3439
Shell string `json:"shell"`
3540
ShellVersion string `json:"shell-version"`
@@ -112,9 +117,11 @@ func createServeCmd() *cmdtree.Command {
112117
// primitive fish has) never EOFs the read side. Unix only - the shell owns
113118
// the fifo's lifecycle.
114119
//
115-
// Clients must write each request in a single write(2) call; requests from a
116-
// single sequential writer (one shell session) never interleave regardless
117-
// of size.
120+
// A request (header line plus its env blob, see readEnvBlob) may span more
121+
// than one write(2) call - the reader is not line/buffer-size bound - but
122+
// those calls must be consecutive with no other writer's bytes landing
123+
// between them. A single sequential writer (one shell session, one request
124+
// at a time) guarantees that regardless of size.
118125
func openServeInput(pipePath string) (*os.File, error) {
119126
if pipePath == "" {
120127
return os.Stdin, nil
@@ -139,7 +146,8 @@ type serveActiveCycle struct {
139146
copierDone chan struct{}
140147
}
141148

142-
// runServeLoop reads newline-delimited JSON requests from in and writes
149+
// runServeLoop reads newline-delimited JSON requests (each immediately
150+
// followed by a raw env record blob, see readEnvBlob) from in and writes
143151
// NUL-delimited, cycle-id-prefixed prompt records to out. It returns when it
144152
// reads a quit command or hits EOF on stdin. The returned bool reports
145153
// whether at least one render request was handled, so the caller knows
@@ -152,20 +160,18 @@ type serveActiveCycle struct {
152160
// shell additionally redirects this process's stderr so anything unrecovered
153161
// can never reach the user's terminal.
154162
func runServeLoop(in, out *os.File) bool {
155-
scanner := bufio.NewScanner(in)
156-
// Env payloads (a POSH_* overlay plus PATH) can exceed the default 64 KB
157-
// scanner buffer, so grow it up front.
158-
scanner.Buffer(make([]byte, 0, 256*1024), 1024*1024)
163+
reader := bufio.NewReader(in)
159164

160165
var active *serveActiveCycle
161166
renderedAtLeastOnce := false
162167

163-
// envKeys tracks which variables the previous request's overlay set, so
168+
// envKeys tracks which variables the previous request's env blob set, so
164169
// a variable that disappears from a later request (e.g. VIRTUAL_ENV after
165-
// `deactivate`) gets unset instead of pinning its stale value for the rest
166-
// of the daemon's life. Scoped to the loop so repeated invocations in the
167-
// same process (tests) never inherit a previous loop's keys. The serve
168-
// loop is single-threaded, so no locking.
170+
// `deactivate`, or anything a client stops forwarding) gets unset instead
171+
// of pinning its stale value for the rest of the daemon's life. Scoped to
172+
// the loop so repeated invocations in the same process (tests) never
173+
// inherit a previous loop's keys. The serve loop is single-threaded, so
174+
// no locking.
169175
envKeys := map[string]struct{}{}
170176

171177
stopActiveCycle := func() {
@@ -189,52 +195,108 @@ func runServeLoop(in, out *os.File) bool {
189195
active = nil
190196
}
191197

192-
for scanner.Scan() {
193-
line := scanner.Bytes()
198+
for {
199+
line, err := reader.ReadBytes('\n')
200+
eof := err != nil
194201

202+
line = bytes.TrimSuffix(line, []byte{'\n'})
203+
line = bytes.TrimSuffix(line, []byte{'\r'})
195204
// Strip a UTF-8 BOM: .NET's default UTF8 encoding writes one on the
196205
// StreamWriter's first write, which would otherwise make the first
197206
// request line of a session unparseable.
198207
line = bytes.TrimPrefix(line, []byte{0xEF, 0xBB, 0xBF})
199208

200209
if len(line) == 0 {
210+
if eof {
211+
break
212+
}
201213
continue
202214
}
203215

204-
var req serveRequest
205-
if err := json.Unmarshal(line, &req); err != nil {
206-
// Malformed line: ignore for forward/backward compatibility.
207-
continue
216+
// A well-formed client always sends the env blob right after the
217+
// header line, for every command - even abort/quit send a bare NUL
218+
// terminator. Reading it here, unconditionally, is what keeps the
219+
// stream in sync regardless of the header's command or JSON validity;
220+
// a client that skipped it on some commands would desync every
221+
// request after the first one that did.
222+
env, envErr := readEnvBlob(reader)
223+
if envErr != nil {
224+
// Truncated/closed mid-blob: nothing more can be recovered.
225+
break
208226
}
209227

210-
switch req.Command {
211-
case serveCommandRender:
212-
// A new render request implicitly aborts whatever is running.
213-
stopActiveCycle()
214-
// A nil cycle means setup panicked before prompt.New completed -
215-
// template.Init may never have run, in which case the shutdown
216-
// path must not call template.SaveCache (it dereferences state
217-
// only Init sets). A started cycle implies Init completed.
218-
if active = startRenderCycle(&req, out, envKeys); active != nil {
219-
renderedAtLeastOnce = true
228+
var req serveRequest
229+
if err := json.Unmarshal(line, &req); err == nil {
230+
req.Env = env
231+
232+
switch req.Command {
233+
case serveCommandRender:
234+
// A new render request implicitly aborts whatever is running.
235+
stopActiveCycle()
236+
// A nil cycle means setup panicked before prompt.New completed -
237+
// template.Init may never have run, in which case the shutdown
238+
// path must not call template.SaveCache (it dereferences state
239+
// only Init sets). A started cycle implies Init completed.
240+
if active = startRenderCycle(&req, out, envKeys); active != nil {
241+
renderedAtLeastOnce = true
242+
}
243+
case serveCommandAbort:
244+
stopActiveCycle()
245+
case serveCommandQuit:
246+
stopActiveCycle()
247+
return renderedAtLeastOnce
248+
default:
249+
// Unknown command: ignore for forward compatibility.
220250
}
221-
case serveCommandAbort:
222-
stopActiveCycle()
223-
case serveCommandQuit:
224-
stopActiveCycle()
225-
return renderedAtLeastOnce
226-
default:
227-
// Unknown command: ignore for forward compatibility.
251+
}
252+
// Malformed JSON header: ignored for forward/backward compatibility
253+
// (its env blob was already consumed above, keeping the stream in sync).
254+
255+
if eof {
256+
break
228257
}
229258
}
230259

231-
// EOF (or a scanner error) on stdin: behave like an explicit quit so
232-
// caches are still flushed by the caller's deferred cleanup.
260+
// EOF (or a read error) on stdin: behave like an explicit quit so caches
261+
// are still flushed by the caller's deferred cleanup.
233262
stopActiveCycle()
234263

235264
return renderedAtLeastOnce
236265
}
237266

267+
// readEnvBlob reads a "KEY=VALUE\x00" record stream from r, terminated by an
268+
// empty record (a bare NUL byte). Every request line is unconditionally
269+
// followed by this blob - even for commands that ignore its contents - so
270+
// the reader never needs to know in advance whether one is coming.
271+
//
272+
// Environment variable values cannot contain a NUL byte on any OS this
273+
// project targets (POSIX environ entries and the Windows environment block
274+
// are themselves NUL-terminated/-delimited C strings), so this framing needs
275+
// no escaping: a key/value pair is malformed only if it has no '=', in which
276+
// case it is skipped.
277+
func readEnvBlob(r *bufio.Reader) (map[string]string, error) {
278+
env := map[string]string{}
279+
280+
for {
281+
record, err := r.ReadBytes(0)
282+
if err != nil {
283+
return nil, err
284+
}
285+
286+
record = record[:len(record)-1] // drop the trailing NUL delimiter
287+
if len(record) == 0 {
288+
return env, nil
289+
}
290+
291+
key, value, found := bytes.Cut(record, []byte{'='})
292+
if !found {
293+
continue
294+
}
295+
296+
env[string(key)] = string(value)
297+
}
298+
}
299+
238300
func applyEnvOverlay(env map[string]string, keys map[string]struct{}) {
239301
for key := range keys {
240302
if _, ok := env[key]; ok {

src/cli/serve_pipe_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,9 @@ func TestServeLoop_RequestPipe(t *testing.T) {
5353
data, err := json.Marshal(v)
5454
require.NoError(t, err)
5555

56-
_, err = f.Write(append(data, '\n'))
56+
data = append(data, '\n', 0) // trailing 0: empty env blob, just the terminator
57+
58+
_, err = f.Write(data)
5759
require.NoError(t, err)
5860
require.NoError(t, f.Close())
5961
}

src/cli/serve_test.go

Lines changed: 114 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,40 @@ func startServeHarness(t *testing.T) *serveHarness {
5959
return h
6060
}
6161

62-
// send writes a single newline-terminated JSON request to the loop's stdin.
62+
// send writes a newline-terminated JSON header to the loop's stdin, followed
63+
// by the NUL-delimited env blob every request must carry (see readEnvBlob).
64+
// v may carry an "env" key (map[string]string) - if present, it is pulled
65+
// out of the JSON header and sent as the raw blob instead; a request with no
66+
// "env" key sends an empty blob (just the terminator).
6367
func (h *serveHarness) send(v any) {
6468
h.t.Helper()
6569

70+
env := map[string]string{}
71+
if m, ok := v.(map[string]any); ok {
72+
if raw, ok := m["env"]; ok {
73+
delete(m, "env")
74+
typed, ok := raw.(map[string]string)
75+
require.True(h.t, ok, "send: \"env\" must be a map[string]string, got %T", raw)
76+
env = typed
77+
}
78+
}
79+
6680
data, err := json.Marshal(v)
6781
require.NoError(h.t, err)
6882

69-
_, err = h.stdin.Write(append(data, '\n'))
83+
var buf bytes.Buffer
84+
buf.Write(data)
85+
buf.WriteByte('\n')
86+
87+
for key, value := range env {
88+
buf.WriteString(key)
89+
buf.WriteByte('=')
90+
buf.WriteString(value)
91+
buf.WriteByte(0)
92+
}
93+
buf.WriteByte(0) // empty record: terminates the blob
94+
95+
_, err = h.stdin.Write(buf.Bytes())
7096
require.NoError(h.t, err)
7197
}
7298

@@ -430,6 +456,91 @@ func TestServeLoop_EnvOverlayUnsetsVanishedVariables(t *testing.T) {
430456
h.quitAndWait()
431457
}
432458

459+
// TestServeLoop_EnvBlobHandlesArbitraryValues guards the reason env forwarding
460+
// moved off JSON: a value with a literal newline, tab, quote, backslash, or
461+
// non-ASCII byte must reach the daemon byte-exact, with no escaping logic to
462+
// get wrong. This would corrupt or silently drop such values under JSON
463+
// string escaping (which is exactly what the whitelist-based overlay hid,
464+
// since it only ever carried PATH/POSH_*-shaped values).
465+
func TestServeLoop_EnvBlobHandlesArbitraryValues(t *testing.T) {
466+
h := startServeHarness(t)
467+
pwd := t.TempDir()
468+
chdirBackToWD(t)
469+
470+
const name = "POSH_SERVE_ENV_ARBITRARY_TEST"
471+
t.Cleanup(func() { _ = os.Unsetenv(name) })
472+
473+
value := "line1\nline2\ttab\"quote\\backénd"
474+
475+
h.send(map[string]any{
476+
"command": "render", "id": 1, "shell": "pwsh", "pwd": pwd,
477+
"env": map[string]string{name: value},
478+
})
479+
records := h.records(500 * time.Millisecond)
480+
require.NotEmpty(t, records)
481+
assert.Equal(t, value, os.Getenv(name), "env value must survive the blob byte-exact, unescaped")
482+
483+
h.quitAndWait()
484+
}
485+
486+
// TestReadEnvBlob_MalformedRecordsSkipped guards readEnvBlob's parsing rules
487+
// directly: a record with no '=' is dropped rather than corrupting the map
488+
// or aborting the parse, and an empty blob (just the terminator) parses to
489+
// an empty, non-nil map.
490+
func TestReadEnvBlob_MalformedRecordsSkipped(t *testing.T) {
491+
blob := "NOEQUALSSIGN\x00KEY=value\x00ANOTHER=a=b=c\x00\x00"
492+
reader := bufio.NewReader(bytes.NewBufferString(blob))
493+
494+
env, err := readEnvBlob(reader)
495+
require.NoError(t, err)
496+
assert.NotContains(t, env, "NOEQUALSSIGN", "a record with no '=' must be skipped, not stored under an empty value")
497+
assert.Equal(t, "value", env["KEY"])
498+
assert.Equal(t, "a=b=c", env["ANOTHER"], "only the first '=' splits key from value")
499+
500+
empty, err := readEnvBlob(bufio.NewReader(bytes.NewBufferString("\x00")))
501+
require.NoError(t, err)
502+
assert.Empty(t, empty)
503+
}
504+
505+
// TestReadEnvBlob_TruncatedBlobReturnsError guards the case where the
506+
// connection closes mid-record, before the terminating empty record ever
507+
// arrives: readEnvBlob must report an error (so runServeLoop treats it like
508+
// EOF and shuts down) rather than block forever or return a partial map.
509+
func TestReadEnvBlob_TruncatedBlobReturnsError(t *testing.T) {
510+
reader := bufio.NewReader(bytes.NewBufferString("KEY=value\x00TRUNC=no-terminator-ever"))
511+
512+
env, err := readEnvBlob(reader)
513+
require.Error(t, err)
514+
assert.Nil(t, env)
515+
}
516+
517+
// TestServeLoop_MalformedHeaderStillConsumesBlobThenNextRequestRenders is
518+
// the central desync-resilience property runServeLoop's comments claim: a
519+
// non-empty header line that fails JSON parsing must still have its env blob
520+
// consumed (every header is unconditionally followed by one), or the next
521+
// request's header would be misread as more of the previous blob and the
522+
// loop would never render again.
523+
func TestServeLoop_MalformedHeaderStillConsumesBlobThenNextRequestRenders(t *testing.T) {
524+
h := startServeHarness(t)
525+
pwd := t.TempDir()
526+
chdirBackToWD(t)
527+
528+
// A malformed (non-JSON) header line, followed by its own non-empty env
529+
// blob - exactly what a well-formed client always sends, just with a
530+
// garbled header. The header still needs its own newline terminator;
531+
// omitting it would just make this one long header line, defeating the
532+
// point of the test.
533+
_, err := h.stdin.Write([]byte("not valid json\nSOME=value\x00\x00"))
534+
require.NoError(t, err)
535+
536+
h.render(1, pwd)
537+
records := h.records(500 * time.Millisecond)
538+
require.NotEmpty(t, records, "a render after a malformed header must still produce records - the stream must not have desynced")
539+
assert.Equal(t, "1", records[0].id)
540+
541+
h.quitAndWait()
542+
}
543+
433544
func TestServeLoop_QuitExitsCleanly(t *testing.T) {
434545
h := startServeHarness(t)
435546

@@ -496,7 +607,7 @@ func TestServeLoop_UTF8BOMOnFirstLine(t *testing.T) {
496607
require.NoError(t, err)
497608

498609
payload := append([]byte{0xEF, 0xBB, 0xBF}, data...)
499-
payload = append(payload, '\n')
610+
payload = append(payload, '\n', 0) // trailing 0: empty env blob, just the terminator
500611
_, err = h.stdin.Write(payload)
501612
require.NoError(t, err)
502613

0 commit comments

Comments
 (0)