Problem class: dagger-tool-mediator-secret-echo-invalid-http-header
net/http: invalid header field value for "X-Api-Key" (502)Problem class: dagger-tool-mediator-secret-echo-invalid-http-header
Stack: Hermes DAGger (TypeScript spec pm.ts + Go bridge), DuckBrain HTTP API
Landed fix: scheduler repo 36c257b — thread the secret as a DAGGER_ENV K=V pair from the invoking shell and read env("DUCKBRAIN_KEY") in the spec. Never fetch a secret through tool().
<project> pm.ts (trace_duck_write) fetched the DuckBrain API key with a tool call equivalent to:
const key = await tool("terminal", { cmd: "cat /secure/duckbrain.token" });
The write then failed on every attempt:
POST /api/memories?namespace=stand-in-pm
→ status=502
→ Post "http://localhost:3000/api/memories?namespace=stand-in-pm":
net/http: invalid header field value for "X-Api-Key"
3 attempts, then the verify node was dependency-skipped (the write never produced an artifact to verify). A manual probe with a clean token returned 200, which proves the API, URL, and namespace are fine — only the header value is bad.
The secret was treated as content and forced through an LLM-mediated transport:
tool("terminal", …) is executed by the Hermes LLM tool-executor, not by a direct process spawn. The tool result is returned to the model, and tool() hands back the model-relayed reply.Here is the key you asked for:, indentation, a trailing newline, and often ANSI/TAB/CR control bytes. The value that comes back is roughly:"Here is the DuckBrain key you asked for:\n sk-live-…\t\n"
pm.ts put that string straight into the request headers:ts
fetch(url, { method: "POST", headers: { "X-Api-Key": key }, body });
fetch() is a Go bridge call. Go's net/http validates every header field value at request-write time using internal/httpguts.ValidHeaderFieldValue: it rejects any byte < 0x20 except HTAB (0x09), and 0x7f. The embedded \n/\r/\t makes the value invalid, so the transport returnsnet/http: invalid header field value for "X-Api-Key"
The surrounding service maps that client-side transport error to 502 Bad Gateway.
trim() / “unwrap the prose” is not a fixtrim() leaves foo\nbar broken.slice/extract on an LLM paraphrase is probabilistic. It will eventually cut a valid key, or pass a crafted one.The correct model: secrets are injected out of band at the Go bridge layer, and the sandbox spec reads them by name.
Hermes DAGger already supports this. From dagger --help (Run flags):
DAGGER_ENV Per-run env vars for the spec, comma-separated K=V pairs
and from the bundled dagger.d.ts:
/**
* Read an environment variable.
* Variables are injected at the Go bridge layer —
* sandbox code never sees raw credentials.
*/
declare function env(name: string): string | undefined;
So: the invoking shell reads the secret from the file (once, with direct byte access), and passes it to the DAG as DAGGER_ENV. The spec reads it with env().
cat through the agent# Run this where the process that spawns `dagger run` lives.
# `tr -d '\r\n'` strips the trailing newline that a naive `$(cat …)` may keep.
DUCKBRAIN_KEY="$(cat /etc/coding-hermes/duckbrain.token | tr -d '\r\n')"
# Sanity check: no CTL bytes, no whitespace. Fail before invoking.
case "$DUCKBRAIN_KEY" in
'' ) echo "DUCKBRAIN_KEY is empty" >&2; exit 1 ;;
esac
if printf '%s' "$DUCKBRAIN_KEY" | LC_ALL=C grep -q '[[:cntrl:][:space:]]'; then
echo "DUCKBRAIN_KEY contains control/whitespace bytes" >&2; exit 1
fi
# Export for the DAG runner. Comma-separated K=V list (multiple secrets allowed).
export DAGGER_ENV="DUCKBRAIN_KEY=$DUCKBRAIN_KEY"
dagger run pm.ts
Multiple secrets:
export DAGGER_ENV="DUCKBRAIN_KEY=$DUCKBRAIN_KEY,OTHER_TOKEN=$OTHER_TOKEN"
Verified parsing semantics: split on ,, then on the first =; values may contain = and spaces, and K= yields the empty string:
DAGGER_ENV='A=1,B=2,DUCKBRAIN_KEY=abc=def,EMPTY=' → A=1, B=2, K=abc=def, E=""
Note: because the list is comma-separated, a value containing a literal comma is ambiguous. API keys normally do not; if yours can, URL-encode it at the shell and decode it in the spec.
pm.ts — prefer env() over tool(), hard-fail on a bad secretBefore (broken):
const key: string = await tool("terminal", { cmd: "cat /secure/duckbrain.token" });
const resp = await fetch(`${duckUrl}/api/memories?namespace=${ns}`, {
method: "POST",
headers: { "X-Api-Key": key, "Content-Type": "application/json" },
body: JSON.stringify(memory),
});
After (fixed):
// Direct, out-of-band secret read. `env()` is served by the Go bridge;
// the value is never sent to the model and is never paraphrased.
const key = env("DUCKBRAIN_KEY");
if (typeof key !== "string" || key.length === 0) {
throw new Error("DUCKBRAIN_KEY not set — pass it via DAGGER_ENV from the invoking shell");
}
// Defense in depth: mirror Go's net/http header rule so the failure is a
// clear, local error instead of a 502 from the transport.
// Reject C0 controls except HTAB, plus DEL.
if (/[\u0000-\u0008\u000a-\u001f\u007f]/.test(key)) {
throw new Error("DUCKBRAIN_KEY contains control bytes; refusing to use it as a header");
}
const resp = await fetch(`${duckUrl}/api/memories?namespace=${ns}`, {
method: "POST",
headers: { "X-Api-Key": key, "Content-Type": "application/json" },
body: JSON.stringify(memory),
});
The env-first preference belongs in one place (the spec helper) so no future node can reintroduce the tool() path. A safe shape:
function duckbrainKey(): string {
const k = env("DUCKBRAIN_KEY");
if (typeof k !== "string" || k.length === 0) {
throw new Error("DUCKBRAIN_KEY not set (use DAGGER_ENV)");
}
if (/[\u0000-\u0008\u000a-\u001f\u007f]/.test(k)) {
throw new Error("DUCKBRAIN_KEY contains control bytes");
}
return k;
}
// ✗ secret in the LLM context, paraphrased on the way back, leaks to logs
const key = await tool("terminal", { cmd: "cat /secure/duckbrain.token" });
const key = await tool("read_file", { path: "/secure/duckbrain.token" });
Mirror Go's own rule so a poisoned header fails with a precise message before net/http turns it into a 502:
// validHeaderValue mirrors internal/httpguts.ValidHeaderFieldValue:
// HTAB is allowed; every other C0 control and DEL is not.
func validHeaderValue(v string) bool {
for i := 0; i < len(v); i++ {
b := v[i]
if b == '\t' {
continue
}
if b < 0x20 || b == 0x7f {
return false
}
}
return true
}
func duckbrainKey() (string, error) {
k := os.Getenv("DUCKBRAIN_KEY")
if k == "" {
return "", errors.New("DUCKBRAIN_KEY is not set")
}
if !validHeaderValue(k) {
return "", errors.New("DUCKBRAIN_KEY contains control bytes")
}
return k, nil
}
Also ensure the key is redacted everywhere it could be logged (X-Api-Key: ***), and keep it out of DAG node inputs/outputs, event logs, and checkpoints.
All commands/observations below were reproduced live against the Hermes DAGger build in this environment (Hermes DAGger v0.1.0 (build 33d789a)), with the sandbox compiler pinned because /usr/local/bin/corsa is a broken symlink here:
export DAGGER_API_TOKEN=0123456789abcdef0123456789abcdef0123456789abcdef # ≥32 bytes
export DAGGER_CORSA_BIN=/tmp/pi/node_modules/.bin/tsgo
dagger serve --addr <ip-address>:19090 --db /tmp/dgtest/dagger.db --tier 3 &
// bad_probe.ts
const bad = dag.node("bad", (prev: any): any => {
const key: string = "Here is the DuckBrain key you asked for:\n sk-live-abc123\t\n";
const r: any = fetch("http://<ip-address>:3000/api/memories?namespace=probe", {
method: "POST",
headers: { "X-Api-Key": key, "Content-Type": "application/json" },
body: "{}",
});
return JSON.stringify(r);
});
dagger run --server http://<ip-address>:19090 --log-level debug \
--log-file /tmp/dgtest/bad.jsonl bad_probe.ts
Observed node output (the exact production signature):
{"status":502,"statusText":"bad gateway","headers":{},
"body":"Post \"http://<ip-address>:3000/api/memories?namespace=probe\": net/http: invalid header field value for \"X-Api-Key\""}
env() returns the exact clean value// env_probe.ts
const probe = dag.node("probe", (prev: any): any => {
const k: any = env("DUCKBRAIN_KEY");
return JSON.stringify({ present: k !== undefined, len: k ? k.length : 0, value: k });
});
DAGGER_ENV='DUCKBRAIN_KEY=abc123' dagger run --server http://<ip-address>:19090 env_probe.ts
# → {"present":true,"len":6,"value":"abc123"}
dagger run --server http://<ip-address>:19090 env_probe.ts
# → {"present":false,"len":0} # fail-fast path when DAGGER_ENV is missing
The multi-key / = / empty-value forms were checked too:
DAGGER_ENV='A=1,B=2,DUCKBRAIN_KEY=abc=def,EMPTY=' → {"A":"1","B":"2","K":"abc=def","E":""}
DAGGER_ENV='DUCKBRAIN_KEY=abc 123' → {"K":"abc 123"}
env() instead of tool()const good = dag.node("good", (prev: any): any => {
const key: any = env("DUCKBRAIN_KEY");
const r: any = fetch("http://<ip-address>:3000/api/memories?namespace=probe", {
method: "POST",
headers: { "X-Api-Key": key, "Content-Type": "application/json" },
body: "{}",
});
return JSON.stringify(r);
});
DAGGER_ENV='DUCKBRAIN_KEY=sk-live-abc123' dagger run --server http://<ip-address>:19090 good_probe.ts
# → {"status":401,...} ← header accepted; DuckBrain answered (401 only because this is a dummy key)
The key point: the clean env() value never produces invalid header field value/502 — the HTTP stack accepted the header and the server answered on its own terms.
With the real token loaded from its file directly in the shell (no LLM in the loop):
DUCKBRAIN_KEY="$(cat /etc/coding-hermes/duckbrain.token | tr -d '\r\n')"
# token hygiene
printf '%s' "$DUCKBRAIN_KEY" | LC_ALL=C grep -q '[[:cntrl:][:space:]]' \
&& { echo "BAD TOKEN BYTES"; exit 1; } || echo "token bytes clean"
# 1) direct API probe — must be 200
curl -sS -o /dev/null -w '%{http_code}\n' \
-H "X-Api-Key: $DUCKBRAIN_KEY" \
"http://localhost:3000/api/memories?namespace=stand-in-pm"
# 2) the DAG path must match it
export DAGGER_ENV="DUCKBRAIN_KEY=$DUCKBRAIN_KEY"
dagger run pm.ts
Expected: 200 on (1) and a completed trace_duck_write (no 502, no invalid header field value) on (2).
bash
! grep -RInE 'tool\(("|\x27)(terminal|read_file|shell|bash)("|\x27)' \
specs/ pm.ts | grep -iE 'token|key|secret|credential'
env() is required — a unit/lint case asserting pm.ts calls env("DUCKBRAIN_KEY") and never assigns a header from tool() output.
Fail-fast test — invoke dagger run pm.ts with DAGGER_ENV unset; it must fail immediately with DUCKBRAIN_KEY not set, not attempt the write.
Header-value test — feed the helper a value containing \n, \r, \x00, \x7f; each must throw before fetch().
cat … | tr -d '\r\n', validates it, and exports DAGGER_ENV="DUCKBRAIN_KEY=$DUCKBRAIN_KEY".pm.ts reads env("DUCKBRAIN_KEY"), throws if absent, and rejects control bytes before building headers.tool() call anywhere fetches a token/key/secret.env() path.# Evidence - Problem class: dagger-tool-mediator-secret-echo-invalid-http-header - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-16T01:25:30.037Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "<project> pm.ts trace_duck_write fetched the DuckBrain API key via LLM-mediated tool(terminal, cat tokenfile). The mediator paraphrased the reply (prose + control chars) so Go http rejected the X-Api-Key header: POST /api/memories -> 502 net/http: invalid header field value for X-Api-Key; 3 attempts, verify chain dependency-skipped. FIX (landed, scheduler repo 36c257b): thread secrets as DAGGER_ENV K=V pairs from the invoking shell and read env(\"DUCKBRAIN_KEY\") in the spec (pm.ts already prefers env over tool()); never fetch secrets through the tool() LLM mediator \u2014 trim/unwrap cannot strip prose containing control bytes. Verified live: clean-token GET probe 200.", "environment": "linux", "language": "go+typescript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "dagger-tool-mediator-secret-echo-invalid-http-header", "provider": "openrouter", "solved_at": "2026-09-16T01:25:30.037Z", "version": ""}net/http: invalid header field value for "X-Api-Key" (502)Problem class: dagger-tool-mediator-secret-echo-invalid-http-header
Stack: Hermes DAGger (TypeScript spec pm.ts + Go bridge), DuckBrain HTTP API
Landed fix: scheduler repo 36c257b — thread the secret as a DAGGER_ENV K=V pair from the invoking shell and read env("DUCKBRAIN_KEY") in the spec. Never fetch a secret through tool().
<project> pm.ts (trace_duck_write) fetched the DuckBrain API key with a tool call equivalent to:
const key = await tool("terminal", { cmd: "cat /secure/duckbrain.token" });
The write then failed on every attempt:
POST /api/memories?namespace=stand-in-pm
→ status=502
→ Post "http://localhost:3000/api/memories?namespace=stand-in-pm":
net/http: invalid header field value for "X-Api-Key"
3 attempts, then the verify node was dependency-skipped (the write never produced an artifact to verify). A manual probe with a clean token returned 200, which proves the API, URL, and namespace are fine — only the header value is bad.
The secret was treated as content and forced through an LLM-mediated transport:
tool("terminal", …) is executed by the Hermes LLM tool-executor, not by a direct process spawn. The tool result is returned to the model, and tool() hands back the model-relayed reply.Here is the key you asked for:, indentation, a trailing newline, and often ANSI/TAB/CR control bytes. The value that comes back is roughly:"Here is the DuckBrain key you asked for:\n sk-live-…\t\n"
pm.ts put that string straight into the request headers:ts
fetch(url, { method: "POST", headers: { "X-Api-Key": key }, body });
fetch() is a Go bridge call. Go's net/http validates every header field value at request-write time using internal/httpguts.ValidHeaderFieldValue: it rejects any byte < 0x20 except HTAB (0x09), and 0x7f. The embedded \n/\r/\t makes the value invalid, so the transport returnsnet/http: invalid header field value for "X-Api-Key"
The surrounding service maps that client-side transport error to 502 Bad Gateway.
trim() / “unwrap the prose” is not a fixtrim() leaves foo\nbar broken.slice/extract on an LLM paraphrase is probabilistic. It will eventually cut a valid key, or pass a crafted one.The correct model: secrets are injected out of band at the Go bridge layer, and the sandbox spec reads them by name.
Hermes DAGger already supports this. From dagger --help (Run flags):
DAGGER_ENV Per-run env vars for the spec, comma-separated K=V pairs
and from the bundled dagger.d.ts:
/**
* Read an environment variable.
* Variables are injected at the Go bridge layer —
* sandbox code never sees raw credentials.
*/
declare function env(name: string): string | undefined;
So: the invoking shell reads the secret from the file (once, with direct byte access), and passes it to the DAG as DAGGER_ENV. The spec reads it with env().
cat through the agent# Run this where the process that spawns `dagger run` lives.
# `tr -d '\r\n'` strips the trailing newline that a naive `$(cat …)` may keep.
DUCKBRAIN_KEY="$(cat /etc/coding-hermes/duckbrain.token | tr -d '\r\n')"
# Sanity check: no CTL bytes, no whitespace. Fail before invoking.
case "$DUCKBRAIN_KEY" in
'' ) echo "DUCKBRAIN_KEY is empty" >&2; exit 1 ;;
esac
if printf '%s' "$DUCKBRAIN_KEY" | LC_ALL=C grep -q '[[:cntrl:][:space:]]'; then
echo "DUCKBRAIN_KEY contains control/whitespace bytes" >&2; exit 1
fi
# Export for the DAG runner. Comma-separated K=V list (multiple secrets allowed).
export DAGGER_ENV="DUCKBRAIN_KEY=$DUCKBRAIN_KEY"
dagger run pm.ts
Multiple secrets:
export DAGGER_ENV="DUCKBRAIN_KEY=$DUCKBRAIN_KEY,OTHER_TOKEN=$OTHER_TOKEN"
Verified parsing semantics: split on ,, then on the first =; values may contain = and spaces, and K= yields the empty string:
DAGGER_ENV='A=1,B=2,DUCKBRAIN_KEY=abc=def,EMPTY=' → A=1, B=2, K=abc=def, E=""
Note: because the list is comma-separated, a value containing a literal comma is ambiguous. API keys normally do not; if yours can, URL-encode it at the shell and decode it in the spec.
pm.ts — prefer env() over tool(), hard-fail on a bad secretBefore (broken):
const key: string = await tool("terminal", { cmd: "cat /secure/duckbrain.token" });
const resp = await fetch(`${duckUrl}/api/memories?namespace=${ns}`, {
method: "POST",
headers: { "X-Api-Key": key, "Content-Type": "application/json" },
body: JSON.stringify(memory),
});
After (fixed):
// Direct, out-of-band secret read. `env()` is served by the Go bridge;
// the value is never sent to the model and is never paraphrased.
const key = env("DUCKBRAIN_KEY");
if (typeof key !== "string" || key.length === 0) {
throw new Error("DUCKBRAIN_KEY not set — pass it via DAGGER_ENV from the invoking shell");
}
// Defense in depth: mirror Go's net/http header rule so the failure is a
// clear, local error instead of a 502 from the transport.
// Reject C0 controls except HTAB, plus DEL.
if (/[\u0000-\u0008\u000a-\u001f\u007f]/.test(key)) {
throw new Error("DUCKBRAIN_KEY contains control bytes; refusing to use it as a header");
}
const resp = await fetch(`${duckUrl}/api/memories?namespace=${ns}`, {
method: "POST",
headers: { "X-Api-Key": key, "Content-Type": "application/json" },
body: JSON.stringify(memory),
});
The env-first preference belongs in one place (the spec helper) so no future node can reintroduce the tool() path. A safe shape:
function duckbrainKey(): string {
const k = env("DUCKBRAIN_KEY");
if (typeof k !== "string" || k.length === 0) {
throw new Error("DUCKBRAIN_KEY not set (use DAGGER_ENV)");
}
if (/[\u0000-\u0008\u000a-\u001f\u007f]/.test(k)) {
throw new Error("DUCKBRAIN_KEY contains control bytes");
}
return k;
}
// ✗ secret in the LLM context, paraphrased on the way back, leaks to logs
const key = await tool("terminal", { cmd: "cat /secure/duckbrain.token" });
const key = await tool("read_file", { path: "/secure/duckbrain.token" });
Mirror Go's own rule so a poisoned header fails with a precise message before net/http turns it into a 502:
// validHeaderValue mirrors internal/httpguts.ValidHeaderFieldValue:
// HTAB is allowed; every other C0 control and DEL is not.
func validHeaderValue(v string) bool {
for i := 0; i < len(v); i++ {
b := v[i]
if b == '\t' {
continue
}
if b < 0x20 || b == 0x7f {
return false
}
}
return true
}
func duckbrainKey() (string, error) {
k := os.Getenv("DUCKBRAIN_KEY")
if k == "" {
return "", errors.New("DUCKBRAIN_KEY is not set")
}
if !validHeaderValue(k) {
return "", errors.New("DUCKBRAIN_KEY contains control bytes")
}
return k, nil
}
Also ensure the key is redacted everywhere it could be logged (X-Api-Key: ***), and keep it out of DAG node inputs/outputs, event logs, and checkpoints.
All commands/observations below were reproduced live against the Hermes DAGger build in this environment (Hermes DAGger v0.1.0 (build 33d789a)), with the sandbox compiler pinned because /usr/local/bin/corsa is a broken symlink here:
export DAGGER_API_TOKEN=0123456789abcdef0123456789abcdef0123456789abcdef # ≥32 bytes
export DAGGER_CORSA_BIN=/tmp/pi/node_modules/.bin/tsgo
dagger serve --addr <ip-address>:19090 --db /tmp/dgtest/dagger.db --tier 3 &
// bad_probe.ts
const bad = dag.node("bad", (prev: any): any => {
const key: string = "Here is the DuckBrain key you asked for:\n sk-live-abc123\t\n";
const r: any = fetch("http://<ip-address>:3000/api/memories?namespace=probe", {
method: "POST",
headers: { "X-Api-Key": key, "Content-Type": "application/json" },
body: "{}",
});
return JSON.stringify(r);
});
dagger run --server http://<ip-address>:19090 --log-level debug \
--log-file /tmp/dgtest/bad.jsonl bad_probe.ts
Observed node output (the exact production signature):
{"status":502,"statusText":"bad gateway","headers":{},
"body":"Post \"http://<ip-address>:3000/api/memories?namespace=probe\": net/http: invalid header field value for \"X-Api-Key\""}
env() returns the exact clean value// env_probe.ts
const probe = dag.node("probe", (prev: any): any => {
const k: any = env("DUCKBRAIN_KEY");
return JSON.stringify({ present: k !== undefined, len: k ? k.length : 0, value: k });
});
DAGGER_ENV='DUCKBRAIN_KEY=abc123' dagger run --server http://<ip-address>:19090 env_probe.ts
# → {"present":true,"len":6,"value":"abc123"}
dagger run --server http://<ip-address>:19090 env_probe.ts
# → {"present":false,"len":0} # fail-fast path when DAGGER_ENV is missing
The multi-key / = / empty-value forms were checked too:
DAGGER_ENV='A=1,B=2,DUCKBRAIN_KEY=abc=def,EMPTY=' → {"A":"1","B":"2","K":"abc=def","E":""}
DAGGER_ENV='DUCKBRAIN_KEY=abc 123' → {"K":"abc 123"}
env() instead of tool()const good = dag.node("good", (prev: any): any => {
const key: any = env("DUCKBRAIN_KEY");
const r: any = fetch("http://<ip-address>:3000/api/memories?namespace=probe", {
method: "POST",
headers: { "X-Api-Key": key, "Content-Type": "application/json" },
body: "{}",
});
return JSON.stringify(r);
});
DAGGER_ENV='DUCKBRAIN_KEY=sk-live-abc123' dagger run --server http://<ip-address>:19090 good_probe.ts
# → {"status":401,...} ← header accepted; DuckBrain answered (401 only because this is a dummy key)
The key point: the clean env() value never produces invalid header field value/502 — the HTTP stack accepted the header and the server answered on its own terms.
With the real token loaded from its file directly in the shell (no LLM in the loop):
DUCKBRAIN_KEY="$(cat /etc/coding-hermes/duckbrain.token | tr -d '\r\n')"
# token hygiene
printf '%s' "$DUCKBRAIN_KEY" | LC_ALL=C grep -q '[[:cntrl:][:space:]]' \
&& { echo "BAD TOKEN BYTES"; exit 1; } || echo "token bytes clean"
# 1) direct API probe — must be 200
curl -sS -o /dev/null -w '%{http_code}\n' \
-H "X-Api-Key: $DUCKBRAIN_KEY" \
"http://localhost:3000/api/memories?namespace=stand-in-pm"
# 2) the DAG path must match it
export DAGGER_ENV="DUCKBRAIN_KEY=$DUCKBRAIN_KEY"
dagger run pm.ts
Expected: 200 on (1) and a completed trace_duck_write (no 502, no invalid header field value) on (2).
bash
! grep -RInE 'tool\(("|\x27)(terminal|read_file|shell|bash)("|\x27)' \
specs/ pm.ts | grep -iE 'token|key|secret|credential'
env() is required — a unit/lint case asserting pm.ts calls env("DUCKBRAIN_KEY") and never assigns a header from tool() output.
Fail-fast test — invoke dagger run pm.ts with DAGGER_ENV unset; it must fail immediately with DUCKBRAIN_KEY not set, not attempt the write.
Header-value test — feed the helper a value containing \n, \r, \x00, \x7f; each must throw before fetch().
cat … | tr -d '\r\n', validates it, and exports DAGGER_ENV="DUCKBRAIN_KEY=$DUCKBRAIN_KEY".pm.ts reads env("DUCKBRAIN_KEY"), throws if absent, and rejects control bytes before building headers.tool() call anywhere fetches a token/key/secret.env() path.# Evidence - Problem class: dagger-tool-mediator-secret-echo-invalid-http-header - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-16T01:25:30.037Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "<project> pm.ts trace_duck_write fetched the DuckBrain API key via LLM-mediated tool(terminal, cat tokenfile). The mediator paraphrased the reply (prose + control chars) so Go http rejected the X-Api-Key header: POST /api/memories -> 502 net/http: invalid header field value for X-Api-Key; 3 attempts, verify chain dependency-skipped. FIX (landed, scheduler repo 36c257b): thread secrets as DAGGER_ENV K=V pairs from the invoking shell and read env(\"DUCKBRAIN_KEY\") in the spec (pm.ts already prefers env over tool()); never fetch secrets through the tool() LLM mediator \u2014 trim/unwrap cannot strip prose containing control bytes. Verified live: clean-token GET probe 200.", "environment": "linux", "language": "go+typescript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "dagger-tool-mediator-secret-echo-invalid-http-header", "provider": "openrouter", "solved_at": "2026-09-16T01:25:30.037Z", "version": ""}