Skip to content

Commit e095490

Browse files
CopilotlpcoxCopilot
authored
feat: auto-forward OTEL_* env vars with one-shot token protection for headers (#3180)
* Initial plan * feat: add OTEL env passthrough and one-shot token protection * feat: add OTEL env passthrough and one-shot token protection * style: fix markdown table separators in OTEL docs section * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * ci: recompile smoke-codex workflow --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Landon Cox <landon.cox@microsoft.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent a57ce18 commit e095490

4 files changed

Lines changed: 169 additions & 2 deletions

File tree

.github/workflows/smoke-codex.lock.yml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/environment.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,57 @@ When enabled, the library logs:
192192

193193
**Note:** Debug output goes to stderr and does not interfere with command stdout. See `containers/agent/one-shot-token/README.md` for complete documentation.
194194

195+
## OpenTelemetry (OTEL) Environment Variables
196+
197+
AWF automatically forwards all `OTEL_*` environment variables and `COPILOT_OTEL_FILE_EXPORTER_PATH` into the agent container — no `--env-all` or explicit `--env` flags are required. This covers the full set of [OpenTelemetry SDK environment variables](https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/).
198+
199+
### Automatic forwarding
200+
201+
Any variable present in the host environment with the `OTEL_` prefix is passed through:
202+
203+
```bash
204+
export OTEL_SERVICE_NAME=my-agent
205+
export OTEL_EXPORTER_OTLP_ENDPOINT=https://otel.example.com
206+
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer $MY_OTEL_TOKEN"
207+
sudo -E awf --allow-domains otel.example.com -- agent-command
208+
```
209+
210+
### Security: one-shot token protection for OTEL credentials
211+
212+
The following OTEL variables often carry bearer tokens or other credentials and are included in the one-shot token protection list (`AWF_ONE_SHOT_TOKENS`). They are cached on first access and removed from `/proc/self/environ`, preventing exfiltration by compromised subprocesses:
213+
214+
| Variable | Content |
215+
|----------|---------|
216+
| `OTEL_EXPORTER_OTLP_HEADERS` | Global auth headers (e.g. `Authorization=Bearer <token>`) |
217+
| `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | Per-signal auth headers |
218+
| `OTEL_EXPORTER_OTLP_METRICS_HEADERS` | Per-signal auth headers |
219+
| `OTEL_EXPORTER_OTLP_LOGS_HEADERS` | Per-signal auth headers |
220+
221+
### Network requirements
222+
223+
- **OTLP/HTTP (`http/protobuf`, default):** Traffic goes through the Squid proxy on ports 80/443. Add the OTLP collector domain to `--allow-domains`:
224+
225+
```bash
226+
awf --allow-domains otel.example.com -- agent-command
227+
```
228+
229+
- **OTLP/gRPC (port 4317):** gRPC clients typically do not respect `HTTP_PROXY` env vars, and port 4317 is not covered by AWF's iptables DNAT rules (only 80/443). Traffic to port 4317 hits the default DROP rule and is blocked. Use `http/protobuf` protocol instead:
230+
231+
```bash
232+
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
233+
```
234+
235+
- **File-based export (`COPILOT_OTEL_FILE_EXPORTER_PATH`):** Writes spans to a local file — no network access needed. AWF forwards this variable automatically. gh-aw uploads the file as an Actions artifact.
236+
237+
### Key OTEL variables by category
238+
239+
| Category | Variables |
240+
|----------|-----------|
241+
| **Sensitive (one-shot protected)** | `OTEL_EXPORTER_OTLP_HEADERS`, `OTEL_EXPORTER_OTLP_TRACES_HEADERS`, `OTEL_EXPORTER_OTLP_METRICS_HEADERS`, `OTEL_EXPORTER_OTLP_LOGS_HEADERS` |
242+
| **Network-affecting** | `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`, `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT`, `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT`, `OTEL_EXPORTER_OTLP_PROTOCOL` |
243+
| **Safe / local config** | `OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES`, `OTEL_SDK_DISABLED`, `OTEL_LOG_LEVEL`, `OTEL_PROPAGATORS`, `OTEL_TRACES_SAMPLER`, `OTEL_TRACES_EXPORTER`, `OTEL_METRICS_EXPORTER`, `OTEL_LOGS_EXPORTER`, `OTEL_EXPORTER_OTLP_TIMEOUT`, `OTEL_EXPORTER_OTLP_COMPRESSION` |
244+
| **Copilot-specific** | `COPILOT_OTEL_FILE_EXPORTER_PATH` |
245+
195246
## Workflow-Scope Docker-in-Docker (`DOCKER_HOST`)
196247

197248
When a GitHub Actions workflow enables Docker-in-Docker (DinD) at the **workflow scope** — for example by starting a `docker:dind` service container and setting `DOCKER_HOST: tcp://localhost:2375` in the runner's environment — AWF handles the conflict automatically.

src/services/agent-environment-credentials.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,4 +304,98 @@ describe('agent environment: credentials', () => {
304304
else delete process.env.GITHUB_TOKEN;
305305
}
306306
});
307+
308+
describe('OTEL environment variable forwarding', () => {
309+
const otelVars: Record<string, string> = {
310+
OTEL_SERVICE_NAME: 'my-service',
311+
OTEL_EXPORTER_OTLP_ENDPOINT: 'https://otel.example.com:4318',
312+
OTEL_EXPORTER_OTLP_HEADERS: 'Authorization=Bearer secret-token',
313+
OTEL_EXPORTER_OTLP_TRACES_HEADERS: 'Authorization=Bearer traces-token',
314+
OTEL_EXPORTER_OTLP_METRICS_HEADERS: 'Authorization=Bearer metrics-token',
315+
OTEL_EXPORTER_OTLP_LOGS_HEADERS: 'Authorization=Bearer logs-token',
316+
OTEL_RESOURCE_ATTRIBUTES: 'host.name=runner01',
317+
OTEL_SDK_DISABLED: 'false',
318+
};
319+
320+
let origVals: Record<string, string | undefined>;
321+
322+
beforeEach(() => {
323+
origVals = {};
324+
for (const key of Object.keys(otelVars)) {
325+
origVals[key] = process.env[key];
326+
process.env[key] = otelVars[key];
327+
}
328+
});
329+
330+
afterEach(() => {
331+
for (const key of Object.keys(otelVars)) {
332+
if (origVals[key] !== undefined) process.env[key] = origVals[key];
333+
else delete process.env[key];
334+
}
335+
});
336+
337+
it('should auto-forward OTEL_* variables in default (non-env-all) mode', () => {
338+
const result = generateDockerCompose(mockConfig, mockNetworkConfig);
339+
const env = result.services.agent.environment as Record<string, string>;
340+
341+
expect(env.OTEL_SERVICE_NAME).toBe('my-service');
342+
expect(env.OTEL_EXPORTER_OTLP_ENDPOINT).toBe('https://otel.example.com:4318');
343+
expect(env.OTEL_EXPORTER_OTLP_HEADERS).toBe('Authorization=Bearer secret-token');
344+
expect(env.OTEL_EXPORTER_OTLP_TRACES_HEADERS).toBe('Authorization=Bearer traces-token');
345+
expect(env.OTEL_EXPORTER_OTLP_METRICS_HEADERS).toBe('Authorization=Bearer metrics-token');
346+
expect(env.OTEL_EXPORTER_OTLP_LOGS_HEADERS).toBe('Authorization=Bearer logs-token');
347+
expect(env.OTEL_RESOURCE_ATTRIBUTES).toBe('host.name=runner01');
348+
expect(env.OTEL_SDK_DISABLED).toBe('false');
349+
});
350+
351+
it('should not forward OTEL_* variables when not set in host environment', () => {
352+
for (const key of Object.keys(otelVars)) {
353+
delete process.env[key];
354+
}
355+
const result = generateDockerCompose(mockConfig, mockNetworkConfig);
356+
const env = result.services.agent.environment as Record<string, string>;
357+
358+
for (const key of Object.keys(otelVars)) {
359+
expect(env[key]).toBeUndefined();
360+
}
361+
});
362+
363+
it('should include OTEL header vars in AWF_ONE_SHOT_TOKENS', () => {
364+
const result = generateDockerCompose(mockConfig, mockNetworkConfig);
365+
const env = result.services.agent.environment as Record<string, string>;
366+
const oneShot = env.AWF_ONE_SHOT_TOKENS ?? '';
367+
368+
expect(oneShot).toContain('OTEL_EXPORTER_OTLP_HEADERS');
369+
expect(oneShot).toContain('OTEL_EXPORTER_OTLP_TRACES_HEADERS');
370+
expect(oneShot).toContain('OTEL_EXPORTER_OTLP_METRICS_HEADERS');
371+
expect(oneShot).toContain('OTEL_EXPORTER_OTLP_LOGS_HEADERS');
372+
});
373+
});
374+
375+
describe('COPILOT_OTEL_FILE_EXPORTER_PATH forwarding', () => {
376+
it('should forward COPILOT_OTEL_FILE_EXPORTER_PATH when set', () => {
377+
const original = process.env.COPILOT_OTEL_FILE_EXPORTER_PATH;
378+
process.env.COPILOT_OTEL_FILE_EXPORTER_PATH = '/tmp/otel-spans.json';
379+
try {
380+
const result = generateDockerCompose(mockConfig, mockNetworkConfig);
381+
const env = result.services.agent.environment as Record<string, string>;
382+
expect(env.COPILOT_OTEL_FILE_EXPORTER_PATH).toBe('/tmp/otel-spans.json');
383+
} finally {
384+
if (original !== undefined) process.env.COPILOT_OTEL_FILE_EXPORTER_PATH = original;
385+
else delete process.env.COPILOT_OTEL_FILE_EXPORTER_PATH;
386+
}
387+
});
388+
389+
it('should not set COPILOT_OTEL_FILE_EXPORTER_PATH when not in host environment', () => {
390+
const original = process.env.COPILOT_OTEL_FILE_EXPORTER_PATH;
391+
delete process.env.COPILOT_OTEL_FILE_EXPORTER_PATH;
392+
try {
393+
const result = generateDockerCompose(mockConfig, mockNetworkConfig);
394+
const env = result.services.agent.environment as Record<string, string>;
395+
expect(env.COPILOT_OTEL_FILE_EXPORTER_PATH).toBeUndefined();
396+
} finally {
397+
if (original !== undefined) process.env.COPILOT_OTEL_FILE_EXPORTER_PATH = original;
398+
}
399+
});
400+
});
307401
});

