Skip to content

Commit 4cdd3d7

Browse files
author
CS
committed
feat(docker): run container as host user with bind-mounted workspace
1 parent a73ac19 commit 4cdd3d7

10 files changed

Lines changed: 716 additions & 4 deletions

File tree

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ DATABASE_PATH=./data/opencode.db
4242
# ============================================
4343
WORKSPACE_PATH=./workspace
4444

45+
# Optional - Docker: bind /workspace to a host directory instead of the named
46+
# volume, and run the container as your host user so the files stay usable from
47+
# the host without root. Set PUID/PGID to the output of `id -u` / `id -g`.
48+
# OCM_WORKSPACE_HOST_PATH=/absolute/path/to/opencode-workspace
49+
# PUID=1000
50+
# PGID=1000
51+
4552
# Optional - convenience vars for Docker bind mounts documented in docs/configuration/docker.md
4653
# OCM_REPOS_HOST_PATH=/Users/you/Development
4754
# OCM_OPENCODE_CONFIG_HOST_PATH=/Users/you/.config/opencode

Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ COPY package.json pnpm-workspace.yaml ./
106106
RUN mkdir -p /app/backend/node_modules/@opencode-manager && \
107107
ln -sfn /app/shared /app/backend/node_modules/@opencode-manager/shared
108108

109+
COPY scripts/lib/container-user.sh /usr/local/lib/ocm/container-user.sh
109110
COPY scripts/docker-entrypoint.sh /docker-entrypoint.sh
110111
RUN chmod +x /docker-entrypoint.sh
111112

