Skip to content

[Bug]: DSH integration atomic replace changes file ownership and causes EACCES across UIDs #4197

Closed

Description

@juzijia

Client or integration

OpenCodex dashboard

Area

Platform (Windows / macOS / Linux)

Summary

When OpenCodex and DeepSeek Harness (DSH) run under different Unix UIDs and share the DSH home through a writable Docker bind mount, an OpenCodex DSH integration write atomically replaces settings.yaml with a fresh 0600 inode owned by the OpenCodex UID. The DSH service then receives EACCES while reading its own configuration.

I expected an integration write either to leave the existing external configuration readable by its owner or to refuse the write before replacing the file when that cannot be guaranteed.

This was reproduced through the real POST /api/client-integrations/restore route on 2.49.0. The same write path is still present in the v2.50.0 source tag.

Reproduction

  1. Run DSH as host UID/GID 987:981 with a valid /srv/dsh-data/settings.yaml owned by 987:981 and mode 0600.

  2. Run OpenCodex in Docker as UID/GID 1000:1000 and bind mount /srv/dsh-data to /home/bun/.dsh with rw. Docker user namespace remapping is disabled.

  3. Confirm that UID 987 can read the file:

    sudo -u dsh sh -c 'cat /srv/dsh-data/settings.yaml >/dev/null'
    stat -c 'inode=%i uid=%u gid=%g mode=%a' /srv/dsh-data/settings.yaml

    Observed before the integration write:

    inode=14686997 uid=987 gid=981 mode=600
    DSH read exit status: 0
    
  4. In the OpenCodex dashboard, enable/overwrite the DSH integration and then use its recorded restore operation, or call the equivalent authenticated route once:

    POST /api/client-integrations/restore
    Content-Type: application/json
    
    {"opId":"<existing DSH operation id>","confirmDrift":false}
  5. Inspect the same bind-mounted file and read it again as UID 987.

    Observed immediately after the successful restore response:

    inode=14687003 uid=1000 gid=1000 mode=600
    cat: /srv/dsh-data/settings.yaml: Permission denied
    DSH read exit status: 1
    

On this filesystem the directory has a default POSIX ACL. The replacement retained a named user:dsh:rw- entry but mask::--- made it ineffective. The ACL is not required for the failure: owner 1000 plus mode 0600 is already unreadable by UID 987.

The current source path is:

restoreIntegrationCoordinated
  -> restoreIntegration
  -> commit
  -> IntegrationIO.writeText
  -> atomicWriteFile
  -> openSync(temp, "wx", 0o600)
  -> fchmodSync(tempFd, 0o600)
  -> rename(temp, settings.yaml)

Relevant v2.50.0 source:

The minimal safe behavior would be to reject an integration write when an existing external config has a different owner and OpenCodex cannot preserve its accessibility. A metadata-preserving atomic replacement could be supported where uid/gid/mode/ACL can all be applied to the temporary inode before rename.

Version

Reproduced on @bitkyc08/opencodex 2.49.0. Verified that the same IntegrationIO.writeText -> atomicWriteFile implementation remains in tag v2.50.0 (2d4d7a22381a2e497c2442902104619e25f937c7).

Operating system

fnOS host based on Debian GNU/Linux 12 (bookworm), Linux 6.18.18.c1032-trim x86_64; Docker bind mount, no user namespace remapping.

Provider and model

Not provider- or model-specific.

Logs or error output

Error: dsh: plugin tree failed to load: failed to apply loader entry settings (@deepseek-ai/dsh-settings-file): EACCES: permission denied, open '/srv/dsh-data/settings.yaml'
Error: EACCES: permission denied, open '/srv/dsh-data/settings.yaml'
    at async readFile (node:internal/fs/promises:1287:14)
    at async FileSettingsProvider.load (.../@deepseek-ai/dsh-settings-file/lib/index.js:137:11)

Screenshots and supporting files

No screenshots. The before/after metadata and DSH read result above came from a controlled, rollback-tested reproduction on the same inode-backed bind mount.

Redacted configuration

