Skip to content

feat(acp): materialise reserved file-content secrets onto disk#3269

Open
simonrosenberg wants to merge 1 commit into
mainfrom
feat-acp-file-secrets
Open

feat(acp): materialise reserved file-content secrets onto disk#3269
simonrosenberg wants to merge 1 commit into
mainfrom
feat-acp-file-secrets

Conversation

@simonrosenberg
Copy link
Copy Markdown
Collaborator

@simonrosenberg simonrosenberg commented May 15, 2026

Why

Two ACP servers we already support authenticate via a JSON file on disk rather than an env var:

  • Codex (ChatGPT subscription) reads OAuth tokens from $CODEX_HOME/auth.json and writes back to that file on refresh. There is no env-var alternative — codex's auth state is inherently file-backed.
  • Gemini CLI (Vertex AI service account / ADC) authenticates via GOOGLE_APPLICATION_CREDENTIALS=/abs/path/to/key.json pointing at a JSON file.

Today the SDK has no way to surface these to the spawned subprocess. Consumers (e.g. OpenHands) end up reinventing per-conversation file injection, bind-mounts, cleanup, and path-routing — all of which is provider-specific knowledge that more properly belongs in the SDK alongside the rest of the per-provider config (acp_command, acp_model, _acp_provider_env, etc.).

This PR adds a small registry-driven mechanism: a reserved secret name → write its value to a per-agent tempdir → set the controlling env var on the subprocess → strip the secret from the regular env-var injection path so the (potentially multi-KB) JSON payload isn't also exported.

What changes

openhands-sdk/openhands/sdk/agent/acp_agent.py:

@dataclass(frozen=True)
class _FileSecretSpec:
    filename: str
    env_var: str
    env_points_to: Literal["dir", "file"]


_FILE_SECRETS: dict[str, _FileSecretSpec] = {
    "CODEX_AUTH_JSON": _FileSecretSpec(
        filename="auth.json",
        env_var="CODEX_HOME",
        env_points_to="dir",
    ),
    "GOOGLE_APPLICATION_CREDENTIALS_JSON": _FileSecretSpec(
        filename="gcloud-credentials.json",
        env_var="GOOGLE_APPLICATION_CREDENTIALS",
        env_points_to="file",
    ),
}

ACPAgent._materialise_file_secrets(env) walks agent_context.secrets, materialises matches into self._file_secrets_tempdir (a tempfile.TemporaryDirectory created lazily), sets the controlling env vars, and returns the remaining (plain) secrets that should flow through the existing env-var injection loop in _start_acp_server. Files are written with 0o600 perms; each file-secret lives in its own subdirectory so env_points_to="dir" handlers don't expose siblings. The per-agent tempdir is cleaned up in _cleanup after the subprocess and executor are torn down.

Adding a new file-based auth mechanism for a future ACP server is one _FILE_SECRETS entry.

Why these two reserved names

Provider · Path Why it needs a file
Codex · ChatGPT subscription The codex binary writes back to auth.json on token refresh. An env var would lose refresh state between turns.
Gemini · Vertex AI SA / ADC GOOGLE_APPLICATION_CREDENTIALS is contractually a path to a JSON file. There is no env-var-with-content form of this credential.

