From 7ada93918272bbe0fe2ff408f4cb68684f467fc7 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 04:29:21 +0100 Subject: [PATCH] fix: refuse a docker/.env that exists but is not a regular file (release review) The first-run gate tested `! -f`, so a dangling symlink was silently replaced, and a directory got a 0600 temp file holding a working password, a "Wrote" message and exit 0, with setup repeating on every later run. Setup now runs only when nothing is there. A symlink to a real file stays an existing install. Co-Authored-By: Claude Opus 5.5 --- docker/Start-Codeman.sh | 9 ++++++- test/docker-start-setup.test.ts | 47 ++++++++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/docker/Start-Codeman.sh b/docker/Start-Codeman.sh index d30737cb..0109b69a 100644 --- a/docker/Start-Codeman.sh +++ b/docker/Start-Codeman.sh @@ -441,8 +441,15 @@ run_first_run_setup() { printf 'proxy); docker/README.md explains each setting.\n\n' } -if [[ ! -f "$env_file" ]]; then +if [[ ! -e "$env_file" && ! -L "$env_file" ]]; then run_first_run_setup +elif [[ ! -f "$env_file" ]]; then + # A dangling symlink or a directory is not "no .env yet": setup would replace + # the link, or leave its password file inside the directory and run again on + # every start. + printf 'Error: %s exists but is not a regular file.\n' "$env_file" >&2 + printf 'Move it out of the way or repoint it, then rerun this script.\n' >&2 + exit 1 elif [[ "$setup_only" == '1' ]]; then printf '%s already exists; the setup only runs when it does not, and never edits it.\n' "$env_file" fi diff --git a/test/docker-start-setup.test.ts b/test/docker-start-setup.test.ts index 05e13824..25d517eb 100644 --- a/test/docker-start-setup.test.ts +++ b/test/docker-start-setup.test.ts @@ -32,6 +32,7 @@ import { writeFileSync, mkdirSync, statSync, + lstatSync, existsSync, symlinkSync, readdirSync, @@ -118,6 +119,10 @@ interface Run { dir: string; /** What was in HOME when the run ended; the sandbox is gone by the time a test reads this. */ homeEntries: string[]; + /** What `docker/.env` is when the run ended, without following a symlink. */ + envKind: 'file' | 'directory' | 'symlink' | null; + /** What a `docker/.env` that is a directory holds. */ + envDirEntries: string[]; } /** @@ -130,6 +135,8 @@ function runStart( opts: { env?: Record; existingEnv?: string; + /** Makes `docker/.env` something other than a regular file. */ + envShape?: 'directory' | 'dangling-symlink' | 'symlink-to-file'; noDocker?: boolean; /** A real Unix socket to use as DOCKER_SOCKET (the start path checks `-S`). */ socket?: string; @@ -158,6 +165,12 @@ function runStart( // Hashed by the start path for the updater's fingerprint baseline. writeFileSync(join(dockerDir, 'server.Dockerfile'), readFileSync(join(ROOT, 'docker/server.Dockerfile'))); if (opts.existingEnv !== undefined) writeFileSync(join(dockerDir, '.env'), opts.existingEnv); + if (opts.envShape === 'directory') mkdirSync(join(dockerDir, '.env')); + if (opts.envShape === 'dangling-symlink') symlinkSync(join(dir, 'no-such-file'), join(dockerDir, '.env')); + if (opts.envShape === 'symlink-to-file') { + writeFileSync(join(dir, 'real.env'), 'CODEMAN_PASSWORD=mine-and-only-mine\n'); + symlinkSync(join(dir, 'real.env'), join(dockerDir, '.env')); + } const binDir = join(dir, 'bin'); mkdirSync(binDir); @@ -200,7 +213,15 @@ function runStart( input: opts.terminal, }); const envPath = join(dockerDir, '.env'); - const hasEnv = existsSync(envPath); + const envEntry = lstatSync(envPath, { throwIfNoEntry: false }); + const envKind = !envEntry + ? null + : envEntry.isSymbolicLink() + ? 'symlink' + : envEntry.isDirectory() + ? 'directory' + : 'file'; + const hasEnv = envKind === 'file' || (envKind === 'symlink' && existsSync(envPath)); return { status: res.status ?? 1, // A terminal turns every line end into CRLF. @@ -214,6 +235,8 @@ function runStart( home, dir, homeEntries: readdirSync(home), + envKind, + envDirEntries: envKind === 'directory' ? readdirSync(envPath) : [], }; } finally { rmSync(dir, { recursive: true, force: true }); @@ -357,6 +380,28 @@ describe('Start-Codeman.sh first run (no docker/.env yet)', () => { } ); + it('refuses a dangling docker/.env symlink instead of replacing the link', () => { + const r = runStart(['--setup-only'], { envShape: 'dangling-symlink' }); + expect(r.status).toBe(1); + expect(r.stderr).toMatch(/not a regular file/); + expect(r.envKind).toBe('symlink'); + }); + + it('refuses a docker/.env directory instead of writing a password file into it', () => { + const r = runStart(['--setup-only'], { envShape: 'directory' }); + expect(r.status).toBe(1); + expect(r.stderr).toMatch(/not a regular file/); + expect(r.envKind).toBe('directory'); + expect(r.envDirEntries).toEqual([]); + }); + + it('treats a docker/.env symlink to a real file as an existing install', () => { + const r = runStart(['--setup-only'], { envShape: 'symlink-to-file' }); + expect(r.status, r.stderr).toBe(0); + expect(r.stdout).toMatch(/already exists/); + expect(r.envKind).toBe('symlink'); + }); + it('never rewrites an existing docker/.env', () => { const existing = '# hand-written\nCODEMAN_PASSWORD=mine-and-only-mine\nCODEMAN_APPDATA_PATH=/x\n'; const r = runStart(['--setup-only'], { existingEnv: existing });