{
  "opencodex": {
    "version": "2.49.0",
    "uid": 1000,
    "gid": 1000,
    "containerPath": "/home/bun/.dsh"
  },
  "dsh": {
    "version": "0.1.5-rc.1",
    "uid": 987,
    "gid": 981,
    "hostPath": "/srv/dsh-data"
  },
  "mount": {
    "type": "bind",
    "readWrite": true,
    "userNamespaceRemapping": false
  }
}

Checks

  • I searched existing issues and documentation.
  • I removed secrets, tokens, account details, request credentials, and personal data.

Activity

  1. added
    bugSomething isn't working
    platformOS/service/tray/ACL (Windows-heavy, not Windows-only)
    on Sep 10, 2026
  2. lidge-jun commented on Sep 10, 2026

    @lidge-jun
    Owner

    리뷰 · 우선순위 64 / 80

    이 이슈는 OpenCodex가 DeepSeek Harness(DSH) 설정을 통합(integration)으로 쓰거나 복원할 때, 공유된 settings.yaml을 원자적으로 덮어쓰면서 파일 주인이 OpenCodex 프로세스 UID로 바뀌고, 원래 DSH UID로는 더 이상 읽을 수 없게 되는 문제다. 재현은 Docker bind mount로 /srv/dsh-data를 OpenCodex 컨테이너의 DSH 홈에 붙인 뒤, 대시보드에서 DSH 통합을 켜거나 POST /api/client-integrations/restore를 한 번 호출하는 경로다. 보고된 환경은 OpenCodex UID 1000 / DSH UID 987이고, 쓰기 전에는 inode 주인이 987:981·mode 600이라 DSH가 읽히다가, 복원 직후 inode가 새로 생기고 주인이 1000:1000·mode 600이 되어 DSH가 EACCES로 죽는다.

    현재 dev HEAD 6d3ad12e3(package.json 2.51.0, 직전 tip은 #4196 2.50.0 회귀감사 문서 마감)에서도 같은 쓰기 경로가 그대로다. src/integrations/config-io.ts의 fileIO().writeText는 곧바로 atomicWriteFile을 호출하고, src/config/atomic-write.ts의 writePrivateTempFile은 openSync(path, "wx", 0o600)으로 임시 파일을 만든 뒤 fchmodSync(..., 0o600)으로 다시 잠근 다음 rename으로 목표 파일을 교체한다. 기존 대상 파일의 uid/gid/mode/ACL을 읽어서 임시 inode에 되돌리는 단계는 없다. 보안 관점에서는 비밀 설정에 owner-only 0600을 강제하는 설계가 맞고, Windows 쪽도 hardenSecretPath로 같은 방향을 따른다. 다만 DSH처럼 외부 제품의 홈을 공유 마운트로 만지는 통합에서는, 원자 교체가 ‘내용만 안전하게 바꾸는 것’이 아니라 ‘권한 주체까지 OpenCodex로 바꾸는 것’이 되어 버린다. 보고자가 ACL mask::--- 얘기까지 적었지만, 핵심은 ACL이 없어도 owner 1000 + 0600이면 UID 987은 이미 막힌다는 점이다.

    호출 스택도 이슈 본문과 맞다. restoreIntegrationCoordinated → restoreIntegration → commit → IntegrationIO.writeText → atomicWriteFile. src/integrations/registry.ts에 dsh 항목이 있고 config/detect 경로가 DSH 홈의 settings.yaml을 가리킨다. 즉 이건 DSH 전용 버그라기보다 통합 writer가 외부 소유 파일을 같은 원자 쓰기 헬퍼로 덮어쓰는 일반 패턴이 멀티 UID Docker에서 터진 사례다. Claude Desktop·Codex 홈 같은 다른 클라이언트 통합도, 호스트/컨테이너 UID가 갈라진 bind mount면 같은 함정에 빠질 수 있다. 2.49.0에서 재현됐고 v2.50.0 태그에도 동일 구현이 있다는 확인까지 되어 있어서, ‘이미 고쳤는지’를 다시 추적할 필요는 없고 dev에서도 아직 열려 있다.

    우선순위 64로 둔 이유: 플랫폼/권한 계열 실사용 장애이고 재현·원인 경로가 매우 구체적이며, 한 번 터지면 DSH 플러그인 트리가 통째로 로드 실패한다. 다만 기본 단일 UID 설치에서는 잘 안 보이고, 지금 dev의 제품 초점은 2.51.0 라인의 카탈로그/어피니티/릴리즈 기차 쪽이라 즉시 merge-blocking 핫픽스 급까지는 아니다. 고칠 때는 ‘전역 atomicWriteFile의 0600 정책을 풀지 말 것’이 중요하다. 비밀 파일 harden은 유지하고, 외부 소유 통합 경로에서만 거부하거나 메타데이터 보존을 선택적으로 넣는 쪽이 맞다.

    라인 259 (src/integrations/config-io.ts) - writeText가 무조건 atomicWriteFile로 가서, 통합 대상이 외부 UID 소유여도 소유권 검사를 하지 않는다.
    라인 123-129 (src/config/atomic-write.ts writePrivateTempFile) - 임시 파일을 항상 0o600·현재 프로세스 UID로 만들고, 기존 대상의 uid/gid를 fchown으로 맞추지 않는다.
    라인 159-191 (src/config/atomic-write.ts atomicWriteFile) - temp+rename 전후에 기존 inode 메타데이터(stat uid/gid/mode, ACL)를 캡처·복원하는 훅이 없다.
    src/integrations/writer.ts restoreIntegration / restoreIntegrationCoordinated - restore가 성공 응답을 낸 뒤 DSH 쪽에서 읽기 실패가 나도 OpenCodex는 이미 commit한 상태라 운영자가 ‘복원은 됐는데 DSH가 죽었다’로 본다.
    src/integrations/registry.ts dsh - DSH 홈을 감지·기록하는 통합이지만, 쓰기 전 ‘대상 파일 owner == process.geteuid() 또는 쓰기 후 기존 owner가 읽기 가능’ 가드가 없다.

    메인테이너의 판단이 필요한 지점

    • 외부 소유 파일이면 쓰기 자체를 거부(명확한 API 에러)할지, 가능하면 uid/gid/mode/ACL을 보존한 atomic replace를 지원할지.
    • 보존을 넣을 경우 root/CAP_CHOWN 없이 fchown이 실패하는 환경에서 어떻게 실패할지(거부 vs best-effort chmod only).
    • 이 가드를 DSH만 할지, 모든 IntegrationIO.writeText 대상(외부 클라이언트 설정)에 공통으로 할지.
    • 문서/대시보드에 ‘DSH 홈과 OpenCodex는 같은 UID로 돌리거나, bind mount 대신 복사 동기화’ 운영 가이드를 같이 둘지.

    너의 추천
    열어 두고 platform 버그로 처리하되, 첫 패치는 전역 0600 harden을 풀지 말고 통합 쓰기 직전에 기존 파일이 있고 owner≠geteuid()면 restore/enable을 거부하는 쪽이 안전하다. 그다음 옵션으로 temp inode에 기존 uid/gid를 맞출 수 있을 때만 rename을 허용하는 메타데이터 보존 경로를 실험하면 된다. 재현 픽스처는 Docker multi-UID bind mount 없이도 chown된 fixture + seteuid 흉내/별도 유저로 읽기 검증이면 충분하다. 중복 이슈는 검색상 이 번호가 유일하니 close-as-duplicate 대상은 없다.

    이 댓글은 grok-bot이 작성했습니다

  3. lidge-jun commented on Sep 10, 2026

    @lidge-jun
    Owner

    Fixed on dev.

    #4227 (6b033f1f1) refuses a write that would take a config file from its owner, so the integration's atomic replace no longer changes ownership and no longer produces EACCES across UIDs.

    Closing manually: pull requests here target dev, so GitHub does not auto-close linked issues.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingplatformOS/service/tray/ACL (Windows-heavy, not Windows-only)

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions