fix(cli-install): install npm CLIs to ~/.local when the npm global prefix is not writable

installEnv() only redirected NPM_CONFIG_PREFIX inside the Docker container
(CODEMAN_IN_CONTAINER=1). On a native install with a root-owned system node
(prefix /usr) `npm install -g @deepseek-ai/dsh` run as the server user died
with EACCES (exit 243), so DeepSeek, pi and other npm-based CLIs could not be
installed from Settings. Ask npm for its global prefix and, when the server
user cannot write it, redirect to $HOME/.local like the container path does.
An operator-set NPM_CONFIG_PREFIX and a writable prefix are left alone.
This commit is contained in:
Devvyn
2026-10-09 18:41:58 +08:00
parent 3a0cee6b90
commit 26487f0ec5
3 changed files with 87 additions and 7 deletions
@@ -0,0 +1,5 @@
---
'aicodeman': patch
---
Installing an npm-based CLI from Settings (DeepSeek's `dsh`, pi, ...) no longer fails with EACCES on a native install whose system node keeps its global prefix under `/usr`. When the npm global prefix is not writable by the Codeman user, the install now goes to `~/.local` (where Codeman already looks for CLIs), as the Docker deployment already did. A prefix you set yourself, or one that is writable, is left alone.
+49 -5
View File
@@ -29,7 +29,9 @@
* write path (`registry-writer.ts` mirrors `custom-model-hosts.ts`).
*/
import { spawn } from 'node:child_process';
import { execFileSync, spawn } from 'node:child_process';
import { accessSync, constants as fsConstants } from 'node:fs';
import { join } from 'node:path';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
import { getAuthUser, isAdmin, parseBody, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
@@ -207,14 +209,54 @@ const CLI_INSTALL_TIMEOUT_MS = 300_000;
*/
const installsInFlight = new Set<string>();
/**
* True when `npm install -g` can write to this process's npm global prefix, or when that cannot
* be determined (then nothing is redirected: a wrong guess would move installs somewhere the
* user did not choose). `npm config get prefix` is asked rather than guessed from `process.execPath`
* because a user `.npmrc` / `NPM_CONFIG_PREFIX` can point it anywhere.
*/
export function npmGlobalPrefixWritable(source: NodeJS.ProcessEnv): boolean {
try {
const prefix = execFileSync('npm', ['config', 'get', 'prefix'], {
env: source,
encoding: 'utf8',
timeout: 5_000,
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
if (!prefix) return true;
// npm creates lib/node_modules under the prefix; check that directory when it exists, else the prefix.
for (const dir of [join(prefix, 'lib', 'node_modules'), prefix]) {
try {
accessSync(dir, fsConstants.W_OK);
return true;
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return false;
}
}
return false;
} catch {
return true;
}
}
/**
* The server's environment minus every `CODEMAN_*` variable. An install script is third-party
* code, and those variables carry Codeman's own secrets and wiring (`CODEMAN_PASSWORD`, the
* data dir, the tmux socket), none of which an installer needs. Inside the Docker Compose
* container (`CODEMAN_IN_CONTAINER=1`) it also points `NPM_CONFIG_PREFIX` at `$HOME/.local`,
* so an `npm install -g` lands on the persistent home mount instead of the image.
* data dir, the tmux socket), none of which an installer needs.
*
* It also points `NPM_CONFIG_PREFIX` at `$HOME/.local` so an `npm install -g` lands somewhere the
* server user can write and Codeman's resolvers already search (`~/.local/bin`):
* - inside the Docker Compose container (`CODEMAN_IN_CONTAINER=1`), so installs survive an image
* update (the image's own prefix is image content);
* - on a native install whose npm global prefix is not writable by the server user (a system node
* under `/usr`, installed by root). Without this `npm install -g` died with EACCES (exit 243),
* e.g. DeepSeek's `npm install -g @deepseek-ai/dsh`. An explicit `NPM_CONFIG_PREFIX` the
* operator set is respected, and so is a prefix that is writable (nvm, `~/.npm-global`, ...).
*/
export function installEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
export function installEnv(
source: NodeJS.ProcessEnv = process.env,
prefixWritable: (env: NodeJS.ProcessEnv) => boolean = npmGlobalPrefixWritable
): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
for (const [key, value] of Object.entries(source)) {
if (!key.startsWith('CODEMAN_')) env[key] = value;
@@ -225,6 +267,8 @@ export function installEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.Proc
// list, so npm-based installs are redirected there. curl|bash installers already target HOME.
if (source.CODEMAN_IN_CONTAINER === '1' && source.HOME) {
env.NPM_CONFIG_PREFIX = `${source.HOME}/.local`;
} else if (process.platform !== 'win32' && source.HOME && !source.NPM_CONFIG_PREFIX && !prefixWritable(env)) {
env.NPM_CONFIG_PREFIX = `${source.HOME}/.local`;
}
return env;
}
+33 -2
View File
@@ -15,7 +15,12 @@ import { EventEmitter } from 'node:events';
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync, statSync } from 'node:fs';
import { dirname } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { installEnv, registerCliRegistryRoutes, type CliListItem } from '../../src/web/routes/cli-registry-routes.js';
import {
installEnv,
npmGlobalPrefixWritable,
registerCliRegistryRoutes,
type CliListItem,
} from '../../src/web/routes/cli-registry-routes.js';
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
import { CreateSessionSchema } from '../../src/web/schemas.js';
import { buildSpawnCommandFromRegistry } from '../../src/session-cli-registry-bridge.js';
@@ -711,7 +716,7 @@ describe('registry writes are serialized and never clobber a file the reader wou
} finally {
delete process.env.CODEMAN_TEST_SECRET;
}
expect(installEnv({ CODEMAN_PASSWORD: 'x', HOME: '/h' })).toEqual({ HOME: '/h' });
expect(installEnv({ CODEMAN_PASSWORD: 'x', HOME: '/h' }, () => true)).toEqual({ HOME: '/h' });
});
it('redirects npm installs to the persistent HOME inside the Compose container', () => {
@@ -719,6 +724,32 @@ describe('registry writes are serialized and never clobber a file the reader wou
installEnv({ CODEMAN_IN_CONTAINER: '1', HOME: '/home/codeman', NPM_CONFIG_PREFIX: '/opt/codeman-cli' })
).toEqual({ HOME: '/home/codeman', NPM_CONFIG_PREFIX: '/home/codeman/.local' });
});
it('redirects npm installs to ~/.local on a native install whose global prefix is not writable', () => {
// A system node under /usr: `npm install -g` as the server user dies with EACCES (exit 243).
expect(installEnv({ HOME: '/home/dev', CODEMAN_PASSWORD: 'x' }, () => false)).toEqual({
HOME: '/home/dev',
NPM_CONFIG_PREFIX: '/home/dev/.local',
});
});
it('leaves a writable, explicit or undeterminable npm prefix alone on a native install', () => {
expect(installEnv({ HOME: '/home/dev' }, () => true)).toEqual({ HOME: '/home/dev' });
// An operator-set prefix wins even if it is not writable: it is theirs to fix.
expect(installEnv({ HOME: '/home/dev', NPM_CONFIG_PREFIX: '/opt/npm' }, () => false)).toEqual({
HOME: '/home/dev',
NPM_CONFIG_PREFIX: '/opt/npm',
});
// No HOME means nowhere to redirect to.
expect(installEnv({ PATH: '/usr/bin' }, () => false)).toEqual({ PATH: '/usr/bin' });
});
it('npmGlobalPrefixWritable follows npm config and treats a probe failure as writable', () => {
// Real probe against this machine: must return a boolean and never throw.
expect(typeof npmGlobalPrefixWritable({ PATH: process.env.PATH })).toBe('boolean');
// npm not found on PATH: cannot tell, so do not redirect.
expect(npmGlobalPrefixWritable({ PATH: '/nonexistent' })).toBe(true);
});
});
/**