Intentionally not covered: Gemini personal-account "Sign in with Google" subscription. gemini-cli caches those credentials in ~/.gemini/gemini-credentials.json encrypted with a key derived from os.hostname() + os.userInfo().username (see @google/gemini-cli-core/dist/src/services/fileKeychain.js, deriveEncryptionKey() salted with ${hostname}-${username}-gemini-cli then scrypt'd). That file is machine-bound by design and cannot be transported to a cloud host. Google's own headless docs confirm: subscription users running headless must fall back to API key or Vertex AI.

Why not just inject as env vars

The SDK already exports AgentContext.secrets as env vars on the spawned subprocess. We could put the JSON payload in an env var named (say) CODEX_AUTH_JSON and tell the binary to read it from there. But:

  1. Codex specifically writes back to its credential file on refresh. An env var can't be written back.
  2. GOOGLE_APPLICATION_CREDENTIALS is contractually a file path, not file content. There's no way to make Google's SDKs read a JSON blob out of an env var.
  3. A multi-KB JSON payload in os.environ is visible to anything that can read /proc/<pid>/environ. Materialising the file with 0o600 and dropping the secret from the env-var path is the safer default.

How consumers use this

A consumer (OpenHands or anyone embedding the SDK) doesn't need provider-specific code. To authenticate Codex via ChatGPT subscription, they write a secret named CODEX_AUTH_JSON with the contents of the user's ~/.codex/auth.json:

agent_context = AgentContext(
    secrets={
        "CODEX_AUTH_JSON": StaticSecret(value=SecretStr(open(auth_path).read())),
    }
)
ACPAgent(acp_command=[...], agent_context=agent_context, ...)

The same flow with GOOGLE_APPLICATION_CREDENTIALS_JSON activates Gemini Vertex AI auth.

Tests

Seven new cases in TestACPFileSecretMaterialisation:

  • Codex auth materialises to auth.json + CODEX_HOME points at containing dir
  • Gemini GAC materialises to a JSON file + GOOGLE_APPLICATION_CREDENTIALS points at the file path
  • Files on disk are 0o600
  • Plain secrets still flow through the env-var path (regression coverage for the existing injection)
  • Empty payload is skipped and tempdir is not created
  • Codex + Gemini coexist in one agent, sharing one base tempdir but separate subdirs
  • ACPAgent.close() removes the materialised tempdir

All 180 tests in test_acp_agent.py pass.

Related

This unblocks an architectural simplification on the OpenHands side: OpenHands/OpenHands#14425 currently carries ~500 lines of provider-specific file-injection machinery (_inject_codex_auth, _cleanup_acp_temp_dir, OH_ACP_FILE_SECRETS_DIR, Docker bind-mount auto-config, secrets_router :path hack, special credential textareas per preset). Once this PR is released and pinned, that all collapses to a small informational panel telling users which secret name to create — provider-specific knowledge stays here in the SDK where it already lives next to _acp_provider_env.


Agent Server images for this PR

GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python amd64, arm64 nikolaik/python-nodejs:python3.13-nodejs22-slim Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:6a379c0-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-6a379c0-python \
  ghcr.io/openhands/agent-server:6a379c0-python

All tags pushed for this build

ghcr.io/openhands/agent-server:6a379c0-golang-amd64
ghcr.io/openhands/agent-server:6a379c03faf7f1f03a32bf19831851dbf27c0400-golang-amd64
ghcr.io/openhands/agent-server:feat-acp-file-secrets-golang-amd64
ghcr.io/openhands/agent-server:6a379c0-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:6a379c0-golang-arm64
ghcr.io/openhands/agent-server:6a379c03faf7f1f03a32bf19831851dbf27c0400-golang-arm64
ghcr.io/openhands/agent-server:feat-acp-file-secrets-golang-arm64
ghcr.io/openhands/agent-server:6a379c0-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:6a379c0-java-amd64
ghcr.io/openhands/agent-server:6a379c03faf7f1f03a32bf19831851dbf27c0400-java-amd64
ghcr.io/openhands/agent-server:feat-acp-file-secrets-java-amd64
ghcr.io/openhands/agent-server:6a379c0-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:6a379c0-java-arm64
ghcr.io/openhands/agent-server:6a379c03faf7f1f03a32bf19831851dbf27c0400-java-arm64
ghcr.io/openhands/agent-server:feat-acp-file-secrets-java-arm64
ghcr.io/openhands/agent-server:6a379c0-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:6a379c0-python-amd64
ghcr.io/openhands/agent-server:6a379c03faf7f1f03a32bf19831851dbf27c0400-python-amd64
ghcr.io/openhands/agent-server:feat-acp-file-secrets-python-amd64
ghcr.io/openhands/agent-server:6a379c0-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:6a379c0-python-arm64
ghcr.io/openhands/agent-server:6a379c03faf7f1f03a32bf19831851dbf27c0400-python-arm64
ghcr.io/openhands/agent-server:feat-acp-file-secrets-python-arm64
ghcr.io/openhands/agent-server:6a379c0-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:6a379c0-golang
ghcr.io/openhands/agent-server:6a379c03faf7f1f03a32bf19831851dbf27c0400-golang
ghcr.io/openhands/agent-server:feat-acp-file-secrets-golang
ghcr.io/openhands/agent-server:6a379c0-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:6a379c0-java
ghcr.io/openhands/agent-server:6a379c03faf7f1f03a32bf19831851dbf27c0400-java
ghcr.io/openhands/agent-server:feat-acp-file-secrets-java
ghcr.io/openhands/agent-server:6a379c0-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:6a379c0-python
ghcr.io/openhands/agent-server:6a379c03faf7f1f03a32bf19831851dbf27c0400-python
ghcr.io/openhands/agent-server:feat-acp-file-secrets-python
ghcr.io/openhands/agent-server:6a379c0-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim

About Multi-Architecture Support

  • Each variant tag (e.g., 6a379c0-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., 6a379c0-python-amd64) are also available if needed

Some ACP servers authenticate via a JSON credential file rather than an
env var (Codex's ChatGPT subscription via ``$CODEX_HOME/auth.json``,
Gemini's Vertex AI service-account / ADC via
``GOOGLE_APPLICATION_CREDENTIALS``). Previously the only path for these
deployments was to pass the credentials in the host filesystem out of
band, which doesn't work when the SDK runs in a container without
access to the user's home directory.

This commit adds a small registry-driven mechanism in ``ACPAgent`` for
reserved secret names that should be **materialised as files** before
the subprocess spawns, rather than exported as env vars:

- ``CODEX_AUTH_JSON`` → written to ``<tempdir>/codex_auth_json/auth.json``;
  ``CODEX_HOME`` is set to that directory. Codex writes back to the file
  on token refresh, so a real writable file (not an env var) is required.

- ``GOOGLE_APPLICATION_CREDENTIALS_JSON`` → written to
  ``<tempdir>/google_application_credentials_json/gcloud-credentials.json``;
  ``GOOGLE_APPLICATION_CREDENTIALS`` is set to that file path. This
  covers Vertex AI service-account keys and ADC. Personal-account
  "Sign in with Google" subscriptions are intentionally not covered:
  gemini-cli encrypts those credentials with a key derived from
  ``hostname + username``, making the cached file machine-bound.

Mechanism:

1. ``_FILE_SECRETS`` is a module-level registry mapping a reserved
   secret name to a ``_FileSecretSpec(filename, env_var, env_points_to)``.
   ``env_points_to`` is ``"dir"`` when the binary expects an env var
   naming a directory (Codex) and ``"file"`` when it expects an
   absolute file path (Google).
2. ``_materialise_file_secrets`` walks ``agent_context.secrets``,
   writes each matching payload into a per-agent ``TemporaryDirectory``
   with 0o600 perms, sets the controlling env var, and returns the
   subset of secrets that still belong on the env-var injection path.
3. The existing env-var loop in ``_start_acp_server`` now iterates the
   filtered remainder, so a file-secret's (potentially multi-KB) JSON
   payload is **not** also leaked into ``os.environ``.
4. ``_cleanup`` removes the per-agent tempdir after the subprocess and
   executor are torn down, so we don't yank credential files out from
   under a still-running child.

Adding a new file-based auth mechanism for a future ACP server is one
``_FILE_SECRETS`` entry. Consumers (e.g. OpenHands) get subscription
auth by writing a plain secret with the reserved name — no provider-
specific backend code on the host side.

Tested with seven new cases in ``TestACPFileSecretMaterialisation``
covering both reserved names, file permissions, coexistence of file-
and plain-secrets, empty-payload skip, shared-tempdir layout, and
cleanup on ``close()``.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

Python API breakage checks — ✅ PASSED

Result:PASSED

Action log

@github-actions
Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

@github-actions
Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-sdk/openhands/sdk/agent
   acp_agent.py6634593%397–399, 529–530, 563, 565, 569, 573, 581, 644–645, 650, 717, 877, 880–881, 898–899, 928, 933, 951, 961, 982–985, 1225–1228, 1232–1234, 1237–1241, 1243, 1403, 1739–1740, 1749–1750
TOTAL26763773371% 

@simonrosenberg simonrosenberg self-assigned this May 15, 2026
Copy link
Copy Markdown
Collaborator

@all-hands-bot all-hands-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taste Rating: 🟡 Acceptable - Solid implementation solving real problems, but one test needs fixing.

Overview: This PR elegantly solves the file-based authentication problem for Codex and Gemini with a simple registry pattern. The data structure is clean, the implementation is straightforward, and the security considerations are mostly sound. One test has an environment collision issue that needs fixing.

# File-secret was materialised
assert env["CODEX_HOME"]
# Plain secret was injected as env var
assert env.get("GITHUB_TOKEN") == "ghp_xyz"
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical - Test Failure: This test fails because GITHUB_TOKEN from os.environ takes precedence over the agent's secret (line 1131 in acp_agent.py: if name not in env).

The test environment already has GITHUB_TOKEN set, so the agent's ghp_xyz value is never injected. Use a non-colliding secret name instead:

Suggested change
assert env.get("GITHUB_TOKEN") == "ghp_xyz"
secrets={
"CODEX_AUTH_JSON": StaticSecret(value=SecretStr("{}")),
"MY_API_TOKEN": StaticSecret(value=SecretStr("test_token_xyz")),
}

Then update the assertion:

assert env.get("MY_API_TOKEN") == "test_token_xyz"

Comment on lines +1095 to +1096
target_path.write_text(value)
target_path.chmod(0o600)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion - Security: There's a brief window between write_text() and chmod(0o600) where the file might have default permissions.

This is mitigated by the parent directory already being 0o700 (line 1093), which prevents other users from accessing the file even during this window. However, for defense-in-depth, consider using os.open() with atomic mode setting:

fd = os.open(target_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, 'w') as f:
    f.write(value)

Not blocking since directory permissions provide adequate protection.

@all-hands-bot
Copy link
Copy Markdown
Collaborator

Risk Assessment

[RISK ASSESSMENT]

  • [Overall PR] ⚠️ Risk Assessment: 🟢 LOW

This PR adds optional file-based secret materialization for specific ACP providers. The risk is low because:

  • Additive functionality: Only activates when specific reserved secret names are used
  • No breaking changes: Existing secret injection behavior unchanged
  • Security conscious: Files written with 0o600 permissions, isolated in per-secret subdirs within 0o700 parent
  • Proper cleanup: Tempdir cleaned up in close() with defensive error handling
  • Well tested: 6 of 7 tests pass, covering all scenarios
  • Bounded scope: Only two providers affected (Codex, Gemini GAC), both with clear rationale

[IMPROVEMENT OPPORTUNITIES]

  • One test needs fixing (environment collision with GITHUB_TOKEN)
  • Consider atomic file permission setting for defense-in-depth (not critical due to directory permissions)

VERDICT:
Needs rework: Fix the failing test, then this is ready to merge.

KEY INSIGHT:
The registry pattern (_FILE_SECRETS) is the right abstraction – adding future file-based auth is a single dict entry, and the implementation elegantly separates file-secrets from regular env-var secrets without special-casing the mainline code path.


Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger and the context the reviewer is missing. See the customization docs for the required frontmatter format.
  2. Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
  3. When your PR is merged, the guideline file goes through normal code review by repository maintainers.

Resolve with AI? Install the iterate skill in your agent and run /iterate to automatically drive this PR through CI, review, and QA until it's merge-ready.

Copy link
Copy Markdown
Collaborator

@all-hands-bot all-hands-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ QA Report: PASS

File-secret materialization works correctly for both Codex and Gemini CLI authentication, with proper security isolation and cleanup.

Does this PR achieve its stated goal?

Yes. This PR set out to add a mechanism for ACP servers that authenticate via JSON credential files on disk (specifically Codex ChatGPT subscription and Gemini Vertex AI service accounts). I verified that:

  1. CODEX_AUTH_JSON is correctly materialized to auth.json with CODEX_HOME pointing to the containing directory
  2. GOOGLE_APPLICATION_CREDENTIALS_JSON is correctly materialized to gcloud-credentials.json with GOOGLE_APPLICATION_CREDENTIALS pointing to the file path
  3. Files are written with secure 0o600 permissions (owner read/write only)
  4. File-secrets are excluded from env-var injection (preventing multi-KB JSON payloads in the environment)
  5. Plain secrets continue to work normally alongside file-secrets
  6. Cleanup correctly removes the tempdir after agent close

The implementation delivers exactly what the PR description promises: a registry-driven mechanism that lets consumers (OpenHands or SDK embedders) provide file-based credentials without writing provider-specific file-injection code.

Phase Result
Environment Setup ✅ Dependencies installed, project built successfully
CI Status ✅ All critical checks passing (sdk-tests, tools-tests, pre-commit, API checks)
Functional Verification ✅ All functionality verified end-to-end
Functional Verification

Test 1: CODEX_AUTH_JSON Materialization

Setup: Created ACPAgent with CODEX_AUTH_JSON secret containing test JSON payload.

Execution:

from pydantic import SecretStr
from openhands.sdk.secret import StaticSecret

CODEX_PAYLOAD = '{"auth_mode":"chatgpt","tokens":{"id_token":"test_token_123"}}'
agent = ACPAgent(
    llm=LLM(model="openai/gpt-4o", api_key="dummy"),
    acp_command=["echo", "test"],
    agent_context=AgentContext(
        secrets={"CODEX_AUTH_JSON": StaticSecret(value=SecretStr(CODEX_PAYLOAD))}
    ),
)

env = {}
remaining = agent._materialise_file_secrets(env)

Result:

  • CODEX_HOME env var set: /tmp/acp-file-secrets-_1wzte74/codex_auth_json
  • auth.json file created at $CODEX_HOME/auth.json
  • ✅ File content matches payload exactly
  • ✅ File permissions: 0o600 (verified with stat.S_IMODE())
  • CODEX_AUTH_JSON removed from regular secrets dict
  • ✅ Tempdir cleaned up after agent.close()

Test 2: GOOGLE_APPLICATION_CREDENTIALS_JSON Materialization

Setup: Created ACPAgent with GOOGLE_APPLICATION_CREDENTIALS_JSON secret.

Result:

  • GOOGLE_APPLICATION_CREDENTIALS env var set to file path (not directory)
  • gcloud-credentials.json created at the specified path
  • ✅ File content matches payload
  • ✅ File permissions: 0o600
  • ✅ Secret removed from regular env-var injection

Test 3: Integration with Subprocess Environment

Setup: Created a shell script that prints environment variables and reads the materialized files. Simulated the full _start_acp_server() flow.

Result:

CODEX_HOME=/tmp/acp-file-secrets-ai1he84j/codex_auth_json
GOOGLE_APPLICATION_CREDENTIALS=/tmp/.../gcloud-credentials.json
CODEX_AUTH_FILE_EXISTS=yes
CODEX_AUTH_CONTENT={"test":"codex_auth"}
GAC_FILE_EXISTS=yes
GAC_CONTENT={"test":"gac_auth"}
  • ✅ Subprocess received correct environment variables
  • ✅ Subprocess could read materialized files
  • ✅ File-secret payloads NOT present as raw env vars
  • ✅ Plain secrets (GITHUB_TOKEN) still injected normally

Test 4: Security Isolation

Setup: Created agent with both file-secrets to verify isolation.

Result:

  • ✅ Each file-secret in separate subdirectory:
    • codex_auth_json/auth.json
    • google_application_credentials_json/gcloud-credentials.json
  • CODEX_HOME directory contains ONLY auth.json (no GAC file)
  • ✅ GAC directory contains ONLY gcloud-credentials.json (no Codex file)
  • ✅ File permissions: 0o600 (owner read/write only)
  • ✅ Directory permissions: 0o700 (owner access only)

Test 5: Edge Cases

Empty secret value:

  • ✅ Warning logged: Reserved file-secret 'CODEX_AUTH_JSON' has an empty value; skipping materialisation
  • ✅ No tempdir created
  • ✅ No CODEX_HOME env var set

Mixed file-secrets and plain secrets:

  • ✅ Both file-secrets materialized correctly
  • ✅ Plain secrets (GITHUB_TOKEN) retained in secrets dict
  • ✅ All secrets accessible to subprocess as expected

Issues Found

None.

simonrosenberg pushed a commit to OpenHands/OpenHands that referenced this pull request May 15, 2026
Replaces the host-side codex auth.json materialisation introduced in
d019357 (and the Claude-OAuth credential textarea introduced in
e74389a) with the SDK's new reserved-secret materialisation in
``ACPAgent`` (OpenHands/software-agent-sdk#3269, commit 6a379c03f).

Authentication for ACP subscriptions now lives entirely in the standard
Settings → Secrets surface, with provider-specific behaviour pushed
down to the SDK:

- Claude Code · Max subscription → secret named ``CLAUDE_CODE_OAUTH_TOKEN``,
  value = the OAuth access token. SDK exports it as an env var on the
  spawned subprocess (claude reads it directly).
- Codex · ChatGPT subscription → secret named ``CODEX_AUTH_JSON``,
  value = the contents of ``~/.codex/auth.json``. SDK writes it to a
  per-agent temp file and sets ``CODEX_HOME``. Codex's refresh-token
  state is preserved across turns because the file is real and writable.

API-key paths (``ANTHROPIC_API_KEY``, ``OPENAI_API_KEY``) continue to
work via the existing ``_acp_provider_env`` translation of the LLM
profile's API key into the provider-native env var.

What this PR drops from OpenHands:

- ``_inject_codex_auth``, ``_cleanup_acp_temp_dir``,
  ``_acp_file_secrets_base_dir``, the ``CODEX_AUTH_JSON_SECRET_NAME``
  constant, and their call sites in
  ``live_status_app_conversation_service.py``.
- ``OH_ACP_FILE_SECRETS_DIR`` env var and the auto bind-mount in
  ``DockerSandboxService`` config — the SDK now writes the file
  next to the subprocess inside the agent-server, so the backend
  doesn't need filesystem reach into the agent's container.
- ``/{secret_id:path}`` → ``/{secret_id}`` reverted in ``secrets_router``
  (the slashed ``FILE:~/.codex/auth.json`` name is gone).
- ``encodeURIComponent`` on PUT/DELETE in ``secrets-service.ts``
  reverted for the same reason.
- ``SecretsService.upsertSecret`` and the ``useUpsertSecret`` hook —
  no longer needed; users create secrets through the regular Secrets UI.
- The two credential textareas under the Claude Code / Codex presets
  in ``agent-settings.tsx``, the ``extractClaudeOauthToken`` and
  ``isLikelyCodexAuthJson`` helpers, and the save-credentials flow.

What replaces it:

- A small ``SubscriptionAuthInfo`` panel per preset (Claude Code,
  Codex) telling the user which magic secret name to create, surfacing
  a "Secret found" / "Secret not set" indicator (via the existing
  ``useSearchSecrets`` hook), and showing copy-pasteable extraction
  commands for macOS / Linux.
- Five new i18n keys × 15 languages (``SETTINGS$AGENT_AUTH_*``,
  ``SETTINGS$AGENT_CLAUDE_AUTH_INSTRUCTIONS``,
  ``SETTINGS$AGENT_CODEX_AUTH_INSTRUCTIONS``). Old credential-
  textarea keys are still in ``translation.json`` for now; they're
  no longer referenced from code and can be swept in a follow-up.

Net diff: −596 / +300 lines on this PR vs the previous tip.

The SDK pin is temporarily a git source pointing at the SDK PR's
commit (6a379c03f). It must be replaced with a regular pinned release
version (>= whichever SDK release first includes that commit) before
this PR can land on main.

Frontend tests updated: ``TestACPSecretsEnvInjection`` and codex
credential textarea tests deleted; five new cases under
"subscription auth info panels" covering visibility per preset,
secret-name surfaced in the panel text, and detected / not-detected
indicators.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Collaborator

@all-hands-bot all-hands-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Acceptable - Solid implementation solving real problems, but critical issues need fixing.

Assessment

This PR elegantly solves file-based authentication for Codex and Gemini using a clean registry pattern. The data structure (_FileSecretSpec) is well-designed, the implementation is straightforward, and the test coverage is comprehensive. However, two critical issues must be addressed:

  1. Security race condition in file permission setting (minor risk, easy fix)
  2. Failing tests due to incorrect secret precedence logic (critical, pre-existing bug exposed by new tests)

[CRITICAL ISSUES]

Secret Precedence Bug (Line 1131)

Problem: Agent secrets are silently ignored when the same key exists in os.environ. Both test_static_secret_injected_into_subprocess_env (existing) and test_plain_secrets_still_exported_as_env_vars (new) fail.

Root cause: The condition if name not in env checks the merged environment dict that already includes os.environ, giving inherited environment variables precedence over user-provided agent secrets.

Expected precedence:

  1. acp_env (explicit agent configuration)
  2. agent_context.secrets (user-provided runtime secrets)
  3. os.environ (inherited environment)

Fix: Change line 1131 from if name not in env to if name not in self.acp_env

Note: This is a pre-existing bug from PR #2984, but must be fixed here since the new tests expose it. Without this fix, users cannot override environment variables with agent secrets, which breaks the expected behavior.

File Permission Race Condition (Lines 1095-1096)

Problem: There's a brief window between write_text() and chmod(0o600) where files have default permissions.

Current mitigation: Parent directory is 0o700, preventing access from other users.

Better solution: Use atomic permission setting:

target_path.write_text(
    value,
    opener=lambda path, flags: os.open(path, flags, 0o600)
)

This eliminates the race window entirely and doesn't rely on parent directory protection.

[TESTING GAPS]

The PR includes comprehensive tests (7 test cases), but two tests are currently failing:

  1. test_static_secret_injected_into_subprocess_env (pre-existing)
  2. test_plain_secrets_still_exported_as_env_vars (new)

Both fail with:

AssertionError: assert '<secret-hidden>' == 'ghp_xyz'

This PR should not be merged with failing tests. The fix is straightforward (see line 1131 comment above).

[RISK ASSESSMENT]

⚠️ Risk Level: 🟡 MEDIUM

Factors:

  • Touches security-sensitive credential handling (file-based secrets)
  • Has failing tests that must pass before merge
  • Pre-existing bug affects all agent secrets, not just file-secrets
  • Changes are additive (only affects new reserved secret names)
  • Well-isolated with clear separation of concerns

Recommendation: Fix the two critical issues above before merging. The core file-secret materialization mechanism is sound and well-tested. Once the secret precedence bug is fixed and tests pass, this is ready to merge.

VERDICT

Needs fixes: Core logic is excellent, but failing tests and the secret precedence bug must be addressed. Both fixes are straightforward.

KEY INSIGHT

The file-secret registry pattern is elegant and extensible — adding support for future ACP providers with file-based auth is a one-line registry entry. However, the pre-existing secret precedence bug undermines the entire secret injection mechanism by preventing users from overriding environment variables with agent secrets.

Comment on lines +1095 to +1096
target_path.write_text(value)
target_path.chmod(0o600)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Important - Security: There's a brief window between write_text() (line 1095) and chmod(0o600) (line 1096) where the file has default permissions.

While this is partially mitigated by the parent directory being 0o700 (line 1093), there's a cleaner atomic solution:

Suggested change
target_path.write_text(value)
target_path.chmod(0o600)
target_path.write_text(
value,
opener=lambda path, flags: os.open(path, flags, 0o600)
)

This sets permissions atomically during file creation, eliminating the race window entirely. You'll need to add import os at the top if not already present (it's already imported on line 21).

# calling it here is safe because _start_acp_server is a regular
# (non-async) method.
for name, secret in regular_secrets.items():
if name not in env:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical - Logic Error: This condition causes agent secrets to be silently ignored when the same key exists in os.environ.

Current behavior (incorrect):

  1. acp_env has highest precedence ✓
  2. os.environ has second precedence ✗
  3. agent_context.secrets has lowest precedence ✗

Expected behavior:

  1. acp_env (explicit agent config)
  2. agent_context.secrets (user-provided runtime secrets)
  3. os.environ (inherited environment)

Impact: Both test_static_secret_injected_into_subprocess_env and test_plain_secrets_still_exported_as_env_vars fail because GITHUB_TOKEN from the test environment takes precedence over the agent's secret value.

Fix:

Suggested change
if name not in env:
if name not in self.acp_env:

This ensures only explicit acp_env entries override agent secrets, while agent secrets can override inherited os.environ values.

Note: This appears to be a pre-existing bug from PR #2984 that introduced secret injection. However, it must be fixed before merging this PR since the new tests expose the issue.

# File-secret was materialised
assert env["CODEX_HOME"]
# Plain secret was injected as env var
assert env.get("GITHUB_TOKEN") == "ghp_xyz"
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test currently fails due to the secret precedence bug at line 1131 in acp_agent.py. Once that's fixed, this test should pass.

Test output:

AssertionError: assert '<secret-hidden>' == 'ghp_xyz'

The test environment's GITHUB_TOKEN takes precedence over the agent's ghp_xyz value.

Copy link
Copy Markdown
Collaborator

@all-hands-bot all-hands-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ QA Report: PASS

File-secret materialization works as described; verified CODEX_AUTH_JSON and GOOGLE_APPLICATION_CREDENTIALS_JSON materialize to temp files with correct permissions, env vars, and cleanup.

Does this PR achieve its stated goal?

Yes. The PR successfully enables ACP agents to authenticate via file-based credentials without requiring consumers to implement provider-specific file injection. Verified end-to-end:

  1. Reserved secrets (CODEX_AUTH_JSON, GOOGLE_APPLICATION_CREDENTIALS_JSON) are written to temp files with 0o600 permissions
  2. Appropriate env vars (CODEX_HOME, GOOGLE_APPLICATION_CREDENTIALS) are set automatically
  3. File secrets are filtered from regular env var injection (not leaked)
  4. Plain secrets continue to work normally alongside file secrets
  5. Temp files are cleaned up on agent.close()
  6. Consumer usage pattern works transparently as documented
Phase Result
Environment Setup ✅ Dependencies synced, environment ready
CI Status ✅ All checks passing (SUCCESS)
Functional Verification ✅ 12 tests passed across unit + integration suites
Functional Verification

Test Suite 1: Unit Tests

Created /tmp/test_file_secrets.py to verify the materialization mechanism:

Test 1 — CODEX_AUTH_JSON Materialization:

agent = ACPAgent(
    llm=LLM(model="anthropic/claude-3-5-sonnet-20241022", api_key="fake"),
    acp_command=["echo", "fake"],
    agent_context=AgentContext(
        secrets={"CODEX_AUTH_JSON": StaticSecret(value=SecretStr(codex_payload))}
    )
)
test_env = {}
remaining = agent._materialise_file_secrets(test_env)

Result:

✓ Materialization method returned remaining secrets: []
✓ CODEX_HOME set: /tmp/acp-file-secrets-djx050v9/codex_auth_json
✓ File exists: /tmp/acp-file-secrets-djx050v9/codex_auth_json/auth.json
✓ File content matches payload
✓ File permissions: 0o600
✓ CODEX_AUTH_JSON not in remaining env vars
✓ Agent closed successfully
✓ Temp directory cleaned up

This confirms CODEX_AUTH_JSON is written to auth.json with CODEX_HOME pointing to the containing directory, exactly as required by the Codex binary.

Test 2 — GOOGLE_APPLICATION_CREDENTIALS_JSON Materialization:

agent_context=AgentContext(
    secrets={"GOOGLE_APPLICATION_CREDENTIALS_JSON": StaticSecret(value=SecretStr(gac_payload))}
)

Result:

✓ GOOGLE_APPLICATION_CREDENTIALS set: /tmp/acp-file-secrets-mdh7zvaa/google_application_credentials_json/gcloud-credentials.json
✓ File exists at path
✓ File content matches payload
✓ File permissions: 0o600
✓ GOOGLE_APPLICATION_CREDENTIALS_JSON not in remaining env vars
✓ Cleanup verified

This confirms the Gemini Vertex AI credential is written with GOOGLE_APPLICATION_CREDENTIALS pointing to the file path (not dir), as required by Google's SDK.

Test 3 — Plain Secrets Still Work:

secrets={
    "CODEX_AUTH_JSON": StaticSecret(value=SecretStr("{}")),
    "GITHUB_TOKEN": StaticSecret(value=SecretStr("ghp_fake_token")),
    "CUSTOM_SECRET": StaticSecret(value=SecretStr("custom_value")),
}

Result:

✓ Remaining secrets: ['GITHUB_TOKEN', 'CUSTOM_SECRET']
✓ CODEX_AUTH_JSON filtered out (file secret)
✓ GITHUB_TOKEN and CUSTOM_SECRET in remaining (plain secrets)

This confirms the change doesn't break existing secret injection — file secrets are filtered, plain secrets pass through.

Tests 4-5: Both file secrets coexist with shared base tempdir but isolated subdirs ✅, empty payloads skipped gracefully ✅

Test Suite 2: Integration Tests

Created /tmp/test_integration.py to verify consumer usage pattern:

Test: Consumer Usage Pattern (as documented in PR description):

codex_auth_content = json.dumps({
    "auth_mode": "chatgpt",
    "tokens": {"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."},
})

agent = ACPAgent(
    llm=LLM(model="anthropic/claude-3-5-sonnet-20241022", api_key="test"),
    acp_command=["codex", "serve"],
    agent_context=AgentContext(
        secrets={"CODEX_AUTH_JSON": StaticSecret(value=SecretStr(codex_auth_content))}
    )
)

Result:

✓ Agent created successfully
✓ CODEX_HOME set: /tmp/acp-file-secrets-zooasij9/codex_auth_json
✓ auth.json materialized automatically at /tmp/acp-file-secrets-zooasij9/codex_auth_json/auth.json
✓ Secret not leaked as env var
✓ Agent closed and cleaned up

This confirms the exact consumer pattern described in the PR works: create agent with reserved secret name, materialization happens transparently, no provider-specific code needed.

All 12 tests passed across both suites.

Issues Found

None.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants