Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,20 @@ A package-manager timeout must not release this lock while npm descendants are s

Boot's `harper-application-lock.json` records an application configuration only after preparation fulfills. Recording at queue time would make a failed install look complete and suppress its retry on the next boot.

Custom `install_command` spawns receive `npm_config_ignore_scripts=true` unless
`install_allow_scripts` (or `install.allowInstallScripts` in root config) is true. The child-only
environment setting reaches npm nested in shell commands without changing the command's arguments;
other package managers must honor npm's configuration namespace for it to apply. An explicit opt-in
does not clear an inherited host restriction. Omitted policy emits a warning naming the opt-ins,
because existing custom commands can rely on lifecycle scripts, including `npm run` pre/post hooks.
This is best-effort enforcement for arbitrary commands: an explicit override in the command or a
package manager that ignores npm configuration can bypass it.

The deprecated `install_node_modules` operation defaults to scripts enabled for compatibility.
`installModules()` consumes Joi's converted `allowInstallScripts` value (also accepted as
`install_allow_scripts`), so a string `'false'` suppresses lifecycle scripts. Both spellings together
are rejected. Only camelCase `dryRun` controls dry-run invocation.

## Peer-side deploy_component payload read: retryable blob stalls and `Readable.from()` cancellation

`readPayloadBlobWithRetry` (`components/deploymentRecorder.ts`) wraps the peer's read of a replicated `hdb_deployment` row's `payload_blob` so a transient 503 `BlobReadError` (`BLOB_UNAVAILABLE_STATUS`, `resources/blob.ts`) — content bytes not arriving within `blobReadTimeout`, e.g. a parked blob send on the origin — retries instead of failing the whole deploy. Two non-obvious constraints shaped the design:
Expand Down
26 changes: 22 additions & 4 deletions components/Application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -888,8 +888,15 @@ export async function installApplication(application: Application) {
// If node_modules doesn't exist, we need to install dependencies
}

const allowInstallScripts = !!application.install?.allowInstallScripts;

