Repository navigation
[Bug]: DSH integration atomic replace changes file ownership and causes EACCES across UIDs #4197
Description
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
-
Run DSH as host UID/GID
987:981with a valid/srv/dsh-data/settings.yamlowned by987:981and mode0600. -
Run OpenCodex in Docker as UID/GID
1000:1000and bind mount/srv/dsh-datato/home/bun/.dshwithrw. Docker user namespace remapping is disabled. -
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 -
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}
-
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:
src/integrations/config-io.ts:writeText: (path, text) => atomicWriteFile(path, text)src/config/atomic-write.ts: the temporary file is created and hardened to0600, then renamed over the target; the existing uid/gid/mode/ACL are not captured or restored.
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
lidge-jun commentedon Sep 10, 2026
리뷰 · 우선순위 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 UID1000/ DSH UID987이고, 쓰기 전에는 inode 주인이987:981·mode600이라 DSH가 읽히다가, 복원 직후 inode가 새로 생기고 주인이1000:1000·mode600이 되어 DSH가EACCES로 죽는다.현재
devHEAD6d3ad12e3(package.json2.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-only0600을 강제하는 설계가 맞고, Windows 쪽도hardenSecretPath로 같은 방향을 따른다. 다만 DSH처럼 외부 제품의 홈을 공유 마운트로 만지는 통합에서는, 원자 교체가 ‘내용만 안전하게 바꾸는 것’이 아니라 ‘권한 주체까지 OpenCodex로 바꾸는 것’이 되어 버린다. 보고자가 ACLmask::---얘기까지 적었지만, 핵심은 ACL이 없어도 owner1000+0600이면 UID987은 이미 막힌다는 점이다.호출 스택도 이슈 본문과 맞다.
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.tswritePrivateTempFile) - 임시 파일을 항상0o600·현재 프로세스 UID로 만들고, 기존 대상의 uid/gid를fchown으로 맞추지 않는다.라인 159-191 (
src/config/atomic-write.tsatomicWriteFile) - temp+rename 전후에 기존 inode 메타데이터(stat uid/gid/mode, ACL)를 캡처·복원하는 훅이 없다.src/integrations/writer.tsrestoreIntegration/restoreIntegrationCoordinated- restore가 성공 응답을 낸 뒤 DSH 쪽에서 읽기 실패가 나도 OpenCodex는 이미 commit한 상태라 운영자가 ‘복원은 됐는데 DSH가 죽었다’로 본다.src/integrations/registry.tsdsh- DSH 홈을 감지·기록하는 통합이지만, 쓰기 전 ‘대상 파일 owner == process.geteuid() 또는 쓰기 후 기존 owner가 읽기 가능’ 가드가 없다.메인테이너의 판단이 필요한 지점
CAP_CHOWN없이fchown이 실패하는 환경에서 어떻게 실패할지(거부 vs best-effort chmod only).IntegrationIO.writeText대상(외부 클라이언트 설정)에 공통으로 할지.너의 추천
열어 두고
platform버그로 처리하되, 첫 패치는 전역0600harden을 풀지 말고 통합 쓰기 직전에 기존 파일이 있고 owner≠geteuid()면 restore/enable을 거부하는 쪽이 안전하다. 그다음 옵션으로 temp inode에 기존 uid/gid를 맞출 수 있을 때만 rename을 허용하는 메타데이터 보존 경로를 실험하면 된다. 재현 픽스처는 Docker multi-UID bind mount 없이도chown된 fixture +seteuid흉내/별도 유저로 읽기 검증이면 충분하다. 중복 이슈는 검색상 이 번호가 유일하니 close-as-duplicate 대상은 없다.이 댓글은 grok-bot이 작성했습니다
lidge-jun commentedon Sep 10, 2026
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.