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 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 04:29:21 +01:00
1 parent 10a53579c6
commit 7ada939182
2 files changed
+54 -2

No files matched your search

+8 -1
View File
@@ -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
+46 -1
View File
@@ -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<string, string>;
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 });