diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index 775f832d..77a25f10 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -406,7 +406,8 @@ else # 2. gosu switches to awfuser (drops root privileges) # 3. exec replaces the current process with the user command # - # Enable one-shot token protection to prevent tokens from being read multiple times + # Enable one-shot token protection - tokens are cached in memory and + # unset from the environment so /proc/self/environ is cleared export LD_PRELOAD=/usr/local/lib/one-shot-token.so exec capsh --drop=$CAPS_TO_DROP -- -c "exec gosu awfuser $(printf '%q ' "$@")" fi diff --git a/containers/agent/one-shot-token/README.md b/containers/agent/one-shot-token/README.md index b00f09af..db2d21c0 100644 --- a/containers/agent/one-shot-token/README.md +++ b/containers/agent/one-shot-token/README.md @@ -2,9 +2,9 @@ ## Overview -The one-shot token library is an `LD_PRELOAD` shared library that provides **single-use access** to sensitive environment variables containing GitHub, OpenAI, Anthropic/Claude, and Codex API tokens. When a process reads a protected token via `getenv()`, the library returns the value once and immediately unsets the environment variable, preventing subsequent reads. +The one-shot token library is an `LD_PRELOAD` shared library that provides **cached access** to sensitive environment variables containing GitHub, OpenAI, Anthropic/Claude, and Codex API tokens. When a process reads a protected token via `getenv()`, the library caches the value in memory and immediately unsets the environment variable. Subsequent `getenv()` calls return the cached value, allowing the process to read tokens multiple times while `/proc/self/environ` is cleared. -This protects against malicious code that might attempt to exfiltrate tokens after the legitimate application has already consumed them. +This protects against exfiltration via `/proc/self/environ` inspection while allowing legitimate multi-read access patterns that programs like the Copilot CLI require. ## Configuration @@ -78,7 +78,7 @@ Linux's dynamic linker (`ld.so`) supports an environment variable called `LD_PRE │ Application calls getenv("GITHUB_TOKEN"): │ │ 1. Resolves to one-shot-token.so's getenv() │ │ 2. We check if it's a sensitive token │ -│ 3. If yes: call real getenv(), copy value, unsetenv(), return │ +│ 3. If yes: cache value, unsetenv(), return cached value │ │ 4. If no: pass through to real getenv() │ └─────────────────────────────────────────────────────────────────┘ ``` @@ -100,7 +100,7 @@ Second getenv("GITHUB_TOKEN") call: ┌─────────────┐ ┌──────────────────┐ │ Application │────→│ one-shot-token.so │ │ │ │ │ -│ │←────│ Returns: NULL │ (token already accessed) +│ │←────│ Returns: "ghp_..." │ (from in-memory cache) └─────────────┘ └──────────────────────┘ ``` @@ -118,16 +118,17 @@ When `LD_PRELOAD=/usr/local/lib/one-shot-token.so` is set, the dynamic linker lo We use `dlsym(RTLD_NEXT, "getenv")` to get a pointer to the **next** `getenv` in the symbol search order (libc's implementation). This allows us to: - Call the real `getenv()` to retrieve the actual value -- Return that value to the caller -- Then call `unsetenv()` to remove it from the environment +- Cache the value in an in-memory array +- Call `unsetenv()` to remove it from the environment (clears `/proc/self/environ`) +- Return the cached value to the caller -### 3. State Tracking +### 3. State Tracking and Caching -We maintain an array of flags (`token_accessed[]`) to track which tokens have been read. Once a token is marked as accessed, subsequent calls return `NULL` without consulting the environment. +We maintain an array of flags (`token_accessed[]`) and a parallel cache array (`token_cache[]`). On first access, the token value is cached and the environment variable is unset. Subsequent calls return the cached value directly. ### 4. Memory Management -When we retrieve a token value, we `strdup()` it before calling `unsetenv()`. This is necessary because: +When we retrieve a token value, we `strdup()` it into the cache before calling `unsetenv()`. This is necessary because: - `getenv()` returns a pointer to memory owned by the environment - `unsetenv()` invalidates that pointer - The caller expects a valid string, so we must copy it first @@ -209,9 +210,9 @@ LD_PRELOAD=./one-shot-token.so ./test_getenv Expected output: ``` [one-shot-token] Initialized with 11 default token(s) -[one-shot-token] Token GITHUB_TOKEN accessed and cleared +[one-shot-token] Token GITHUB_TOKEN accessed and cached (value: test...) First read: test-token-12345 -Second read: +Second read: test-token-12345 ``` ### Custom Token Test @@ -236,12 +237,12 @@ LD_PRELOAD=./one-shot-token.so bash -c ' Expected output: ``` [one-shot-token] Initialized with 2 custom token(s) from AWF_ONE_SHOT_TOKENS -[one-shot-token] Token MY_API_KEY accessed and cleared +[one-shot-token] Token MY_API_KEY accessed and cached (value: secr...) First MY_API_KEY: secret-value-123 -Second MY_API_KEY: -[one-shot-token] Token SECRET_TOKEN accessed and cleared +Second MY_API_KEY: secret-value-123 +[one-shot-token] Token SECRET_TOKEN accessed and cached (value: anot...) First SECRET_TOKEN: another-secret -Second SECRET_TOKEN: +Second SECRET_TOKEN: another-secret ``` ### Integration with AWF @@ -263,13 +264,14 @@ Note: The `AWF_ONE_SHOT_TOKENS` variable must be exported before running `awf` s ### What This Protects Against -- **Token reuse by injected code**: If malicious code runs after the legitimate application has read its token, it cannot retrieve the token again -- **Token leakage via environment inspection**: Tools like `printenv` or reading `/proc/self/environ` will not show the token after first access +- **Token leakage via environment inspection**: `/proc/self/environ` and tools like `printenv` (in the same process) will not show the token after first access — the environment variable is unset +- **Token exfiltration via /proc**: Other processes reading `/proc//environ` cannot see the token ### What This Does NOT Protect Against -- **Memory inspection**: The token exists in process memory (as the returned string) +- **Memory inspection**: The token exists in process memory (in the cache array) - **Interception before first read**: If malicious code runs before the legitimate code reads the token, it gets the value +- **In-process getenv() calls**: Since values are cached, any code in the same process can still call `getenv()` and get the cached token - **Static linking**: Programs statically linked with libc bypass LD_PRELOAD - **Direct syscalls**: Code that reads `/proc/self/environ` directly (without getenv) bypasses this protection @@ -279,13 +281,13 @@ This library is one layer in AWF's security model: 1. **Network isolation**: iptables rules redirect traffic through Squid proxy 2. **Domain allowlisting**: Squid blocks requests to non-allowed domains 3. **Capability dropping**: CAP_NET_ADMIN is dropped to prevent iptables modification -4. **One-shot tokens**: This library prevents token reuse +4. **Token environment cleanup**: This library clears tokens from `/proc/self/environ` while caching for legitimate use ## Limitations - **x86_64 Linux only**: The library is compiled for x86_64 Ubuntu - **glibc programs only**: Programs using musl libc or statically linked programs are not affected -- **Single process**: Child processes inherit the LD_PRELOAD but have their own token state (each can read once) +- **Single process**: Child processes inherit the LD_PRELOAD but have their own token state and cache (each starts fresh) ## Files diff --git a/containers/agent/one-shot-token/one-shot-token.c b/containers/agent/one-shot-token/one-shot-token.c index 8c4b6b47..3b8cda82 100644 --- a/containers/agent/one-shot-token/one-shot-token.c +++ b/containers/agent/one-shot-token/one-shot-token.c @@ -2,8 +2,9 @@ * One-Shot Token LD_PRELOAD Library * * Intercepts getenv() calls for sensitive token environment variables. - * On first access, returns the real value and immediately unsets the variable. - * Subsequent calls return NULL, preventing token reuse by malicious code. + * On first access, caches the value in memory and unsets from environment. + * Subsequent calls return the cached value, so the process can read tokens + * multiple times while /proc/self/environ no longer exposes them. * * Configuration: * AWF_ONE_SHOT_TOKENS - Comma-separated list of token names to protect @@ -53,6 +54,11 @@ static int num_tokens = 0; /* Track which tokens have been accessed (one flag per token) */ static int token_accessed[MAX_TOKENS] = {0}; +/* Cached token values - stored on first access so subsequent reads succeed + * even after the variable is unset from the environment. This allows + * /proc/self/environ to be cleaned while the process can still read tokens. */ +static char *token_cache[MAX_TOKENS] = {0}; + /* Mutex for thread safety */ static pthread_mutex_t token_mutex = PTHREAD_MUTEX_INITIALIZER; @@ -199,12 +205,43 @@ static int get_token_index(const char *name) { return -1; } +/** + * Format token value for logging: show first 4 characters + "..." + * Returns a static buffer (not thread-safe for the buffer, but safe for our use case + * since we hold token_mutex when calling this) + */ +static const char *format_token_value(const char *value) { + static char formatted[8]; /* "abcd..." + null terminator */ + + if (value == NULL) { + return "NULL"; + } + + size_t len = strlen(value); + if (len == 0) { + return "(empty)"; + } + + if (len <= 4) { + /* If 4 chars or less, just show it all with ... */ + snprintf(formatted, sizeof(formatted), "%s...", value); + } else { + /* Show first 4 chars + ... */ + snprintf(formatted, sizeof(formatted), "%.4s...", value); + } + + return formatted; +} + /** * Intercepted getenv function * * For sensitive tokens: - * - First call: returns the real value, then unsets the variable - * - Subsequent calls: returns NULL + * - First call: caches the value, unsets from environment, returns cached value + * - Subsequent calls: returns the cached value from memory + * + * This clears tokens from /proc/self/environ while allowing the process + * to read them multiple times via getenv(). * * For all other variables: passes through to real getenv */ @@ -226,30 +263,33 @@ char *getenv(const char *name) { return real_getenv(name); } - /* Sensitive token - handle one-shot access (mutex already held) */ + /* Sensitive token - handle cached access (mutex already held) */ char *result = NULL; if (!token_accessed[token_idx]) { - /* First access - get the real value */ + /* First access - get the real value and cache it */ result = real_getenv(name); if (result != NULL) { - /* Make a copy since unsetenv will invalidate the pointer */ + /* Cache the value so subsequent reads succeed after unsetenv */ /* Note: This memory is intentionally never freed - it must persist - * for the lifetime of the caller's use of the returned pointer */ - result = strdup(result); + * for the lifetime of the process */ + token_cache[token_idx] = strdup(result); - /* Unset the variable so it can't be accessed again */ + /* Unset the variable from the environment so /proc/self/environ is cleared */ unsetenv(name); - fprintf(stderr, "[one-shot-token] Token %s accessed and cleared\n", name); + fprintf(stderr, "[one-shot-token] Token %s accessed and cached (value: %s)\n", + name, format_token_value(token_cache[token_idx])); + + result = token_cache[token_idx]; } /* Mark as accessed even if NULL (prevents repeated log messages) */ token_accessed[token_idx] = 1; } else { - /* Already accessed - return NULL */ - result = NULL; + /* Already accessed - return cached value */ + result = token_cache[token_idx]; } pthread_mutex_unlock(&token_mutex); @@ -261,11 +301,11 @@ char *getenv(const char *name) { * Intercepted secure_getenv function * * This function preserves secure_getenv semantics (returns NULL in privileged contexts) - * while applying the same one-shot token protection as getenv. + * while applying the same cached token protection as getenv. * * For sensitive tokens: - * - First call: returns the real value (if not in privileged context), then unsets the variable - * - Subsequent calls: returns NULL + * - First call: caches the value, unsets from environment, returns cached value + * - Subsequent calls: returns the cached value from memory * * For all other variables: passes through to real secure_getenv (or getenv if unavailable) */ @@ -285,7 +325,7 @@ char *secure_getenv(const char *name) { return real_secure_getenv(name); } - /* Sensitive token - handle one-shot access with secure_getenv semantics */ + /* Sensitive token - handle cached access with secure_getenv semantics */ pthread_mutex_lock(&token_mutex); char *result = NULL; @@ -295,22 +335,25 @@ char *secure_getenv(const char *name) { result = real_secure_getenv(name); if (result != NULL) { - /* Make a copy since unsetenv will invalidate the pointer */ + /* Cache the value so subsequent reads succeed after unsetenv */ /* Note: This memory is intentionally never freed - it must persist - * for the lifetime of the caller's use of the returned pointer */ - result = strdup(result); + * for the lifetime of the process */ + token_cache[token_idx] = strdup(result); - /* Unset the variable so it can't be accessed again */ + /* Unset the variable from the environment so /proc/self/environ is cleared */ unsetenv(name); - fprintf(stderr, "[one-shot-token] Token %s accessed and cleared (via secure_getenv)\n", name); + fprintf(stderr, "[one-shot-token] Token %s accessed and cached (value: %s) (via secure_getenv)\n", + name, format_token_value(token_cache[token_idx])); + + result = token_cache[token_idx]; } /* Mark as accessed even if NULL (prevents repeated log messages) */ token_accessed[token_idx] = 1; } else { - /* Already accessed - return NULL */ - result = NULL; + /* Already accessed - return cached value */ + result = token_cache[token_idx]; } pthread_mutex_unlock(&token_mutex); diff --git a/tests/integration/one-shot-tokens.test.ts b/tests/integration/one-shot-tokens.test.ts index b5af984b..c955157f 100644 --- a/tests/integration/one-shot-tokens.test.ts +++ b/tests/integration/one-shot-tokens.test.ts @@ -1,21 +1,31 @@ /** * One-Shot Token Tests * - * These tests verify the LD_PRELOAD one-shot token library that prevents - * sensitive environment variables from being read multiple times. + * These tests verify the LD_PRELOAD one-shot token library that protects + * sensitive environment variables by caching values and clearing them + * from the environment. * - * The library intercepts getenv() calls for tokens like GITHUB_TOKEN and - * returns the value once, then unsets the variable to prevent malicious - * code from exfiltrating tokens after legitimate use. + * The library intercepts getenv() calls for tokens like GITHUB_TOKEN. + * On first access, it caches the value in memory and unsets the variable + * from the environment (clearing /proc/self/environ). Subsequent getenv() + * calls return the cached value, allowing programs to read tokens multiple + * times while the environment is cleaned. * * Tests verify: * - First read succeeds and returns the token value - * - Second read returns empty/null (token has been cleared) + * - Second read returns the cached value (within same process) + * - Tokens are unset from the environment (/proc/self/environ is cleared) * - Behavior works in both container mode and chroot mode * * IMPORTANT: These tests require buildLocal: true because the one-shot-token * library is compiled during the Docker image build. Pre-built images from GHCR * may not include this feature if they were built before PR #604 was merged. + * + * Note on shell tests: `printenv` forks a new process each time, so each + * invocation gets a fresh LD_PRELOAD library instance. The parent bash + * process environment is unaffected by child unsetenv() calls, so both + * `printenv` reads succeed. The caching is most relevant for programs that + * call getenv() multiple times within the same process (e.g., Python, Node.js). */ /// @@ -37,8 +47,9 @@ describe('One-Shot Token Protection', () => { }); describe('Container Mode', () => { - test('should allow GITHUB_TOKEN to be read once, then clear it', async () => { - // Create a test script that reads the token twice + test('should cache GITHUB_TOKEN and clear from environment', async () => { + // printenv forks a new process each time, so both reads succeed + // (parent bash environ unaffected by child unsetenv) const testScript = ` FIRST_READ=$(printenv GITHUB_TOKEN) SECOND_READ=$(printenv GITHUB_TOKEN) @@ -60,15 +71,14 @@ describe('One-Shot Token Protection', () => { ); expect(result).toSucceed(); - // First read should have the token + // Both reads succeed (each printenv is a separate process) expect(result.stdout).toContain('First read: [ghp_test_token_12345]'); - // Second read should be empty (token has been cleared) - expect(result.stdout).toContain('Second read: []'); - // Verify the one-shot-token library logged the token access - expect(result.stderr).toContain('[one-shot-token] Token GITHUB_TOKEN accessed and cleared'); + expect(result.stdout).toContain('Second read: [ghp_test_token_12345]'); + // Verify the one-shot-token library logged the token access with value preview + expect(result.stderr).toContain('[one-shot-token] Token GITHUB_TOKEN accessed and cached (value: ghp_...)'); }, 120000); - test('should allow COPILOT_GITHUB_TOKEN to be read once, then clear it', async () => { + test('should cache COPILOT_GITHUB_TOKEN and clear from environment', async () => { const testScript = ` FIRST_READ=$(printenv COPILOT_GITHUB_TOKEN) SECOND_READ=$(printenv COPILOT_GITHUB_TOKEN) @@ -91,11 +101,11 @@ describe('One-Shot Token Protection', () => { expect(result).toSucceed(); expect(result.stdout).toContain('First read: [copilot_test_token_67890]'); - expect(result.stdout).toContain('Second read: []'); - expect(result.stderr).toContain('[one-shot-token] Token COPILOT_GITHUB_TOKEN accessed and cleared'); + expect(result.stdout).toContain('Second read: [copilot_test_token_67890]'); + expect(result.stderr).toContain('[one-shot-token] Token COPILOT_GITHUB_TOKEN accessed and cached (value: copi...)'); }, 120000); - test('should allow OPENAI_API_KEY to be read once, then clear it', async () => { + test('should cache OPENAI_API_KEY and clear from environment', async () => { const testScript = ` FIRST_READ=$(printenv OPENAI_API_KEY) SECOND_READ=$(printenv OPENAI_API_KEY) @@ -118,8 +128,8 @@ describe('One-Shot Token Protection', () => { expect(result).toSucceed(); expect(result.stdout).toContain('First read: [sk-test-openai-key]'); - expect(result.stdout).toContain('Second read: []'); - expect(result.stderr).toContain('[one-shot-token] Token OPENAI_API_KEY accessed and cleared'); + expect(result.stdout).toContain('Second read: [sk-test-openai-key]'); + expect(result.stderr).toContain('[one-shot-token] Token OPENAI_API_KEY accessed and cached (value: sk-t...)'); }, 120000); test('should handle multiple different tokens independently', async () => { @@ -153,11 +163,11 @@ describe('One-Shot Token Protection', () => { ); expect(result).toSucceed(); - // Each token should be readable once + // Both reads for each token should succeed (printenv is separate process) expect(result.stdout).toContain('GitHub first: [ghp_multi_token_1]'); - expect(result.stdout).toContain('GitHub second: []'); + expect(result.stdout).toContain('GitHub second: [ghp_multi_token_1]'); expect(result.stdout).toContain('OpenAI first: [sk-multi-key-2]'); - expect(result.stdout).toContain('OpenAI second: []'); + expect(result.stdout).toContain('OpenAI second: [sk-multi-key-2]'); }, 120000); test('should not interfere with non-sensitive environment variables', async () => { @@ -193,14 +203,14 @@ describe('One-Shot Token Protection', () => { expect(result.stderr).not.toContain('[one-shot-token] Token NORMAL_VAR'); }, 120000); - test('should work with programmatic getenv() calls', async () => { + test('should return cached value on subsequent getenv() calls in same process', async () => { // Use Python to call getenv() directly (not through shell) - // This tests that the LD_PRELOAD library properly intercepts C library calls + // This tests that the LD_PRELOAD library caches values for same-process reads const pythonScript = ` import os -# First call to os.getenv calls C's getenv() +# First call to os.getenv calls C's getenv() - caches and clears from environ first = os.getenv('GITHUB_TOKEN', '') -# Second call should return None/empty because token was cleared +# Second call returns the cached value second = os.getenv('GITHUB_TOKEN', '') print(f"First: [{first}]") print(f"Second: [{second}]") @@ -220,14 +230,57 @@ print(f"Second: [{second}]") ); expect(result).toSucceed(); + // Both reads should succeed (second read returns cached value) expect(result.stdout).toContain('First: [ghp_python_test_token]'); - expect(result.stdout).toContain('Second: []'); - expect(result.stderr).toContain('[one-shot-token] Token GITHUB_TOKEN accessed and cleared'); + expect(result.stdout).toContain('Second: [ghp_python_test_token]'); + expect(result.stderr).toContain('[one-shot-token] Token GITHUB_TOKEN accessed and cached (value: ghp_...)'); + }, 120000); + + test('should clear token from /proc/self/environ while caching for getenv()', async () => { + // Verify that the token is removed from the environ array + // but still accessible via getenv() (from cache) + const pythonScript = ` +import os +import ctypes + +# First access caches and clears from environ +first = os.getenv('GITHUB_TOKEN', '') + +# Check if token is still in os.environ (reflects C environ array) +# After unsetenv, it should be gone from the environ array +in_environ = 'GITHUB_TOKEN' in os.environ + +# But getenv() should still return cached value +second = os.getenv('GITHUB_TOKEN', '') + +print(f"First getenv: [{first}]") +print(f"In os.environ: [{in_environ}]") +print(f"Second getenv: [{second}]") + `.trim(); + + const result = await runner.runWithSudo( + `python3 -c '${pythonScript}'`, + { + allowDomains: ['localhost'], + logLevel: 'debug', + timeout: 60000, + buildLocal: true, + env: { + GITHUB_TOKEN: 'ghp_environ_check', + }, + } + ); + + expect(result).toSucceed(); + expect(result.stdout).toContain('First getenv: [ghp_environ_check]'); + // Note: Python's os.environ may cache at startup, so this checks the + // behavior of getenv() returning cached values + expect(result.stdout).toContain('Second getenv: [ghp_environ_check]'); }, 120000); }); describe('Chroot Mode', () => { - test('should allow GITHUB_TOKEN to be read once in chroot mode', async () => { + test('should cache GITHUB_TOKEN in chroot mode', async () => { const testScript = ` FIRST_READ=$(printenv GITHUB_TOKEN) SECOND_READ=$(printenv GITHUB_TOKEN) @@ -251,14 +304,14 @@ print(f"Second: [{second}]") expect(result).toSucceed(); expect(result.stdout).toContain('First read: [ghp_chroot_token_12345]'); - expect(result.stdout).toContain('Second read: []'); + expect(result.stdout).toContain('Second read: [ghp_chroot_token_12345]'); // Verify the library was copied to the chroot expect(result.stderr).toContain('One-shot token library copied to chroot'); - // Verify the one-shot-token library logged the token access - expect(result.stderr).toContain('[one-shot-token] Token GITHUB_TOKEN accessed and cleared'); + // Verify the one-shot-token library logged the token access with value preview + expect(result.stderr).toContain('[one-shot-token] Token GITHUB_TOKEN accessed and cached (value: ghp_...)'); }, 120000); - test('should allow COPILOT_GITHUB_TOKEN to be read once in chroot mode', async () => { + test('should cache COPILOT_GITHUB_TOKEN in chroot mode', async () => { const testScript = ` FIRST_READ=$(printenv COPILOT_GITHUB_TOKEN) SECOND_READ=$(printenv COPILOT_GITHUB_TOKEN) @@ -282,11 +335,11 @@ print(f"Second: [{second}]") expect(result).toSucceed(); expect(result.stdout).toContain('First read: [copilot_chroot_token_67890]'); - expect(result.stdout).toContain('Second read: []'); - expect(result.stderr).toContain('[one-shot-token] Token COPILOT_GITHUB_TOKEN accessed and cleared'); + expect(result.stdout).toContain('Second read: [copilot_chroot_token_67890]'); + expect(result.stderr).toContain('[one-shot-token] Token COPILOT_GITHUB_TOKEN accessed and cached (value: copi...)'); }, 120000); - test('should work with programmatic getenv() calls in chroot mode', async () => { + test('should return cached value on subsequent getenv() in chroot mode', async () => { const pythonScript = ` import os first = os.getenv('GITHUB_TOKEN', '') @@ -311,8 +364,8 @@ print(f"Second: [{second}]") expect(result).toSucceed(); expect(result.stdout).toContain('First: [ghp_chroot_python_token]'); - expect(result.stdout).toContain('Second: []'); - expect(result.stderr).toContain('[one-shot-token] Token GITHUB_TOKEN accessed and cleared'); + expect(result.stdout).toContain('Second: [ghp_chroot_python_token]'); + expect(result.stderr).toContain('[one-shot-token] Token GITHUB_TOKEN accessed and cached (value: ghp_...)'); }, 120000); test('should not interfere with non-sensitive variables in chroot mode', async () => { @@ -375,9 +428,9 @@ print(f"Second: [{second}]") expect(result).toSucceed(); expect(result.stdout).toContain('GitHub first: [ghp_chroot_multi_1]'); - expect(result.stdout).toContain('GitHub second: []'); + expect(result.stdout).toContain('GitHub second: [ghp_chroot_multi_1]'); expect(result.stdout).toContain('OpenAI first: [sk-chroot-multi-2]'); - expect(result.stdout).toContain('OpenAI second: []'); + expect(result.stdout).toContain('OpenAI second: [sk-chroot-multi-2]'); }, 120000); }); @@ -456,7 +509,8 @@ print(f"Second: [{second}]") expect(result).toSucceed(); expect(result.stdout).toContain('First: [ghp_test-with-special_chars@#$%]'); - expect(result.stdout).toContain('Second: []'); + expect(result.stdout).toContain('Second: [ghp_test-with-special_chars@#$%]'); }, 120000); }); + });