src/services/agent-environment.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ export function buildAgentEnvironment(params: AgentEnvironmentParams): Record<st
130130
}),
131131
// Configure one-shot-token library with sensitive tokens to protect
132132
// These tokens are cached on first access and unset from /proc/self/environ
133-
AWF_ONE_SHOT_TOKENS: 'COPILOT_GITHUB_TOKEN,GITHUB_TOKEN,GH_TOKEN,GITHUB_API_TOKEN,GITHUB_PAT,GH_ACCESS_TOKEN,OPENAI_API_KEY,OPENAI_KEY,ANTHROPIC_API_KEY,CLAUDE_API_KEY,CODEX_API_KEY,COPILOT_API_KEY,COPILOT_PROVIDER_API_KEY',
133+
AWF_ONE_SHOT_TOKENS: 'COPILOT_GITHUB_TOKEN,GITHUB_TOKEN,GH_TOKEN,GITHUB_API_TOKEN,GITHUB_PAT,GH_ACCESS_TOKEN,OPENAI_API_KEY,OPENAI_KEY,ANTHROPIC_API_KEY,CLAUDE_API_KEY,CODEX_API_KEY,COPILOT_API_KEY,COPILOT_PROVIDER_API_KEY,OTEL_EXPORTER_OTLP_HEADERS,OTEL_EXPORTER_OTLP_TRACES_HEADERS,OTEL_EXPORTER_OTLP_METRICS_HEADERS,OTEL_EXPORTER_OTLP_LOGS_HEADERS',
134134
};
135135

