diff --git a/.env.example b/.env.example index 848020baa..dc60c65d5 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,13 @@ DATABASE_PATH=./data/opencode.db # ============================================ WORKSPACE_PATH=./workspace +# Optional - Docker: bind /workspace to a host directory instead of the named +# volume, and run the container as your host user so the files stay usable from +# the host without root. Set PUID/PGID to the output of `id -u` / `id -g`. +# OCM_WORKSPACE_HOST_PATH=/absolute/path/to/opencode-workspace +# PUID=1000 +# PGID=1000 + # Optional - convenience vars for Docker bind mounts documented in docs/configuration/docker.md # OCM_REPOS_HOST_PATH=/Users/you/Development # OCM_OPENCODE_CONFIG_HOST_PATH=/Users/you/.config/opencode diff --git a/Dockerfile b/Dockerfile index 7924a0645..53591c8f0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -106,6 +106,7 @@ COPY package.json pnpm-workspace.yaml ./ RUN mkdir -p /app/backend/node_modules/@opencode-manager && \ ln -sfn /app/shared /app/backend/node_modules/@opencode-manager/shared +COPY scripts/lib/container-user.sh /usr/local/lib/ocm/container-user.sh COPY scripts/docker-entrypoint.sh /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh diff --git a/backend/test/helpers/repo-root.ts b/backend/test/helpers/repo-root.ts new file mode 100644 index 000000000..88169fc10 --- /dev/null +++ b/backend/test/helpers/repo-root.ts @@ -0,0 +1,14 @@ +import { existsSync } from 'fs' +import { dirname, join, resolve } from 'path' + +export function findRepoRoot(start: string): string { + let dir = start + while (!existsSync(join(dir, 'pnpm-workspace.yaml'))) { + const parent = dirname(dir) + if (parent === dir) throw new Error('repo root not found') + dir = parent + } + return dir +} + +export const repoRoot = findRepoRoot(resolve(process.cwd())) diff --git a/backend/test/scripts/container-user.test.ts b/backend/test/scripts/container-user.test.ts new file mode 100644 index 000000000..18f77504e --- /dev/null +++ b/backend/test/scripts/container-user.test.ts @@ -0,0 +1,352 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { spawnSync } from 'child_process' +import { mkdirSync, writeFileSync, rmSync, chmodSync, existsSync, readFileSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { repoRoot } from '../helpers/repo-root' + +const libPath = join(repoRoot, 'scripts/lib/container-user.sh') + +let stubDir: string +let logPath: string + +const writeStub = (name: string, body: string) => { + const file = join(stubDir, name) + writeFileSync(file, `#!/bin/bash\n${body}\n`) + chmodSync(file, 0o755) +} + +beforeEach(() => { + stubDir = join(tmpdir(), `ocm-container-user-${Date.now()}-${Math.random().toString(36).slice(2)}`) + mkdirSync(stubDir, { recursive: true }) + logPath = join(stubDir, 'calls.log') + + writeStub('id', ` +case "$1" in + -u) echo "${'${OCM_STUB_NODE_UID:-1000}'}" ;; + -g) echo "${'${OCM_STUB_NODE_GID:-1000}'}" ;; + *) exit 1 ;; +esac`) + + writeStub('getent', ` +if [ "$1" = "group" ] && [ "$2" = "${'${OCM_STUB_GID_KEY:-__none__}'}" ]; then + echo "${'${OCM_STUB_GID_HOLDER}'}:x:$2:" + exit 0 +fi +if [ "$1" = "passwd" ] && [ "$2" = "${'${OCM_STUB_UID_KEY:-__none__}'}" ]; then + echo "${'${OCM_STUB_UID_HOLDER}'}:x:$2:$2::/nonexistent:/bin/false" + exit 0 +fi +exit 2`) + + writeStub('groupmod', `echo "groupmod $*" >> "$OCM_STUB_LOG"`) + writeStub('usermod', `echo "usermod $*" >> "$OCM_STUB_LOG"`) + writeStub('stat', `case "$2" in + '%u %g') echo "${'${OCM_STUB_OWNER_UID:-0}'} ${'${OCM_STUB_OWNER_GID:-0}'}" ;; + *) echo "${'${OCM_STUB_OWNER_UID:-0}'}" ;; +esac`) +}) + +afterEach(() => { + rmSync(stubDir, { recursive: true, force: true }) +}) + +const runScript = (snippet: string, env: Record = {}) => + spawnSync('bash', ['-c', 'set -e\nsource "$1"\neval "$2"', '--', libPath, snippet], { + encoding: 'utf-8', + env: { + ...process.env, + PUID: '', + PGID: '', + PATH: `${stubDir}:${process.env.PATH}`, + OCM_STUB_LOG: logPath, + ...env, + }, + }) + +const stubCalls = () => { + if (!existsSync(logPath)) return [] + return readFileSync(logPath, 'utf-8').split('\n').filter(Boolean) +} + +describe('resolve_target_ids', () => { + it('defaults PUID/PGID to 1000 when unset', () => { + const res = runScript('unset PUID PGID; resolve_target_ids; echo "uid=$OCM_TARGET_UID gid=$OCM_TARGET_GID"') + expect(res.status).toBe(0) + expect(res.stdout).toContain('uid=1000 gid=1000') + }) + + it('honors explicit PUID and PGID values', () => { + const res = runScript('resolve_target_ids; echo "uid=$OCM_TARGET_UID gid=$OCM_TARGET_GID"', { + PUID: '1001', + PGID: '1002', + }) + expect(res.status).toBe(0) + expect(res.stdout).toContain('uid=1001 gid=1002') + }) + + it('rejects non-numeric PUID with the offending value in stderr', () => { + const res = runScript('resolve_target_ids', { PUID: 'abc' }) + expect(res.status).not.toBe(0) + expect(res.stderr).toMatch(/PUID must be a numeric user id/) + expect(res.stderr).toContain('abc') + }) + + it('rejects non-numeric PGID', () => { + const res = runScript('resolve_target_ids', { PGID: '1x0' }) + expect(res.status).not.toBe(0) + expect(res.stderr).toMatch(/PGID must be a numeric group id/) + }) + + it('falls back to 1000 when PUID is explicitly empty', () => { + const res = runScript('resolve_target_ids; echo "uid=$OCM_TARGET_UID"', { PUID: '' }) + expect(res.status).toBe(0) + expect(res.stdout).toContain('uid=1000') + }) +}) + +describe('align_container_user', () => { + it('is a no-op when ids already match', () => { + const res = runScript( + 'align_container_user node; echo "uidChanged=$OCM_UID_CHANGED gidChanged=$OCM_GID_CHANGED"', + { OCM_STUB_NODE_UID: '1000', OCM_STUB_NODE_GID: '1000', PUID: '1000', PGID: '1000' }, + ) + expect(res.status).toBe(0) + expect(stubCalls()).toEqual([]) + expect(res.stdout).toContain('uidChanged=0 gidChanged=0') + }) + + it('aligns both ids and records the change, group before user', () => { + const res = runScript( + 'align_container_user node; echo "uidChanged=$OCM_UID_CHANGED gidChanged=$OCM_GID_CHANGED"', + { OCM_STUB_NODE_UID: '1000', OCM_STUB_NODE_GID: '1000', PUID: '1001', PGID: '1002' }, + ) + expect(res.status).toBe(0) + expect(stubCalls()).toEqual(['groupmod -g 1002 node', 'usermod -u 1001 node']) + expect(res.stdout).toContain('uidChanged=1 gidChanged=1') + }) + + it('aligns only the gid when the uid already matches', () => { + const res = runScript( + 'align_container_user node; echo "uidChanged=$OCM_UID_CHANGED gidChanged=$OCM_GID_CHANGED"', + { OCM_STUB_NODE_UID: '1001', PUID: '1001', PGID: '1002' }, + ) + expect(res.status).toBe(0) + expect(stubCalls()).toEqual(['groupmod -g 1002 node']) + expect(res.stdout).toContain('uidChanged=0 gidChanged=1') + }) + + it('aligns only the uid when the gid already matches', () => { + const res = runScript( + 'align_container_user node; echo "uidChanged=$OCM_UID_CHANGED gidChanged=$OCM_GID_CHANGED"', + { OCM_STUB_NODE_GID: '1002', PUID: '1001', PGID: '1002' }, + ) + expect(res.status).toBe(0) + expect(stubCalls()).toEqual(['usermod -u 1001 node']) + expect(res.stdout).toContain('uidChanged=1 gidChanged=0') + }) + + it('defaults the account name to node', () => { + const res = runScript('align_container_user', { PUID: '1001' }) + expect(res.status).toBe(0) + expect(stubCalls().some((c) => c.endsWith(' node'))).toBe(true) + }) + + it('rejects malformed ids before touching accounts', () => { + const res = runScript('align_container_user node', { PUID: 'abc' }) + expect(res.status).not.toBe(0) + expect(stubCalls()).toEqual([]) + }) + + it('logs what it is doing', () => { + const res = runScript('align_container_user node', { PUID: '1001', PGID: '1002' }) + expect(res.status).toBe(0) + expect(res.stdout).toMatch(/Aligning node group to gid 1002/) + expect(res.stdout).toMatch(/Aligning node user to uid 1001/) + }) + + it('propagates groupmod failure without setting the gid change flag', () => { + writeStub('groupmod', `echo "groupmod $*" >> "$OCM_STUB_LOG"\nexit 1`) + const res = runScript( + 'align_container_user node || echo "uidChanged=$OCM_UID_CHANGED gidChanged=$OCM_GID_CHANGED"', + { OCM_STUB_NODE_UID: '1000', OCM_STUB_NODE_GID: '1000', PUID: '1001', PGID: '1002' }, + ) + expect(res.status).toBe(0) + expect(stubCalls()).toEqual(['groupmod -g 1002 node']) + expect(res.stdout).toContain('uidChanged=0 gidChanged=0') + }) + + it('propagates usermod failure without setting the uid change flag', () => { + writeStub('usermod', `echo "usermod $*" >> "$OCM_STUB_LOG"\nexit 1`) + const res = runScript( + 'align_container_user node || echo "uidChanged=$OCM_UID_CHANGED gidChanged=$OCM_GID_CHANGED"', + { OCM_STUB_NODE_UID: '1000', OCM_STUB_NODE_GID: '1000', PUID: '1001', PGID: '1002' }, + ) + expect(res.status).toBe(0) + expect(stubCalls()).toEqual(['groupmod -g 1002 node', 'usermod -u 1001 node']) + expect(res.stdout).toContain('uidChanged=0 gidChanged=1') + }) +}) + +describe('align_container_user id collisions', () => { + it('aborts before mutating anything when the gid is held by another group', () => { + const res = runScript('align_container_user node', { + OCM_STUB_NODE_UID: '1000', + OCM_STUB_NODE_GID: '1000', + PUID: '1001', + PGID: '100', + OCM_STUB_GID_KEY: '100', + OCM_STUB_GID_HOLDER: 'users', + }) + expect(res.status).not.toBe(0) + expect(res.stderr).toContain('PGID 100') + expect(res.stderr).toContain('users') + expect(res.stderr).toContain('id -g') + expect(stubCalls()).toEqual([]) + }) + + it('aborts after a successful gid alignment when the uid is held by another user', () => { + const res = runScript('align_container_user node', { + OCM_STUB_NODE_UID: '1000', + OCM_STUB_NODE_GID: '1000', + PUID: '4', + PGID: '1002', + OCM_STUB_UID_KEY: '4', + OCM_STUB_UID_HOLDER: 'sync', + }) + expect(res.status).not.toBe(0) + expect(res.stderr).toContain('PUID 4') + expect(res.stderr).toContain('sync') + expect(stubCalls()).toEqual(['groupmod -g 1002 node']) + }) + + it('treats a gid already owned by the target account as a no-collision', () => { + const res = runScript('align_container_user node', { + OCM_STUB_NODE_GID: '1000', + PGID: '1002', + OCM_STUB_GID_KEY: '1002', + OCM_STUB_GID_HOLDER: 'node', + }) + expect(res.status).toBe(0) + expect(stubCalls()).toContain('groupmod -g 1002 node') + }) + + it('treats a uid already owned by the target account as a no-collision', () => { + const res = runScript('align_container_user node', { + OCM_STUB_NODE_UID: '1000', + PUID: '1001', + PGID: '1000', + OCM_STUB_UID_KEY: '1001', + OCM_STUB_UID_HOLDER: 'node', + }) + expect(res.status).toBe(0) + expect(stubCalls()).toEqual(['usermod -u 1001 node']) + }) + + it('emits an actionable remediation hint in the collision message', () => { + const gidCollision = runScript('align_container_user node', { + OCM_STUB_NODE_UID: '1000', + OCM_STUB_NODE_GID: '1000', + PUID: '1001', + PGID: '100', + OCM_STUB_GID_KEY: '100', + OCM_STUB_GID_HOLDER: 'users', + }) + expect(gidCollision.status).not.toBe(0) + expect(gidCollision.stderr).toMatch(/Pick a different PGID/) + + const uidCollision = runScript('align_container_user node', { + OCM_STUB_NODE_UID: '1000', + OCM_STUB_NODE_GID: '1000', + PUID: '4', + PGID: '1002', + OCM_STUB_UID_KEY: '4', + OCM_STUB_UID_HOLDER: 'sync', + }) + expect(uidCollision.status).not.toBe(0) + expect(uidCollision.stderr).toMatch(/Pick a different PUID/) + }) +}) + +describe('warn_if_workspace_owner_differs', () => { + it('is silent when the path does not exist', () => { + const res = runScript(`warn_if_workspace_owner_differs "${join(stubDir, 'does-not-exist')}" 1000 1000`) + expect(res.status).toBe(0) + expect(res.stderr).toBe('') + }) + + it('is silent when the directory is empty', () => { + const ws = join(stubDir, 'ws') + mkdirSync(ws, { recursive: true }) + const res = runScript(`warn_if_workspace_owner_differs "${ws}" 1000 1000`, { OCM_STUB_OWNER_UID: '0' }) + expect(res.status).toBe(0) + expect(res.stderr).toBe('') + }) + + it('is silent when the directory is non-empty and both uid and gid match', () => { + const ws = join(stubDir, 'ws') + mkdirSync(ws, { recursive: true }) + writeFileSync(join(ws, 'repo.txt'), 'data') + const res = runScript(`warn_if_workspace_owner_differs "${ws}" 1000 1000`, { + OCM_STUB_OWNER_UID: '1000', + OCM_STUB_OWNER_GID: '1000', + }) + expect(res.status).toBe(0) + expect(res.stderr).toBe('') + }) + + it('warns non-fatally when a non-empty directory has a mismatched uid', () => { + const ws = join(stubDir, 'ws') + mkdirSync(ws, { recursive: true }) + writeFileSync(join(ws, 'repo.txt'), 'data') + const res = runScript(`warn_if_workspace_owner_differs "${ws}" 1000 1000`, { + OCM_STUB_OWNER_UID: '1001', + OCM_STUB_OWNER_GID: '1000', + }) + expect(res.status).toBe(0) + expect(res.stderr).toContain('1001') + expect(res.stderr).toContain('1000') + expect(res.stderr).toContain('WARNING') + expect(res.stderr).toContain(ws) + expect(res.stderr).toMatch(/rewriting ownership/) + }) + + it('warns non-fatally when a non-empty directory has a mismatched gid', () => { + const ws = join(stubDir, 'ws') + mkdirSync(ws, { recursive: true }) + writeFileSync(join(ws, 'repo.txt'), 'data') + const res = runScript(`warn_if_workspace_owner_differs "${ws}" 1000 1000`, { + OCM_STUB_OWNER_UID: '1000', + OCM_STUB_OWNER_GID: '1001', + }) + expect(res.status).toBe(0) + expect(res.stderr).toContain('1001') + expect(res.stderr).toContain('1000') + expect(res.stderr).toContain('WARNING') + expect(res.stderr).toContain(ws) + expect(res.stderr).toMatch(/rewriting ownership/) + }) + + it('names the id -u / id -g remediation in the warning', () => { + const ws = join(stubDir, 'ws') + mkdirSync(ws, { recursive: true }) + writeFileSync(join(ws, 'repo.txt'), 'data') + const res = runScript(`warn_if_workspace_owner_differs "${ws}" 1000 1000`, { OCM_STUB_OWNER_UID: '1001' }) + expect(res.status).toBe(0) + expect(res.stderr).toMatch(/id -u/) + expect(res.stderr).toMatch(/id -g/) + }) + + it('stays silent and returns 0 when stat fails under set -e', () => { + const ws = join(stubDir, 'ws') + mkdirSync(ws, { recursive: true }) + writeFileSync(join(ws, 'repo.txt'), 'data') + writeStub('stat', `exit 1`) + const res = runScript(`warn_if_workspace_owner_differs "${ws}" 1000 1000; echo "status=$?"`, { + OCM_STUB_OWNER_UID: '1001', + }) + expect(res.status).toBe(0) + expect(res.stderr).toBe('') + expect(res.stdout).toContain('status=0') + }) +}) diff --git a/backend/test/scripts/docker-config.test.ts b/backend/test/scripts/docker-config.test.ts new file mode 100644 index 000000000..242b51b7f --- /dev/null +++ b/backend/test/scripts/docker-config.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, rmSync, readdirSync, statSync, existsSync } from 'fs' +import { execSync } from 'child_process' +import { join } from 'path' +import { tmpdir } from 'os' +import { repoRoot } from '../helpers/repo-root' + +const entrypointPath = join(repoRoot, 'scripts/docker-entrypoint.sh') +const dockerfilePath = join(repoRoot, 'Dockerfile') +const composePath = join(repoRoot, 'docker-compose.yml') +const envExamplePath = join(repoRoot, '.env.example') +const dockerDocsPath = join(repoRoot, 'docs/configuration/docker.md') +const installationDocsPath = join(repoRoot, 'docs/getting-started/installation.md') + +const read = (path: string) => readFileSync(path, 'utf-8') + +const SOURCE_PATH_RE = /^\s*(?:source|\.)\s+(\/\S+)/m +const COPY_LIB_RE = /^COPY\s+scripts\/lib\/container-user\.sh\s+(\S+)/m + +describe('entrypoint library wiring', () => { + it('sources the path the Dockerfile installs', () => { + const entrypoint = read(entrypointPath) + const dockerfile = read(dockerfilePath) + + const sourceMatch = entrypoint.match(SOURCE_PATH_RE) + const copyMatch = dockerfile.match(COPY_LIB_RE) + + expect(sourceMatch, 'entrypoint must source the container-user library').not.toBeNull() + expect(copyMatch, 'Dockerfile must COPY the container-user library').not.toBeNull() + expect(sourceMatch![1]).toBe(copyMatch![1]) + }) + + it('aborts explicitly on alignment failure', () => { + const entrypoint = read(entrypointPath) + expect(entrypoint).toMatch(/if ! align_container_user node; then/) + const blockStart = entrypoint.indexOf('align_container_user node; then') + const blockEnd = entrypoint.indexOf('fi', blockStart) + const block = entrypoint.slice(blockStart, blockEnd) + expect(block).toMatch(/exit 1/) + }) + + it('warns before chowning the workspace', () => { + const entrypoint = read(entrypointPath) + const warnIndex = entrypoint.indexOf('warn_if_workspace_owner_differs /workspace') + const workspaceChownMatch = entrypoint.match(/chown -R node:node [^\n]*\/workspace/) + expect(workspaceChownMatch, 'entrypoint must chown the workspace').not.toBeNull() + const chownIndex = entrypoint.indexOf(workspaceChownMatch![0]) + expect(warnIndex).toBeGreaterThan(-1) + expect(chownIndex).toBeGreaterThan(-1) + expect(warnIndex).toBeLessThan(chownIndex) + }) + + it('realigns /app only when ids changed', () => { + const entrypoint = read(entrypointPath) + + expect(entrypoint).toContain('OCM_UID_CHANGED') + expect(entrypoint).toContain('OCM_GID_CHANGED') + + const conditionalBlockMatch = entrypoint.match( + /if \[ "\$OCM_UID_CHANGED" = "1" \] \|\| \[ "\$OCM_GID_CHANGED" = "1" \]; then[\s\S]*?chown -R node:node \/app\nfi/, + ) + expect(conditionalBlockMatch, 'conditional /app realign block must exist').not.toBeNull() + + const workspaceChownMatch = entrypoint.match(/chown -R node:node [^\n]*\/workspace/) + expect(workspaceChownMatch, 'pre-existing unconditional workspace chown must remain').not.toBeNull() + const conditionalChownIndex = entrypoint.indexOf('chown -R node:node /app\n', conditionalBlockMatch!.index!) + const workspaceChownIndex = entrypoint.indexOf(workspaceChownMatch![0]) + expect(conditionalChownIndex).toBeGreaterThan(workspaceChownIndex) + + expect(entrypoint).toContain('mkdir -p /app/data /workspace /home/node/.cache /home/node/.opencode') + }) + + it('does not mark the library executable in the image', () => { + const dockerfile = read(dockerfilePath) + expect(dockerfile).not.toMatch(/chmod \+x \/usr\/local\/lib\/ocm\/container-user\.sh/) + }) +}) + +describe('workspace ownership configuration', () => { + it('exposes PUID and PGID environment defaults in docker-compose.yml', () => { + const compose = read(composePath) + expect(compose).toContain('- PUID=${PUID:-1000}') + expect(compose).toContain('- PGID=${PGID:-1000}') + }) + + it('overrides the workspace mount source in docker-compose.yml', () => { + const compose = read(composePath) + expect(compose).toContain('${OCM_WORKSPACE_HOST_PATH:-opencode-workspace}:/workspace') + }) + + it('does not declare the bare service workspace mount in docker-compose.yml', () => { + const compose = read(composePath) + expect(compose).not.toContain('- opencode-workspace:/workspace') + }) + + it('keeps the opencode-workspace named volume declared at top level', () => { + const compose = read(composePath) + expect(compose).toMatch(/^volumes:\n(?:.*\n)*?\s+opencode-workspace:/m) + }) + + it('keeps the docker docs compose snippet in sync with docker-compose.yml', () => { + const compose = read(composePath) + const docs = read(dockerDocsPath) + + const fenceStart = docs.indexOf('```yaml\nservices:') + expect(fenceStart, 'docs must contain a fenced compose yaml block').toBeGreaterThan(-1) + const contentStart = fenceStart + '```yaml\n'.length + const fenceEnd = docs.indexOf('\n```\n', contentStart) + expect(fenceEnd, 'docs compose yaml block must be closed').toBeGreaterThan(-1) + const docsBlock = docs.slice(contentStart, fenceEnd) + + const normalize = (s: string) => s.replace(/\s+$/, '').split('\n').map((l) => l.replace(/\s+$/, '')).join('\n') + expect(normalize(docsBlock)).toBe(normalize(compose)) + }) + + it('documents the Accessing Repositories From the Host subsection', () => { + const docs = read(dockerDocsPath) + expect(docs).toContain('#### Accessing Repositories From the Host') + }) + + it('documents the new workspace ownership env vars in .env.example', () => { + const envExample = read(envExamplePath) + expect(envExample).toContain('OCM_WORKSPACE_HOST_PATH') + expect(envExample).toContain('# PUID=1000') + expect(envExample).toContain('# PGID=1000') + }) + + it('links the host-access subsection from the installation guide', () => { + const installation = read(installationDocsPath) + expect(installation).toContain('configuration/docker.md#accessing-repositories-from-the-host') + }) + + it('documents the migration empty-destination guard and quoted host path', () => { + const docs = read(dockerDocsPath) + expect(docs).toContain('if [ -n "$(ls -A "")" ]; then') + expect(docs).toContain('mkdir -p ""') + expect(docs).toContain('-v "":/to') + expect(docs).toContain('chown -R "$(id -u):$(id -g)" ""') + }) +}) + +describe('named-volume migration recipe', () => { + const runMigrationShell = (src: string, dst: string) => { + const scriptDir = mkdtempSync(join(tmpdir(), 'migrate-script-')) + const scriptPath = join(scriptDir, 'migrate.sh') + writeFileSync( + scriptPath, + `set -eu +src=${JSON.stringify(src)} +dst=${JSON.stringify(dst)} +mkdir -p "$dst" +if [ -n "$(ls -A "$dst")" ]; then + echo "destination '$dst' is not empty; aborting migration" >&2 + exit 1 +fi +cp -a "$src/." "$dst/" +chown -R "$(id -u):$(id -g)" "$dst" +`, + ) + try { + return execSync(`bash ${JSON.stringify(scriptPath)}`, { stdio: 'pipe' }) + } finally { + rmSync(scriptDir, { recursive: true, force: true }) + } + } + + it('aborts without modification when the destination is non-empty', () => { + const dst = mkdtempSync(join(tmpdir(), 'migrate-dst-')) + writeFileSync(join(dst, 'existing.txt'), 'keep me') + const src = mkdtempSync(join(tmpdir(), 'migrate-src-')) + mkdirSync(join(src, 'repo')) + writeFileSync(join(src, 'repo', 'file.txt'), 'volume data') + + let threw = false + try { + runMigrationShell(src, dst) + } catch { + threw = true + } + + expect(threw, 'recipe must abort when destination is non-empty').toBe(true) + expect(readdirSync(dst)).toEqual(['existing.txt']) + expect(existsSync(join(dst, 'repo'))).toBe(false) + rmSync(dst, { recursive: true, force: true }) + rmSync(src, { recursive: true, force: true }) + }) + + it('copies volume contents to the root of an empty destination with spaces in the path', () => { + const root = mkdtempSync(join(tmpdir(), 'migrate-root-')) + const dst = join(root, 'My Repositories') + const src = mkdtempSync(join(tmpdir(), 'migrate-src-')) + mkdirSync(join(src, 'repo')) + writeFileSync(join(src, 'repo', 'file.txt'), 'volume data') + + runMigrationShell(src, dst) + + expect(statSync(join(dst, 'repo', 'file.txt')).isFile()).toBe(true) + expect(readdirSync(dst)).toEqual(['repo']) + rmSync(root, { recursive: true, force: true }) + rmSync(src, { recursive: true, force: true }) + }) +}) diff --git a/docker-compose.yml b/docker-compose.yml index 00a023348..1902cef09 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,8 @@ services: - "5103:5103" environment: - NODE_ENV=${NODE_ENV:-production} + - PUID=${PUID:-1000} + - PGID=${PGID:-1000} - HOST=0.0.0.0 - PORT=5003 - OPENCODE_SERVER_PORT=5551 @@ -46,7 +48,7 @@ services: - VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY:-} - VAPID_SUBJECT=${VAPID_SUBJECT:-} volumes: - - opencode-workspace:/workspace + - ${OCM_WORKSPACE_HOST_PATH:-opencode-workspace}:/workspace - opencode-data:/app/data restart: unless-stopped healthcheck: diff --git a/docs/configuration/docker.md b/docs/configuration/docker.md index 3c8bcf3fb..0121e0c89 100644 --- a/docs/configuration/docker.md +++ b/docs/configuration/docker.md @@ -35,6 +35,8 @@ services: build: context: . dockerfile: Dockerfile + args: + TOOLS_CACHEBUST: ${TOOLS_CACHEBUST:-0} container_name: opencode-manager ports: - "5003:5003" @@ -44,6 +46,8 @@ services: - "5103:5103" environment: - NODE_ENV=${NODE_ENV:-production} + - PUID=${PUID:-1000} + - PGID=${PGID:-1000} - HOST=0.0.0.0 - PORT=5003 - OPENCODE_SERVER_PORT=5551 @@ -76,7 +80,7 @@ services: - VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY:-} - VAPID_SUBJECT=${VAPID_SUBJECT:-} volumes: - - opencode-workspace:/workspace + - ${OCM_WORKSPACE_HOST_PATH:-opencode-workspace}:/workspace - opencode-data:/app/data restart: unless-stopped healthcheck: @@ -127,6 +131,7 @@ The container entrypoint (`scripts/docker-entrypoint.sh`) automatically: 2. **Verifies OpenCode** is installed (installed at build time, fallback install if missing) 3. **Upgrades OpenCode** if below minimum version (1.0.137) 4. **Validates AUTH_SECRET** is set (required for startup) +5. **Aligns the `node` account** to `PUID`/`PGID` (default `1000`) before chowning the workspace, the `/app/data` directory, and the `node` home directory. If `PUID`/`PGID` are already used by another account in the image, startup aborts with an explicit error. Group alignment runs first, so a free `PGID` combined with an occupied `PUID` mutates `/etc/group` before the UID collision is detected and aborts startup; realign to the original ids or pick a free pair before retrying. ## Port Configuration @@ -192,10 +197,54 @@ Repository storage: ```yaml volumes: - - opencode-workspace:/workspace + - ${OCM_WORKSPACE_HOST_PATH:-opencode-workspace}:/workspace ``` -All cloned repositories are stored here. Uses a named volume for data persistence across container recreations. +All cloned repositories are stored here. Defaults to a named volume for data persistence across container recreations. + +#### Accessing Repositories From the Host + +To work in the cloned repositories from the host instead of through `docker exec`, bind `/workspace` to a host directory and run the container as your host user so the files stay usable from the host without root. Add to `.env`: + +```bash +# Output of `id -u` and `id -g` on the host +PUID=1000 +PGID=1000 +OCM_WORKSPACE_HOST_PATH=/absolute/path/to/opencode-workspace +``` + +`OCM_WORKSPACE_HOST_PATH` is consumed verbatim as the compose mount source, so an absolute or `./`-relative path becomes a bind mount while the default value (`opencode-workspace`) stays a named volume. `PUID`/`PGID` are applied before the workspace is chowned, so agent-created files are host-owned and both sides share one uid — which also avoids git's "dubious ownership" warning. + +To migrate an existing named volume to a bind mount without losing data, copy the volume contents into the host directory with a one-off container — this works whether `` already exists or not, and avoids depending on Docker's internal storage path (`/var/lib/docker/...` differs on Docker Desktop, rootless Docker, and custom data roots). The destination must be empty; if it is not, the recipe aborts without copying anything so existing files are never overwritten: + +```bash +docker compose stop +mkdir -p "" +# Abort if the destination is non-empty so we never overwrite existing files. +if [ -n "$(ls -A "")" ]; then + echo "destination '' is not empty; aborting migration" >&2 + exit 1 +fi +# `` is the Compose project name, usually the directory containing docker-compose.yml. +docker run --rm \ + -v _opencode-workspace:/from:ro \ + -v "":/to alpine sh -c 'cp -a /from/. /to/' +sudo chown -R "$(id -u):$(id -g)" "" +docker compose up -d +# After confirming /workspace contains your repositories, remove the old volume: +docker volume rm _opencode-workspace +``` + +The quoted `""` keeps paths containing spaces (for example `/Users/name/My Repositories`) intact across `mkdir`, the Docker `-v` argument, and `chown`. With the empty-destination guard in place, `cp -a /from/. /to/` copies the volume's *contents* (not its `_data` directory) directly into the bind-mount root, so repositories land directly beneath the mounted `/workspace`. The named volume is left in place until you confirm the migration succeeded. + +!!! warning "Set PUID before switching to a bind mount" + A wrong `PUID` makes startup chown the whole host directory. The entrypoint prints a warning naming both uids, but it does not block the chown. + +!!! warning "Concurrent git access" + Editing a repository from the host while an agent works in the same repository or worktree can collide on `index.lock` and branch state. + +!!! note "/app keeps the build-time uid" + `/app` and its `node_modules` are chowned to uid 1000 at build time. When `PUID` differs, the entrypoint re-chowns `/app` on startup, which costs one extra recursive walk per container start. ### Data diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 16e04cef7..94ff56a80 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -55,6 +55,7 @@ docker exec -it opencode-manager sh | `opencode-workspace` | `/workspace` | Repository storage | | `opencode-data` | `/app/data` | Database and config | + ## Local Development For contributors who want to develop locally instead of using Docker. diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index e8f613b1c..e54ff7777 100644 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -5,6 +5,8 @@ export HOME=/home/node export BUN_INSTALL="$HOME/.bun" export PATH="$BUN_INSTALL/bin:$HOME/.opencode/bin:/usr/local/bin:$PATH" +source /usr/local/lib/ocm/container-user.sh + install_opencode() { echo "Installing OpenCode latest..." curl -fsSL "https://github.com/anomalyco/opencode/releases/latest/download/opencode-linux-$(uname -m | sed 's/x86_64/x64/; s/aarch64/arm64/').tar.gz" \ @@ -86,7 +88,18 @@ if [ -z "$AUTH_SECRET" ]; then exit 1 fi +if ! align_container_user node; then + exit 1 +fi + +warn_if_workspace_owner_differs /workspace "$OCM_TARGET_UID" "$OCM_TARGET_GID" + mkdir -p /app/data /workspace /home/node/.cache /home/node/.opencode chown -R node:node /app/data /workspace /home/node +if [ "$OCM_UID_CHANGED" = "1" ] || [ "$OCM_GID_CHANGED" = "1" ]; then + echo "Realigning /app ownership after id change" + chown -R node:node /app +fi + exec runuser -u node -- "$@" diff --git a/scripts/lib/container-user.sh b/scripts/lib/container-user.sh new file mode 100644 index 000000000..1acf62936 --- /dev/null +++ b/scripts/lib/container-user.sh @@ -0,0 +1,98 @@ +#!/bin/bash + +OCM_TARGET_UID="" +OCM_TARGET_GID="" +OCM_UID_CHANGED=0 +OCM_GID_CHANGED=0 + +resolve_target_ids() { + OCM_TARGET_UID="${PUID:-1000}" + OCM_TARGET_GID="${PGID:-1000}" + + case "$OCM_TARGET_UID" in + ''|*[!0-9]*) + echo "PUID must be a numeric user id, got '$OCM_TARGET_UID'" >&2 + return 1 + ;; + esac + + case "$OCM_TARGET_GID" in + ''|*[!0-9]*) + echo "PGID must be a numeric group id, got '$OCM_TARGET_GID'" >&2 + return 1 + ;; + esac +} + +account_holding_gid() { + getent group "$1" 2>/dev/null | cut -d: -f1 || true +} + +account_holding_uid() { + getent passwd "$1" 2>/dev/null | cut -d: -f1 || true +} + +align_group_id() { + local account="$1" target_gid="$2" current_gid holder + current_gid="$(id -g "$account")" + + if [ "$current_gid" = "$target_gid" ]; then + return 0 + fi + + holder="$(account_holding_gid "$target_gid")" + if [ -n "$holder" ] && [ "$holder" != "$account" ]; then + echo "PGID $target_gid is already used by group '$holder' in this image" >&2 + echo "Pick a different PGID (see 'id -g' on the host) or chgrp the host workspace to a free group id" >&2 + return 1 + fi + + echo "Aligning $account group to gid $target_gid" + groupmod -g "$target_gid" "$account" || return 1 + OCM_GID_CHANGED=1 +} + +align_user_id() { + local account="$1" target_uid="$2" current_uid holder + current_uid="$(id -u "$account")" + + if [ "$current_uid" = "$target_uid" ]; then + return 0 + fi + + holder="$(account_holding_uid "$target_uid")" + if [ -n "$holder" ] && [ "$holder" != "$account" ]; then + echo "PUID $target_uid is already used by user '$holder' in this image" >&2 + echo "Pick a different PUID (see 'id -u' on the host) or chown the host workspace to a free user id" >&2 + return 1 + fi + + echo "Aligning $account user to uid $target_uid" + usermod -u "$target_uid" "$account" || return 1 + OCM_UID_CHANGED=1 +} + +align_container_user() { + local account="${1:-node}" + + resolve_target_ids || return 1 + align_group_id "$account" "$OCM_TARGET_GID" || return 1 + align_user_id "$account" "$OCM_TARGET_UID" || return 1 +} + +warn_if_workspace_owner_differs() { + local path="$1" target_uid="$2" target_gid="$3" owner current_uid current_gid + + [ -d "$path" ] || return 0 + [ -n "$(ls -A "$path" 2>/dev/null)" ] || return 0 + + owner="$(stat -c '%u %g' "$path" 2>/dev/null)" || return 0 + current_uid="${owner%% *}" + current_gid="${owner##* }" + + if [ "$current_uid" != "$target_uid" ] || [ "$current_gid" != "$target_gid" ]; then + echo "WARNING: $path is owned by uid $current_uid/gid $current_gid but the container will run as uid $target_uid/gid $target_gid" >&2 + echo "WARNING: startup is about to chown $path to uid $target_uid/gid $target_gid, rewriting ownership of existing files" >&2 + echo "WARNING: stop the container now and set PUID/PGID from 'id -u' and 'id -g' if that is not intended" >&2 + fi +}