From 541dc75cfe57d8a4a214c8eea8c49923cccde74b Mon Sep 17 00:00:00 2001 From: Joyee Cheung Date: Sun, 17 Aug 2025 22:45:19 +0200 Subject: [PATCH] sea: implement execArgvExtension This implements the execArgvExtension configuration field for SEA, which takes one of three string values to specify whether and how execution arguments can be extended for the SEA at run time: * `"none"`: No extension is allowed. Only the arguments specified in `execArgv` will be used, and the `NODE_OPTIONS` environment variable will be ignored. * `"env"`: _(Default)_ The `NODE_OPTIONS` environment variable can extend the execution arguments. This is the default behavior to maintain backward compatibility. * `"cli"`: The executable can be launched with `--node-options="--flag1 --flag2"`, and those flags will be parsed as execution arguments for Node.js instead of being passed to the user script. This allows using arguments that are not supported by the `NODE_OPTIONS` environment variable. --- doc/api/single-executable-applications.md | 37 +++++++++ src/node.cc | 12 ++- src/node_sea.cc | 79 +++++++++++++++++-- src/node_sea.h | 10 ++- test/fixtures/sea-exec-argv-extension-cli.js | 14 ++++ test/fixtures/sea-exec-argv-extension-env.js | 19 +++++ test/fixtures/sea-exec-argv-extension-none.js | 14 ++++ ...ble-application-exec-argv-extension-cli.js | 63 +++++++++++++++ ...ble-application-exec-argv-extension-env.js | 68 ++++++++++++++++ ...le-application-exec-argv-extension-none.js | 63 +++++++++++++++ 10 files changed, 372 insertions(+), 7 deletions(-) create mode 100644 test/fixtures/sea-exec-argv-extension-cli.js create mode 100644 test/fixtures/sea-exec-argv-extension-env.js create mode 100644 test/fixtures/sea-exec-argv-extension-none.js create mode 100644 test/sequential/test-single-executable-application-exec-argv-extension-cli.js create mode 100644 test/sequential/test-single-executable-application-exec-argv-extension-env.js create mode 100644 test/sequential/test-single-executable-application-exec-argv-extension-none.js diff --git a/doc/api/single-executable-applications.md b/doc/api/single-executable-applications.md index 5e12e985b6567c..41e4bd7383ef7f 100644 --- a/doc/api/single-executable-applications.md +++ b/doc/api/single-executable-applications.md @@ -180,6 +180,7 @@ The configuration currently reads the following top-level fields: "useSnapshot": false, // Default: false "useCodeCache": true, // Default: false "execArgv": ["--no-warnings", "--max-old-space-size=4096"], // Optional + "execArgvExtension": "env", // Default: "env", options: "none", "env", "cli" "assets": { // Optional "a.dat": "/path/to/a.dat", "b.txt": "/path/to/b.txt" @@ -314,6 +315,42 @@ similar to what would happen if the application is started with: node --no-warnings --max-old-space-size=2048 /path/to/bundled/script.js user-arg1 user-arg2 ``` +### Execution argument extension + +The `execArgvExtension` field controls how additional execution arguments can be +provided beyond those specified in the `execArgv` field. It accepts one of three string values: + +* `"none"`: No extension is allowed. Only the arguments specified in `execArgv` will be used, + and the `NODE_OPTIONS` environment variable will be ignored. +* `"env"`: _(Default)_ The `NODE_OPTIONS` environment variable can extend the execution arguments. + This is the default behavior to maintain backward compatibility. +* `"cli"`: The executable can be launched with `--node-options="--flag1 --flag2"`, and those flags + will be parsed as execution arguments for Node.js instead of being passed to the user script. + This allows using arguments that are not supported by the `NODE_OPTIONS` environment variable. + +For example, with `"execArgvExtension": "cli"`: + +```json +{ + "main": "/path/to/bundled/script.js", + "output": "/path/to/write/the/generated/blob.blob", + "execArgv": ["--no-warnings"], + "execArgvExtension": "cli" +} +``` + +The executable can be launched as: + +```console +./my-sea --node-options="--trace-exit" user-arg1 user-arg2 +``` + +This would be equivalent to running: + +```console +node --no-warnings --trace-exit /path/to/bundled/script.js user-arg1 user-arg2 +``` + ## In the injected main script ### Single-executable application API diff --git a/src/node.cc b/src/node.cc index d6f9922a5b1562..fed1417f5f4dee 100644 --- a/src/node.cc +++ b/src/node.cc @@ -940,7 +940,17 @@ static ExitCode InitializeNodeWithArgsInternal( } #if !defined(NODE_WITHOUT_NODE_OPTIONS) - if (!(flags & ProcessInitializationFlags::kDisableNodeOptionsEnv)) { + bool should_parse_node_options = + !(flags & ProcessInitializationFlags::kDisableNodeOptionsEnv); +#ifndef DISABLE_SINGLE_EXECUTABLE_APPLICATION + if (sea::IsSingleExecutable()) { + sea::SeaResource sea_resource = sea::FindSingleExecutableResource(); + if (sea_resource.exec_argv_extension != sea::SeaExecArgvExtension::kEnv) { + should_parse_node_options = false; + } + } +#endif + if (should_parse_node_options) { // NODE_OPTIONS environment variable is preferred over the file one. if (credentials::SafeGetenv("NODE_OPTIONS", &node_options) || !node_options.empty()) { diff --git a/src/node_sea.cc b/src/node_sea.cc index bcc49c149e2374..49071304262f10 100644 --- a/src/node_sea.cc +++ b/src/node_sea.cc @@ -7,6 +7,7 @@ #include "node_errors.h" #include "node_external_reference.h" #include "node_internals.h" +#include "node_options.h" #include "node_snapshot_builder.h" #include "node_union_bytes.h" #include "node_v8_platform-inl.h" @@ -86,6 +87,11 @@ size_t SeaSerializer::Write(const SeaResource& sea) { uint32_t flags = static_cast(sea.flags); Debug("Write SEA flags %x\n", flags); written_total += WriteArithmetic(flags); + + Debug("Write SEA resource exec argv extension %u\n", + static_cast(sea.exec_argv_extension)); + written_total += + WriteArithmetic(static_cast(sea.exec_argv_extension)); DCHECK_EQ(written_total, SeaResource::kHeaderSize); Debug("Write SEA code path %p, size=%zu\n", @@ -158,6 +164,11 @@ SeaResource SeaDeserializer::Read() { CHECK_EQ(magic, kMagic); SeaFlags flags(static_cast(ReadArithmetic())); Debug("Read SEA flags %x\n", static_cast(flags)); + + uint8_t extension_value = ReadArithmetic(); + SeaExecArgvExtension exec_argv_extension = + static_cast(extension_value); + Debug("Read SEA resource exec argv extension %u\n", extension_value); CHECK_EQ(read_total, SeaResource::kHeaderSize); std::string_view code_path = @@ -212,7 +223,13 @@ SeaResource SeaDeserializer::Read() { exec_argv.emplace_back(arg); } } - return {flags, code_path, code, code_cache, assets, exec_argv}; + return {flags, + exec_argv_extension, + code_path, + code, + code_cache, + assets, + exec_argv}; } std::string_view FindSingleExecutableBlob() { @@ -297,26 +314,55 @@ std::tuple FixupArgsForSEA(int argc, char** argv) { if (IsSingleExecutable()) { static std::vector new_argv; static std::vector exec_argv_storage; + static std::vector cli_extension_args; SeaResource sea_resource = FindSingleExecutableResource(); new_argv.clear(); exec_argv_storage.clear(); + cli_extension_args.clear(); + + // Handle CLI extension mode for --node-options + if (sea_resource.exec_argv_extension == SeaExecArgvExtension::kCli) { + // Extract --node-options and filter argv + for (int i = 1; i < argc; ++i) { + if (strncmp(argv[i], "--node-options=", 15) == 0) { + std::string node_options = argv[i] + 15; + std::vector errors; + cli_extension_args = ParseNodeOptionsEnvVar(node_options, &errors); + // Remove this argument by shifting the rest + for (int j = i; j < argc - 1; ++j) { + argv[j] = argv[j + 1]; + } + argc--; + i--; // Adjust index since we removed an element + } + } + } - // Reserve space for argv[0], exec argv, original argv, and nullptr - new_argv.reserve(argc + sea_resource.exec_argv.size() + 2); + // Reserve space for argv[0], exec argv, cli extension args, original argv, + // and nullptr + new_argv.reserve(argc + sea_resource.exec_argv.size() + + cli_extension_args.size() + 2); new_argv.emplace_back(argv[0]); // Insert exec argv from SEA config if (!sea_resource.exec_argv.empty()) { - exec_argv_storage.reserve(sea_resource.exec_argv.size()); + exec_argv_storage.reserve(sea_resource.exec_argv.size() + + cli_extension_args.size()); for (const auto& arg : sea_resource.exec_argv) { exec_argv_storage.emplace_back(arg); new_argv.emplace_back(exec_argv_storage.back().data()); } } - // Add actual run time arguments. + // Insert CLI extension args + for (const auto& arg : cli_extension_args) { + exec_argv_storage.emplace_back(arg); + new_argv.emplace_back(exec_argv_storage.back().data()); + } + + // Add actual run time arguments new_argv.insert(new_argv.end(), argv, argv + argc); new_argv.emplace_back(nullptr); argc = new_argv.size() - 1; @@ -332,6 +378,7 @@ struct SeaConfig { std::string main_path; std::string output_path; SeaFlags flags = SeaFlags::kDefault; + SeaExecArgvExtension exec_argv_extension = SeaExecArgvExtension::kEnv; std::unordered_map assets; std::vector exec_argv; }; @@ -475,6 +522,27 @@ std::optional ParseSingleExecutableConfig( result.flags |= SeaFlags::kIncludeExecArgv; result.exec_argv = std::move(exec_argv); } + } else if (key == "execArgvExtension") { + std::string_view extension_str; + if (field.value().get_string().get(extension_str)) { + FPrintF(stderr, + "\"execArgvExtension\" field of %s is not a string\n", + config_path); + return std::nullopt; + } + if (extension_str == "none") { + result.exec_argv_extension = SeaExecArgvExtension::kNone; + } else if (extension_str == "env") { + result.exec_argv_extension = SeaExecArgvExtension::kEnv; + } else if (extension_str == "cli") { + result.exec_argv_extension = SeaExecArgvExtension::kCli; + } else { + FPrintF(stderr, + "\"execArgvExtension\" field of %s must be one of " + "\"none\", \"env\", or \"cli\"\n", + config_path); + return std::nullopt; + } } } @@ -674,6 +742,7 @@ ExitCode GenerateSingleExecutableBlob( } SeaResource sea{ config.flags, + config.exec_argv_extension, config.main_path, builds_snapshot_from_main ? std::string_view{snapshot_blob.data(), snapshot_blob.size()} diff --git a/src/node_sea.h b/src/node_sea.h index 5ba41064304fdf..686e283fd6441b 100644 --- a/src/node_sea.h +++ b/src/node_sea.h @@ -31,8 +31,15 @@ enum class SeaFlags : uint32_t { kIncludeExecArgv = 1 << 4, }; +enum class SeaExecArgvExtension : uint8_t { + kNone = 0, + kEnv = 1, + kCli = 2, +}; + struct SeaResource { SeaFlags flags = SeaFlags::kDefault; + SeaExecArgvExtension exec_argv_extension = SeaExecArgvExtension::kEnv; std::string_view code_path; std::string_view main_code_or_snapshot; std::optional code_cache; @@ -42,7 +49,8 @@ struct SeaResource { bool use_snapshot() const; bool use_code_cache() const; - static constexpr size_t kHeaderSize = sizeof(kMagic) + sizeof(SeaFlags); + static constexpr size_t kHeaderSize = + sizeof(kMagic) + sizeof(SeaFlags) + sizeof(SeaExecArgvExtension); }; bool IsSingleExecutable(); diff --git a/test/fixtures/sea-exec-argv-extension-cli.js b/test/fixtures/sea-exec-argv-extension-cli.js new file mode 100644 index 00000000000000..e9585483fcc21d --- /dev/null +++ b/test/fixtures/sea-exec-argv-extension-cli.js @@ -0,0 +1,14 @@ +const assert = require('assert'); + +console.log('process.argv:', JSON.stringify(process.argv)); +console.log('process.execArgv:', JSON.stringify(process.execArgv)); + +// Should have execArgv from SEA config + CLI --node-options +assert.deepStrictEqual(process.execArgv, ['--no-warnings', '--max-old-space-size=1024']); + +assert.deepStrictEqual(process.argv.slice(2), [ + 'user-arg1', + 'user-arg2' +]); + +console.log('execArgvExtension cli test passed'); diff --git a/test/fixtures/sea-exec-argv-extension-env.js b/test/fixtures/sea-exec-argv-extension-env.js new file mode 100644 index 00000000000000..1d706dfe7cfe11 --- /dev/null +++ b/test/fixtures/sea-exec-argv-extension-env.js @@ -0,0 +1,19 @@ +const assert = require('assert'); + +process.emitWarning('This warning should not be shown in the output', 'TestWarning'); + +console.log('process.argv:', JSON.stringify(process.argv)); +console.log('process.execArgv:', JSON.stringify(process.execArgv)); + +// Should have execArgv from SEA config. +// Note that flags from NODE_OPTIONS are not included in process.execArgv no matter it's +// an SEA or not, but we can test whether it works by checking that the warning emitted +// above was silenced. +assert.deepStrictEqual(process.execArgv, ['--no-warnings']); + +assert.deepStrictEqual(process.argv.slice(2), [ + 'user-arg1', + 'user-arg2' +]); + +console.log('execArgvExtension env test passed'); diff --git a/test/fixtures/sea-exec-argv-extension-none.js b/test/fixtures/sea-exec-argv-extension-none.js new file mode 100644 index 00000000000000..c089b065677091 --- /dev/null +++ b/test/fixtures/sea-exec-argv-extension-none.js @@ -0,0 +1,14 @@ +const assert = require('assert'); + +console.log('process.argv:', JSON.stringify(process.argv)); +console.log('process.execArgv:', JSON.stringify(process.execArgv)); + +// Should only have execArgv from SEA config, no NODE_OPTIONS +assert.deepStrictEqual(process.execArgv, ['--no-warnings']); + +assert.deepStrictEqual(process.argv.slice(2), [ + 'user-arg1', + 'user-arg2' +]); + +console.log('execArgvExtension none test passed'); diff --git a/test/sequential/test-single-executable-application-exec-argv-extension-cli.js b/test/sequential/test-single-executable-application-exec-argv-extension-cli.js new file mode 100644 index 00000000000000..81ff05d53ce7a4 --- /dev/null +++ b/test/sequential/test-single-executable-application-exec-argv-extension-cli.js @@ -0,0 +1,63 @@ +'use strict'; + +require('../common'); + +const { + generateSEA, + skipIfSingleExecutableIsNotSupported, +} = require('../common/sea'); + +skipIfSingleExecutableIsNotSupported(); + +// This tests the execArgvExtension "cli" mode in single executable applications. + +const fixtures = require('../common/fixtures'); +const tmpdir = require('../common/tmpdir'); +const { copyFileSync, writeFileSync, existsSync } = require('fs'); +const { spawnSyncAndAssert, spawnSyncAndExitWithoutError } = require('../common/child_process'); +const { join } = require('path'); +const assert = require('assert'); + +const configFile = tmpdir.resolve('sea-config.json'); +const seaPrepBlob = tmpdir.resolve('sea-prep.blob'); +const outputFile = tmpdir.resolve(process.platform === 'win32' ? 'sea.exe' : 'sea'); + +tmpdir.refresh(); + +// Copy test fixture to working directory +copyFileSync(fixtures.path('sea-exec-argv-extension-cli.js'), tmpdir.resolve('sea.js')); + +writeFileSync(configFile, ` +{ + "main": "sea.js", + "output": "sea-prep.blob", + "disableExperimentalSEAWarning": true, + "execArgv": ["--no-warnings"], + "execArgvExtension": "cli" +} +`); + +spawnSyncAndExitWithoutError( + process.execPath, + ['--experimental-sea-config', 'sea-config.json'], + { cwd: tmpdir.path }); + +assert(existsSync(seaPrepBlob)); + +generateSEA(outputFile, process.execPath, seaPrepBlob); + +// Test that --node-options works with execArgvExtension: "cli" +spawnSyncAndAssert( + outputFile, + ['--node-options=--max-old-space-size=1024', 'user-arg1', 'user-arg2'], + { + env: { + ...process.env, + NODE_OPTIONS: '--max-old-space-size=2048', // Should be ignored + COMMON_DIRECTORY: join(__dirname, '..', 'common'), + NODE_DEBUG_NATIVE: 'SEA', + } + }, + { + stdout: /execArgvExtension cli test passed/ + }); diff --git a/test/sequential/test-single-executable-application-exec-argv-extension-env.js b/test/sequential/test-single-executable-application-exec-argv-extension-env.js new file mode 100644 index 00000000000000..25d07bdc468f9a --- /dev/null +++ b/test/sequential/test-single-executable-application-exec-argv-extension-env.js @@ -0,0 +1,68 @@ +'use strict'; + +require('../common'); + +const { + generateSEA, + skipIfSingleExecutableIsNotSupported, +} = require('../common/sea'); + +skipIfSingleExecutableIsNotSupported(); + +// This tests the execArgvExtension "env" mode (default) in single executable applications. + +const fixtures = require('../common/fixtures'); +const tmpdir = require('../common/tmpdir'); +const { copyFileSync, writeFileSync, existsSync } = require('fs'); +const { spawnSyncAndAssert, spawnSyncAndExitWithoutError } = require('../common/child_process'); +const { join } = require('path'); +const assert = require('assert'); + +const configFile = tmpdir.resolve('sea-config.json'); +const seaPrepBlob = tmpdir.resolve('sea-prep.blob'); +const outputFile = tmpdir.resolve(process.platform === 'win32' ? 'sea.exe' : 'sea'); + +tmpdir.refresh(); + +// Copy test fixture to working directory +copyFileSync(fixtures.path('sea-exec-argv-extension-env.js'), tmpdir.resolve('sea.js')); + +writeFileSync(configFile, ` +{ + "main": "sea.js", + "output": "sea-prep.blob", + "disableExperimentalSEAWarning": true, + "execArgv": ["--no-warnings"], + "execArgvExtension": "env" +} +`); + +spawnSyncAndExitWithoutError( + process.execPath, + ['--experimental-sea-config', 'sea-config.json'], + { cwd: tmpdir.path }); + +assert(existsSync(seaPrepBlob)); + +generateSEA(outputFile, process.execPath, seaPrepBlob); + +// Test that NODE_OPTIONS works with execArgvExtension: "env" (default behavior) +spawnSyncAndAssert( + outputFile, + ['user-arg1', 'user-arg2'], + { + env: { + ...process.env, + NODE_OPTIONS: '--max-old-space-size=512', + COMMON_DIRECTORY: join(__dirname, '..', 'common'), + NODE_DEBUG_NATIVE: 'SEA', + } + }, + { + stdout: /execArgvExtension env test passed/, + stderr(output) { + assert.doesNotMatch(output, /This warning should not be shown in the output/); + return true; + }, + trim: true + }); diff --git a/test/sequential/test-single-executable-application-exec-argv-extension-none.js b/test/sequential/test-single-executable-application-exec-argv-extension-none.js new file mode 100644 index 00000000000000..272016f006f3f7 --- /dev/null +++ b/test/sequential/test-single-executable-application-exec-argv-extension-none.js @@ -0,0 +1,63 @@ +'use strict'; + +require('../common'); + +const { + generateSEA, + skipIfSingleExecutableIsNotSupported, +} = require('../common/sea'); + +skipIfSingleExecutableIsNotSupported(); + +// This tests the execArgvExtension "none" mode in single executable applications. + +const fixtures = require('../common/fixtures'); +const tmpdir = require('../common/tmpdir'); +const { copyFileSync, writeFileSync, existsSync } = require('fs'); +const { spawnSyncAndAssert, spawnSyncAndExitWithoutError } = require('../common/child_process'); +const { join } = require('path'); +const assert = require('assert'); + +const configFile = tmpdir.resolve('sea-config.json'); +const seaPrepBlob = tmpdir.resolve('sea-prep.blob'); +const outputFile = tmpdir.resolve(process.platform === 'win32' ? 'sea.exe' : 'sea'); + +tmpdir.refresh(); + +// Copy test fixture to working directory +copyFileSync(fixtures.path('sea-exec-argv-extension-none.js'), tmpdir.resolve('sea.js')); + +writeFileSync(configFile, ` +{ + "main": "sea.js", + "output": "sea-prep.blob", + "disableExperimentalSEAWarning": true, + "execArgv": ["--no-warnings"], + "execArgvExtension": "none" +} +`); + +spawnSyncAndExitWithoutError( + process.execPath, + ['--experimental-sea-config', 'sea-config.json'], + { cwd: tmpdir.path }); + +assert(existsSync(seaPrepBlob)); + +generateSEA(outputFile, process.execPath, seaPrepBlob); + +// Test that NODE_OPTIONS is ignored with execArgvExtension: "none" +spawnSyncAndAssert( + outputFile, + ['user-arg1', 'user-arg2'], + { + env: { + ...process.env, + NODE_OPTIONS: '--max-old-space-size=2048', + COMMON_DIRECTORY: join(__dirname, '..', 'common'), + NODE_DEBUG_NATIVE: 'SEA', + } + }, + { + stdout: /execArgvExtension none test passed/ + });