136136
// Copilot CLI requires Node.js. Ask the agent entrypoint to fail fast with a
@@ -284,11 +284,33 @@ export function buildAgentEnvironment(params: AgentEnvironmentParams): Record<st
284284
'DOCKER_CONFIG',
285285
'DOCKER_API_VERSION',
286286
'DOCKER_DEFAULT_PLATFORM',
287+
// Copilot OTEL file exporter path — written to a local file, no network needed.
288+
// gh-aw uploads the resulting file as an Actions artifact.
289+
'COPILOT_OTEL_FILE_EXPORTER_PATH',
287290
] as const;
288291
for (const v of alwaysForwardVars) {
289292
if (process.env[v]) environment[v] = process.env[v]!;
290293
}
291294

295+
// Forward all OTEL_* environment variables — standardized prefix per the OpenTelemetry spec.
296+
// This covers the full set of ~50+ OTEL_ variables (safe, network-affecting, and sensitive)
297+
// without requiring users to pass --env-all or list each variable explicitly.
298+
// Sensitive header vars (OTEL_EXPORTER_OTLP_HEADERS and per-signal variants) are also
299+
// included in AWF_ONE_SHOT_TOKENS above, so they are cached on first access and removed
300+
// from /proc/self/environ to prevent exfiltration by compromised subprocesses.
301+
// EXCLUDED_ENV_VARS guards against leaking proxy/Actions/AWF internal vars (e.g., if a
302+
// future OTEL_ variable overlaps); the hasOwnProperty check prevents --env-all or earlier
303+
// alwaysForwardVars entries from being silently overwritten.
304+
// Note: process.env values are typed as string | undefined, so the value check is a
305+
// required TypeScript type guard (Object.entries won't include missing keys at runtime).
306+
for (const [key, value] of Object.entries(process.env)) {
307+
if (key.startsWith('OTEL_') && value !== undefined
308+
&& !EXCLUDED_ENV_VARS.has(key)
309+
&& !Object.prototype.hasOwnProperty.call(environment, key)) {
310+
environment[key] = value;
311+
}
312+
}
313+
292314
// When DinD is exposed via a Unix socket override, keep the agent's default docker
293315
// client target aligned with the socket mounted into /host. This intentionally
294316
// overrides any inherited DOCKER_HOST so --enable-dind + --docker-host cannot leave

0 commit comments

Comments
 (0)