backend/test/helpers/repo-root.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { existsSync } from 'fs'
2+
import { dirname, join, resolve } from 'path'
3+
4+
export function findRepoRoot(start: string): string {
5+
let dir = start
6+
while (!existsSync(join(dir, 'pnpm-workspace.yaml'))) {
7+
const parent = dirname(dir)
8+
if (parent === dir) throw new Error('repo root not found')
9+
dir = parent
10+
}
11+
return dir
12+
}
13+
14+
export const repoRoot = findRepoRoot(resolve(process.cwd()))
Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2+
import { spawnSync } from 'child_process'
3+
import { mkdirSync, writeFileSync, rmSync, chmodSync, existsSync, readFileSync } from 'fs'
4+
import { join } from 'path'
5+
import { tmpdir } from 'os'
6+
import { repoRoot } from '../helpers/repo-root'
7+
8+
const libPath = join(repoRoot, 'scripts/lib/container-user.sh')
9+
10+
let stubDir: string
11+
let logPath: string
12+
13+
const writeStub = (name: string, body: string) => {
14+
const file = join(stubDir, name)
15+
writeFileSync(file, `#!/bin/bash\n${body}\n`)
16+
chmodSync(file, 0o755)
17+
}
18+
19+
beforeEach(() => {
20+
stubDir = join(tmpdir(), `ocm-container-user-${Date.now()}-${Math.random().toString(36).slice(2)}`)
21+
mkdirSync(stubDir, { recursive: true })
22+
logPath = join(stubDir, 'calls.log')
23+
24+
writeStub('id', `
25+
case "$1" in
26+
-u) echo "${'${OCM_STUB_NODE_UID:-1000}'}" ;;
27+
-g) echo "${'${OCM_STUB_NODE_GID:-1000}'}" ;;
28+
*) exit 1 ;;
29+
esac`)
30+
31+
writeStub('getent', `
32+
if [ "$1" = "group" ] && [ "$2" = "${'${OCM_STUB_GID_KEY:-__none__}'}" ]; then
33+
echo "${'${OCM_STUB_GID_HOLDER}'}:x:$2:"
34+
exit 0
35+
fi
36+
if [ "$1" = "passwd" ] && [ "$2" = "${'${OCM_STUB_UID_KEY:-__none__}'}" ]; then
37+
echo "${'${OCM_STUB_UID_HOLDER}'}:x:$2:$2::/nonexistent:/bin/false"
38+
exit 0
39+
fi
40+
exit 2`)
41+
42+
writeStub('groupmod', `echo "groupmod $*" >> "$OCM_STUB_LOG"`)
43+
writeStub('usermod', `echo "usermod $*" >> "$OCM_STUB_LOG"`)
44+
writeStub('stat', `echo "${'${OCM_STUB_OWNER_UID:-0}'}"`)
45+
})
46+
47+
afterEach(() => {
48+
rmSync(stubDir, { recursive: true, force: true })
49+
})
50+
51+
const runScript = (snippet: string, env: Record<string, string> = {}) =>
52+
spawnSync('bash', ['-c', `set -e\nsource "${libPath}"\n${snippet}`], {
53+
encoding: 'utf-8',
54+
env: {
55+
...process.env,
56+
PUID: '',
57+
PGID: '',
58+
PATH: `${stubDir}:${process.env.PATH}`,
59+
OCM_STUB_LOG: logPath,
60+
...env,
61+
},
62+
})
63+
64+
const stubCalls = () => {
65+
if (!existsSync(logPath)) return []
66+
return readFileSync(logPath, 'utf-8').split('\n').filter(Boolean)
67+
}
68+
69+
describe('resolve_target_ids', () => {
70+
it('defaults PUID/PGID to 1000 when unset', () => {
71+
const res = runScript('unset PUID PGID; resolve_target_ids; echo "uid=$OCM_TARGET_UID gid=$OCM_TARGET_GID"')
72+
expect(res.status).toBe(0)
73+
expect(res.stdout).toContain('uid=1000 gid=1000')
74+
})
75+
76+
it('honors explicit PUID and PGID values', () => {
77+
const res = runScript('resolve_target_ids; echo "uid=$OCM_TARGET_UID gid=$OCM_TARGET_GID"', {
78+
PUID: '1001',
79+
PGID: '1002',
80+
})
81+
expect(res.status).toBe(0)
82+
expect(res.stdout).toContain('uid=1001 gid=1002')
83+
})
84+
85+
it('rejects non-numeric PUID with the offending value in stderr', () => {
86+
const res = runScript('resolve_target_ids', { PUID: 'abc' })
87+
expect(res.status).not.toBe(0)
88+
expect(res.stderr).toMatch(/PUID must be a numeric user id/)
89+
expect(res.stderr).toContain('abc')
90+
})
91+
92+
it('rejects non-numeric PGID', () => {
93+
const res = runScript('resolve_target_ids', { PGID: '1x0' })
94+
expect(res.status).not.toBe(0)
95+
expect(res.stderr).toMatch(/PGID must be a numeric group id/)
96+
})
97+
98+
it('falls back to 1000 when PUID is explicitly empty', () => {
99+
const res = runScript('resolve_target_ids; echo "uid=$OCM_TARGET_UID"', { PUID: '' })
100+
expect(res.status).toBe(0)
101+
expect(res.stdout).toContain('uid=1000')
102+
})
103+
})
104+
105+
describe('align_container_user', () => {
106+
it('is a no-op when ids already match', () => {
107+
const res = runScript(
108+
'align_container_user node; echo "uidChanged=$OCM_UID_CHANGED gidChanged=$OCM_GID_CHANGED"',
109+
{ OCM_STUB_NODE_UID: '1000', OCM_STUB_NODE_GID: '1000', PUID: '1000', PGID: '1000' },
110+
)
111+
expect(res.status).toBe(0)
112+
expect(stubCalls()).toEqual([])
113+
expect(res.stdout).toContain('uidChanged=0 gidChanged=0')
114+
})
115+
116+
it('aligns both ids and records the change, group before user', () => {
117+
const res = runScript(
118+
'align_container_user node; echo "uidChanged=$OCM_UID_CHANGED gidChanged=$OCM_GID_CHANGED"',
119+
{ OCM_STUB_NODE_UID: '1000', OCM_STUB_NODE_GID: '1000', PUID: '1001', PGID: '1002' },
120+
)
121+
expect(res.status).toBe(0)
122+
expect(stubCalls()).toEqual(['groupmod -g 1002 node', 'usermod -u 1001 node'])
123+
expect(res.stdout).toContain('uidChanged=1 gidChanged=1')
124+
})
125+
126+
it('aligns only the gid when the uid already matches', () => {
127+
const res = runScript(
128+
'align_container_user node; echo "uidChanged=$OCM_UID_CHANGED gidChanged=$OCM_GID_CHANGED"',
129+
{ OCM_STUB_NODE_UID: '1001', PUID: '1001', PGID: '1002' },
130+
)
131+
expect(res.status).toBe(0)
132+
expect(stubCalls()).toEqual(['groupmod -g 1002 node'])
133+
expect(res.stdout).toContain('uidChanged=0 gidChanged=1')
134+
})
135+
136+
it('aligns only the uid when the gid already matches', () => {
137+
const res = runScript(
138+
'align_container_user node; echo "uidChanged=$OCM_UID_CHANGED gidChanged=$OCM_GID_CHANGED"',
139+
{ OCM_STUB_NODE_GID: '1002', PUID: '1001', PGID: '1002' },
140+
)
141+
expect(res.status).toBe(0)
142+
expect(stubCalls()).toEqual(['usermod -u 1001 node'])
143+
expect(res.stdout).toContain('uidChanged=1 gidChanged=0')
144+
})
145+
146+
it('defaults the account name to node', () => {
147+
const res = runScript('align_container_user', { PUID: '1001' })
148+
expect(res.status).toBe(0)
149+
expect(stubCalls().some((c) => c.endsWith(' node'))).toBe(true)
150+
})
151+
152+
it('rejects malformed ids before touching accounts', () => {
153+
const res = runScript('align_container_user node', { PUID: 'abc' })
154+
expect(res.status).not.toBe(0)
155+
expect(stubCalls()).toEqual([])
156+
})
157+
158+
it('logs what it is doing', () => {
159+
const res = runScript('align_container_user node', { PUID: '1001', PGID: '1002' })
160+
expect(res.status).toBe(0)
161+
expect(res.stdout).toMatch(/Aligning node group to gid 1002/)
162+
expect(res.stdout).toMatch(/Aligning node user to uid 1001/)
163+
})
164+
165+
it('propagates groupmod failure without setting the gid change flag', () => {
166+
writeStub('groupmod', `echo "groupmod $*" >> "$OCM_STUB_LOG"\nexit 1`)
167+
const res = runScript(
168+
'align_container_user node || echo "uidChanged=$OCM_UID_CHANGED gidChanged=$OCM_GID_CHANGED"',
169+
{ OCM_STUB_NODE_UID: '1000', OCM_STUB_NODE_GID: '1000', PUID: '1001', PGID: '1002' },
170+
)
171+
expect(res.status).toBe(0)
172+
expect(stubCalls()).toEqual(['groupmod -g 1002 node'])
173+
expect(res.stdout).toContain('uidChanged=0 gidChanged=0')
174+
})
175+
176+
it('propagates usermod failure without setting the uid change flag', () => {
177+
writeStub('usermod', `echo "usermod $*" >> "$OCM_STUB_LOG"\nexit 1`)
178+
const res = runScript(
179+
'align_container_user node || echo "uidChanged=$OCM_UID_CHANGED gidChanged=$OCM_GID_CHANGED"',
180+
{ OCM_STUB_NODE_UID: '1000', OCM_STUB_NODE_GID: '1000', PUID: '1001', PGID: '1002' },
181+
)
182+
expect(res.status).toBe(0)
183+
expect(stubCalls()).toEqual(['groupmod -g 1002 node', 'usermod -u 1001 node'])
184+
expect(res.stdout).toContain('uidChanged=0 gidChanged=1')
185+
})
186+
})
187+
188+
describe('align_container_user id collisions', () => {
189+
it('aborts before mutating anything when the gid is held by another group', () => {
190+
const res = runScript('align_container_user node', {
191+
OCM_STUB_NODE_UID: '1000',
192+
OCM_STUB_NODE_GID: '1000',
193+
PUID: '1001',
194+
PGID: '100',
195+
OCM_STUB_GID_KEY: '100',
196+
OCM_STUB_GID_HOLDER: 'users',
197+
})
198+
expect(res.status).not.toBe(0)
199+
expect(res.stderr).toContain('PGID 100')
200+
expect(res.stderr).toContain('users')
201+
expect(res.stderr).toContain('id -g')
202+
expect(stubCalls()).toEqual([])
203+
})
204+
205+
it('aborts after a successful gid alignment when the uid is held by another user', () => {
206+
const res = runScript('align_container_user node', {
207+
OCM_STUB_NODE_UID: '1000',
208+
OCM_STUB_NODE_GID: '1000',
209+
PUID: '4',
210+
PGID: '1002',
211+
OCM_STUB_UID_KEY: '4',
212+
OCM_STUB_UID_HOLDER: 'sync',
213+
})
214+
expect(res.status).not.toBe(0)
215+
expect(res.stderr).toContain('PUID 4')
216+
expect(res.stderr).toContain('sync')
217+
expect(stubCalls()).toEqual(['groupmod -g 1002 node'])
218+
})
219+
220+
it('treats a gid already owned by the target account as a no-collision', () => {
221+
const res = runScript('align_container_user node', {
222+
OCM_STUB_NODE_GID: '1000',
223+
PGID: '1002',
224+
OCM_STUB_GID_KEY: '1002',
225+
OCM_STUB_GID_HOLDER: 'node',
226+
})
227+
expect(res.status).toBe(0)
228+
expect(stubCalls()).toContain('groupmod -g 1002 node')
229+
})
230+
231+
it('treats a uid already owned by the target account as a no-collision', () => {
232+
const res = runScript('align_container_user node', {
233+
OCM_STUB_NODE_UID: '1000',
234+
PUID: '1001',
235+
PGID: '1000',
236+
OCM_STUB_UID_KEY: '1001',
237+
OCM_STUB_UID_HOLDER: 'node',
238+
})
239+
expect(res.status).toBe(0)
240+
expect(stubCalls()).toEqual(['usermod -u 1001 node'])
241+
})
242+
243+
it('emits an actionable remediation hint in the collision message', () => {
244+
const gidCollision = runScript('align_container_user node', {
245+
OCM_STUB_NODE_UID: '1000',
246+
OCM_STUB_NODE_GID: '1000',
247+
PUID: '1001',
248+
PGID: '100',
249+
OCM_STUB_GID_KEY: '100',
250+
OCM_STUB_GID_HOLDER: 'users',
251+
})
252+
expect(gidCollision.status).not.toBe(0)
253+
expect(gidCollision.stderr).toMatch(/Pick a different PGID/)
254+
255+
const uidCollision = runScript('align_container_user node', {
256+
OCM_STUB_NODE_UID: '1000',
257+
OCM_STUB_NODE_GID: '1000',
258+
PUID: '4',
259+
PGID: '1002',
260+
OCM_STUB_UID_KEY: '4',
261+
OCM_STUB_UID_HOLDER: 'sync',
262+
})
263+
expect(uidCollision.status).not.toBe(0)
264+
expect(uidCollision.stderr).toMatch(/Pick a different PUID/)
265+
})
266+
})
267+
268+
describe('warn_if_workspace_owner_differs', () => {
269+
it('is silent when the path does not exist', () => {
270+
const res = runScript(`warn_if_workspace_owner_differs "${join(stubDir, 'does-not-exist')}" 1000`)
271+
expect(res.status).toBe(0)
272+
expect(res.stderr).toBe('')
273+
})
274+
275+
it('is silent when the directory is empty', () => {
276+
const ws = join(stubDir, 'ws')
277+
mkdirSync(ws, { recursive: true })
278+
const res = runScript(`warn_if_workspace_owner_differs "${ws}" 1000`, { OCM_STUB_OWNER_UID: '0' })
279+
expect(res.status).toBe(0)
280+
expect(res.stderr).toBe('')
281+
})
282+
283+
it('is silent when the directory is non-empty and the owner matches', () => {
284+
const ws = join(stubDir, 'ws')
285+
mkdirSync(ws, { recursive: true })
286+
writeFileSync(join(ws, 'repo.txt'), 'data')
287+
const res = runScript(`warn_if_workspace_owner_differs "${ws}" 1000`, { OCM_STUB_OWNER_UID: '1000' })
288+
expect(res.status).toBe(0)
289+
expect(res.stderr).toBe('')
290+
})
291+
292+
it('warns non-fatally when a non-empty directory has a mismatched owner', () => {
293+
const ws = join(stubDir, 'ws')
294+
mkdirSync(ws, { recursive: true })
295+
writeFileSync(join(ws, 'repo.txt'), 'data')
296+
const res = runScript(`warn_if_workspace_owner_differs "${ws}" 1000`, { OCM_STUB_OWNER_UID: '1001' })
297+
expect(res.status).toBe(0)
298+
expect(res.stderr).toContain('1001')
299+
expect(res.stderr).toContain('1000')
300+
expect(res.stderr).toContain('WARNING')
301+
expect(res.stderr).toContain(ws)
302+
expect(res.stderr).toMatch(/rewriting ownership/)
303+
})
304+
305+
it('names the id -u / id -g remediation in the warning', () => {
306+
const ws = join(stubDir, 'ws')
307+
mkdirSync(ws, { recursive: true })
308+
writeFileSync(join(ws, 'repo.txt'), 'data')
309+
const res = runScript(`warn_if_workspace_owner_differs "${ws}" 1000`, { OCM_STUB_OWNER_UID: '1001' })
310+
expect(res.status).toBe(0)
311+
expect(res.stderr).toMatch(/id -u/)
312+
expect(res.stderr).toMatch(/id -g/)
313+
})
314+
315+
it('stays silent and returns 0 when stat fails under set -e', () => {
316+
const ws = join(stubDir, 'ws')
317+
mkdirSync(ws, { recursive: true })
318+
writeFileSync(join(ws, 'repo.txt'), 'data')
319+
writeStub('stat', `exit 1`)
320+
const res = runScript(`warn_if_workspace_owner_differs "${ws}" 1000; echo "status=$?"`, {
321+
OCM_STUB_OWNER_UID: '1001',
322+
})
323+
expect(res.status).toBe(0)
324+
expect(res.stderr).toBe('')
325+
expect(res.stdout).toContain('status=0')
326+
})
327+
})

0 commit comments

Comments
 (0)