[harness] Preserve harness artifact values and write exit codes#2286
Conversation
c2168c1 to
03e901d
Compare
Greptile SummaryThis PR fixes two concrete gaps left after #2290: over-aggressive redaction of token-count config keys (e.g.
|
| Filename | Overview |
|---|---|
| nemo_retriever/src/nemo_retriever/harness/artifact_writer.py | Refactors redaction logic to use whole-word part matching for "token"/"tokens" and wraps text-write I/O failures as artifact write errors; standalone plural "tokens" key is no longer redacted (regression) |
| nemo_retriever/src/nemo_retriever/harness/beir_runner.py | Wraps _write_trec_run I/O in artifact_write_error and removes redundant query_results.jsonl unlinks that are now handled by ArtifactWriter startup cleanup |
| nemo_retriever/src/nemo_retriever/harness/execution.py | Adds an early re-raise guard for EXIT_ARTIFACT_WRITE_FAILURE inside the ingest exception handler to prevent artifact errors from being misclassified as ingest failures |
| nemo_retriever/tests/test_harness_artifacts.py | Adds tests for the new token-limit preservation, text-artifact error classification, and run-log write failure propagation |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[capture_output_to_log] -->|append_text start header| B{OSError?}
B -->|yes| C[raise artifact_write_error EXIT_ARTIFACT_WRITE_FAILURE]
B -->|no| D[redirect stdout/stderr to buf - yield body]
D -->|body raises| E[record failure_traceback - re-raise]
D -->|body ok| F[restore fds]
E --> F
F --> G{write buf + traceback to run.log}
G -->|OSError| C
G -->|ok| H[write captured to stderr if failed]
subgraph execution.py
I[run_ingest_workflow] -->|exception| J{HarnessRunError EXIT_ARTIFACT_WRITE_FAILURE?}
J -->|yes - NEW| K[re-raise as-is]
J -->|no| L[wrap as EXIT_INGEST_FAILURE]
end
C --> J
subgraph _is_sensitive_key
M[normalize key to snake_case] --> N{marker substring in normalized?}
N -->|yes| O[redact]
N -->|no| P{token whole part?}
P -->|yes| O
P -->|no| Q{tokens with credential qualifier at index-1?}
Q -->|yes| O
Q -->|no| R[preserve value]
end
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A[capture_output_to_log] -->|append_text start header| B{OSError?}
B -->|yes| C[raise artifact_write_error EXIT_ARTIFACT_WRITE_FAILURE]
B -->|no| D[redirect stdout/stderr to buf - yield body]
D -->|body raises| E[record failure_traceback - re-raise]
D -->|body ok| F[restore fds]
E --> F
F --> G{write buf + traceback to run.log}
G -->|OSError| C
G -->|ok| H[write captured to stderr if failed]
subgraph execution.py
I[run_ingest_workflow] -->|exception| J{HarnessRunError EXIT_ARTIFACT_WRITE_FAILURE?}
J -->|yes - NEW| K[re-raise as-is]
J -->|no| L[wrap as EXIT_INGEST_FAILURE]
end
C --> J
subgraph _is_sensitive_key
M[normalize key to snake_case] --> N{marker substring in normalized?}
N -->|yes| O[redact]
N -->|no| P{token whole part?}
P -->|yes| O
P -->|no| Q{tokens with credential qualifier at index-1?}
Q -->|yes| O
Q -->|no| R[preserve value]
end
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 1
nemo_retriever/src/nemo_retriever/harness/artifact_writer.py:139-145
**Bare `tokens` (plural) key no longer redacted — security regression**
The old code checked `"token" in normalized` as a substring, so a key literally named `tokens` (plural, no qualifier) was always redacted. The new code requires either an exact part `"token"` (singular) or `"tokens"` preceded by a credential qualifier at `index - 1`. For a standalone key `tokens`, `parts = ["tokens"]`, `"token" not in parts` is True, and the qualifier check fails because `index 0 > 0` is False — so the key passes through unredacted.
Any config dict with `{"tokens": [...]}` (e.g., an OAuth token list, Slack API response embedded in a config) will now serialize its value in plaintext where it previously would have been `"<redacted>"`. Adding `"tokens"` to `_SENSITIVE_KEY_MARKERS` would restore full coverage while keeping all the new qualifications for count-style keys.
Reviews (2): Last reviewed commit: "Merge branch 'main' into codex/retriever..." | Re-trigger Greptile
| @contextmanager | ||
| def fail_log_capture(*args, **kwargs): | ||
| raise artifact_write_error(OSError("run log unavailable")) | ||
| yield |
There was a problem hiding this comment.
The
yield after raise is unreachable at runtime but is semantically required: Python classifies a function as a generator function only when it contains at least one yield expression, regardless of reachability. Without the yield, @contextmanager would receive a plain function and would fail when __enter__ calls next(). A brief comment prevents future confusion or spurious linter suppression.
| @contextmanager | |
| def fail_log_capture(*args, **kwargs): | |
| raise artifact_write_error(OSError("run log unavailable")) | |
| yield | |
| @contextmanager | |
| def fail_log_capture(*args, **kwargs): | |
| raise artifact_write_error(OSError("run log unavailable")) | |
| yield # unreachable; required to make this a generator function for @contextmanager |
Prompt To Fix With AI
This is a comment left during a code review.
Path: nemo_retriever/tests/test_harness_artifacts.py
Line: 276-279
Comment:
The `yield` after `raise` is unreachable at runtime but is semantically required: Python classifies a function as a generator function only when it contains at least one `yield` expression, regardless of reachability. Without the `yield`, `@contextmanager` would receive a plain function and would fail when `__enter__` calls `next()`. A brief comment prevents future confusion or spurious linter suppression.
```suggestion
@contextmanager
def fail_log_capture(*args, **kwargs):
raise artifact_write_error(OSError("run log unavailable"))
yield # unreachable; required to make this a generator function for @contextmanager
```
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| if "token" in parts: | ||
| return True | ||
| return any( | ||
| part == "tokens" and index > 0 and parts[index - 1] in _TOKEN_CREDENTIAL_QUALIFIERS |
There was a problem hiding this comment.
This only checks the segment immediately before plural tokens, so compound credential keys such as service_account_tokens and oauth_app_tokens remain unredacted; bare tokens does as well. Since these artifacts may be replayed or shared, could we make plural-token handling fail closed while explicitly preserving known budget fields such as max_tokens and overlap_tokens? Please add compound-key and structured-override regressions.
| parts = normalized.split("_") | ||
| if "token" in parts: | ||
| return True | ||
| return any( | ||
| part == "tokens" and index > 0 and parts[index - 1] in _TOKEN_CREDENTIAL_QUALIFIERS | ||
| for index, part in enumerate(parts) | ||
| ) |
There was a problem hiding this comment.
Bare
tokens (plural) key no longer redacted — security regression
The old code checked "token" in normalized as a substring, so a key literally named tokens (plural, no qualifier) was always redacted. The new code requires either an exact part "token" (singular) or "tokens" preceded by a credential qualifier at index - 1. For a standalone key tokens, parts = ["tokens"], "token" not in parts is True, and the qualifier check fails because index 0 > 0 is False — so the key passes through unredacted.
Any config dict with {"tokens": [...]} (e.g., an OAuth token list, Slack API response embedded in a config) will now serialize its value in plaintext where it previously would have been "<redacted>". Adding "tokens" to _SENSITIVE_KEY_MARKERS would restore full coverage while keeping all the new qualifications for count-style keys.
Prompt To Fix With AI
This is a comment left during a code review.
Path: nemo_retriever/src/nemo_retriever/harness/artifact_writer.py
Line: 139-145
Comment:
**Bare `tokens` (plural) key no longer redacted — security regression**
The old code checked `"token" in normalized` as a substring, so a key literally named `tokens` (plural, no qualifier) was always redacted. The new code requires either an exact part `"token"` (singular) or `"tokens"` preceded by a credential qualifier at `index - 1`. For a standalone key `tokens`, `parts = ["tokens"]`, `"token" not in parts` is True, and the qualifier check fails because `index 0 > 0` is False — so the key passes through unredacted.
Any config dict with `{"tokens": [...]}` (e.g., an OAuth token list, Slack API response embedded in a config) will now serialize its value in plaintext where it previously would have been `"<redacted>"`. Adding `"tokens"` to `_SENSITIVE_KEY_MARKERS` would restore full coverage while keeping all the new qualifications for count-style keys.
How can I resolve this? If you propose a fix, please make it concise.
ChrisJar
left a comment
There was a problem hiding this comment.
lgtm apart from existing comments
Summary
caption_max_tokensandtext_chunk_overlap_tokenswhile continuing to redact API keys, tokens, credentials, and webhook valuesrun.logand TREC artifact I/O failures through the harness's existing artifact-write error path and exit code 30query_results.jsonlunlinksWhy this remains after #2290
#2290 absorbed the broader artifact-safety work: atomic JSON publication, preflight-before-cleanup, portable relative manifests, and exit-30 handling for JSON and session writes. This PR intentionally keeps that contract unchanged.
Two concrete gaps remained on merged
main:token, so reproducibility values such asmax_tokenswere serialized as"<redacted>".OSErrorcould therefore surface as internal exit code 70 instead of artifact exit code 30.Scope boundaries
This PR does not change the artifact schema, session layout, output-directory reuse policy, manifest format, Slack reporting, or benchmark execution behavior introduced by #2290. It adds no new writer abstraction.
Validation
uv run pytest -q tests/test_harness*.py tests/test_beir_evaluation.py— 72 passedjp20_smoke --dry-run --jsonartifact check:caption_max_tokens: 77andtext_chunk_max_tokens: 123remained numeric in resolved/planning artifacts