// If custom install command is specified, run it
if (application.install?.command) {
if (application.install.allowInstallScripts === undefined) {
application.logger.warn(
`Application ${application.name} uses install_command without install_allow_scripts; package lifecycle scripts are disabled by default for npm and tools that honor npm_config_ignore_scripts, including npm run pre/post hooks. Set install_allow_scripts (or install.allowInstallScripts in root config) to true to opt in`
);
}
const [command, ...args] = application.install.command.split(' ');
const customOnLine = application.onInstallLine
? (stream: 'stdout' | 'stderr', line: string) => application.onInstallLine!(command, stream, line)
Expand All @@ -901,7 +908,9 @@ export async function installApplication(application: Application) {
application.dirPath,
application.install?.timeout,
customOnLine,
application.npmUserconfigPath
application.npmUserconfigPath,
undefined,
!allowInstallScripts
);
// if it succeeds, return
if (code === 0) {
Expand Down Expand Up @@ -1695,7 +1704,8 @@ export async function nonInteractiveSpawn(
timeoutMs: number = DEFAULT_COMMAND_TIMEOUT_MS,
onLine?: (stream: 'stdout' | 'stderr', line: string) => void,
npmUserconfigPath?: string,
gitCredentialEnv?: Record<string, string>
gitCredentialEnv?: Record<string, string>,
ignoreNpmScripts = false
): Promise<{ stdout: string; stderr: string; code: number }> {
const gitSSH = await materializeGitSSH();
try {
Expand All @@ -1708,7 +1718,8 @@ export async function nonInteractiveSpawn(
onLine,
npmUserconfigPath,
gitSSH?.command,
gitCredentialEnv
gitCredentialEnv,
ignoreNpmScripts
);
} finally {
await gitSSH?.cleanup();
Expand All @@ -1724,7 +1735,8 @@ function spawnWithEnv(
onLine: ((stream: 'stdout' | 'stderr', line: string) => void) | undefined,
npmUserconfigPath: string | undefined,
gitSSHCommand: string | undefined,
gitCredentialEnv: Record<string, string> | undefined
gitCredentialEnv: Record<string, string> | undefined,
ignoreNpmScripts: boolean
): Promise<{ stdout: string; stderr: string; code: number }> {
return new Promise((resolve, reject) => {
logger
Expand Down Expand Up @@ -1760,6 +1772,12 @@ function spawnWithEnv(
}
env.npm_config_userconfig = npmUserconfigPath;
}
if (ignoreNpmScripts) {
for (const key of Object.keys(env)) {
if (key.toLowerCase() === 'npm_config_ignore_scripts') delete env[key];
}
env.npm_config_ignore_scripts = 'true';
}

if (process.platform === 'win32' && command === 'npm') {
command = 'npm.cmd';
Expand Down
169 changes: 169 additions & 0 deletions unitTests/components/applicationInstall.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
'use strict';

const assert = require('node:assert');
const { access, mkdir, mkdtemp, readFile, rm, writeFile } = require('node:fs/promises');
const { tmpdir } = require('node:os');
const { join } = require('node:path');

const testUtils = require('../testUtils.js');
testUtils.preTestPrep();

const { Application, installApplication } = require('#src/components/Application');

async function createApplication(root, name, packageJSON, install) {
const directory = join(root, name);
await mkdir(directory, { recursive: true });
await writeFile(join(directory, 'package.json'), JSON.stringify({ name, version: '1.0.0', ...packageJSON }));
const application = new Application({ name, install });
application.dirPath = directory;
return application;
}

async function createLifecycleDependency(root, name, markerPath) {
const dependencyName = `${name}-dependency`;
const directory = join(root, dependencyName);
await mkdir(directory);
await writeFile(
join(directory, 'install.cjs'),
`require('node:fs').writeFileSync(${JSON.stringify(markerPath)}, 'ran');\n`
);
await writeFile(
join(directory, 'package.json'),
JSON.stringify({ name: dependencyName, version: '1.0.0', scripts: { install: 'node install.cjs' } })
);
return { dependencyName, directory };
}

async function configureInstallCapture(application, root, name) {
const capturePath = join(root, `${name}-args.json`);
const captureScript = join(root, `${name}-capture.cjs`);
await writeFile(
captureScript,
`require('node:fs').writeFileSync(${JSON.stringify(capturePath)}, JSON.stringify(process.argv.slice(2)));\n`
);
application.packageManagerPrefix = `"${process.execPath}" "${captureScript}"`;
return capturePath;
}

describe('automatic application installation', () => {
beforeEach(async function () {
this.root = await mkdtemp(join(tmpdir(), 'application-install-'));
});

afterEach(async function () {
await rm(this.root, { recursive: true, force: true });
});

it('suppresses lifecycle scripts for the default and declared npm paths', async function () {
const defaultApplication = await createApplication(this.root, 'default-npm', {
dependencies: { runtime: '1.0.0' },
});
const defaultCapture = await configureInstallCapture(defaultApplication, this.root, 'default-npm');
await installApplication(defaultApplication);
assert.deepEqual(JSON.parse(await readFile(defaultCapture, 'utf8')), [
'npm',
'install',
'--force',
'--ignore-scripts',
]);

const declaredApplication = await createApplication(this.root, 'declared-npm', {
dependencies: { runtime: '1.0.0' },
devEngines: { packageManager: { name: 'npm' } },
});
const declaredCapture = await configureInstallCapture(declaredApplication, this.root, 'declared-npm');
await installApplication(declaredApplication);
assert.deepEqual(JSON.parse(await readFile(declaredCapture, 'utf8')), ['npm', 'install', '--ignore-scripts']);
});

it('suppresses lifecycle scripts for a declared non-npm package manager', async function () {
const application = await createApplication(this.root, 'declared-pnpm', {
devEngines: { packageManager: { name: 'pnpm' } },
});
const capturePath = await configureInstallCapture(application, this.root, 'declared-pnpm');

await installApplication(application);

assert.deepEqual(JSON.parse(await readFile(capturePath, 'utf8')), ['pnpm', 'install', '--ignore-scripts']);
});

it('omits script suppression when the default npm path opts in', async function () {
const application = await createApplication(this.root, 'allowed-lifecycle', {}, { allowInstallScripts: true });
const capturePath = await configureInstallCapture(application, this.root, 'allowed-lifecycle');

await installApplication(application);

assert.deepEqual(JSON.parse(await readFile(capturePath, 'utf8')), ['npm', 'install', '--force']);
assert.equal(application.installationIsOpaque, true);
});

it('preserves the custom install command arguments', async function () {
const application = await createApplication(
this.root,
'custom-command',
{},
{ command: 'node custom-install.cjs' }
);
const automaticCapture = await configureInstallCapture(application, this.root, 'custom-command');
const customMarker = join(application.dirPath, 'custom-command-ran');
await writeFile(
join(application.dirPath, 'custom-install.cjs'),
`require('node:fs').writeFileSync(${JSON.stringify(customMarker)}, JSON.stringify(process.argv.slice(2)));\n`
);

await installApplication(application);

assert.deepEqual(JSON.parse(await readFile(customMarker, 'utf8')), []);
await assert.rejects(access(automaticCapture), (error) => error.code === 'ENOENT');
assert.equal(application.installationIsOpaque, true);
});

it('applies the lifecycle-script policy to custom install commands', async function () {
this.timeout(60_000);
const inheritedPolicies = Object.entries(process.env).filter(
([key]) => key.toLowerCase() === 'npm_config_ignore_scripts'
);
for (const [key] of inheritedPolicies) delete process.env[key];
process.env.npm_config_ignore_scripts = 'false';
try {
for (const allowInstallScripts of [undefined, false, true]) {
const name =
allowInstallScripts === undefined
? 'custom-scripts-default'
: allowInstallScripts
? 'custom-scripts-allowed'
: 'custom-scripts-blocked';
const markerPath = join(this.root, `${name}-marker`);
const dependency = await createLifecycleDependency(this.root, name, markerPath);
const install = { command: 'npm install --no-audit --no-fund && node -e "process.exit(0)"' };
if (allowInstallScripts !== undefined) install.allowInstallScripts = allowInstallScripts;
const application = await createApplication(
this.root,
name,
{ dependencies: { [dependency.dependencyName]: `file:${dependency.directory}` } },
install
);
const warnings = [];
application.logger.warn = (message) => warnings.push(message);

await installApplication(application);

await access(join(application.dirPath, 'node_modules', dependency.dependencyName, 'package.json'));
assert.equal(
await access(markerPath).then(
() => true,
() => false
),
allowInstallScripts === true
);
assert.equal(warnings.length, allowInstallScripts === undefined ? 1 : 0);
if (warnings.length) assert.match(warnings[0], /install\.allowInstallScripts/);
}
} finally {
for (const key of Object.keys(process.env)) {
if (key.toLowerCase() === 'npm_config_ignore_scripts') delete process.env[key];
}
Object.assign(process.env, Object.fromEntries(inheritedPolicies));
}
});
});
Loading
Loading