Skip to content
Merged
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
36 changes: 20 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,14 +290,20 @@ deterministic **named Docker volume** (derived from the `--user-dir` value)
mounted inside the container, never a host bind mount — so "no stray host
files" holds even for persistent installs.

For images/derived images that pre-install Node-RED node packages into
their own conventional userDir, setting the `NODE_RED_CLI_DEFAULT_USERDIR`
environment variable (inside the image, e.g. via `ENV`) to that path lets
`--docker` discover it automatically whenever `--user-dir` isn't given —
that directory is used as `userDir` and, like an explicit `--user-dir`,
never deleted afterward. If the path doesn't exist or isn't a directory,
`--docker` logs a warning and falls back to the normal ephemeral `userDir`
rather than failing the invocation.
Without an explicit `--user-dir`, `--docker` also auto-probes the
container's own `/data` for a userDir a community image already
pre-populated with its own Node-RED node packages (e.g. the motivating
[`ghcr.io/tbrandenburg/agentic-workflow-dev-env`](https://github.com/tbrandenburg/agentic-workflow-dev-env),
which sets `NODE_RED_HOME=/data`) — validated by scanning `/data/node_modules`
(including scoped `@scope/*` packages) for any `package.json` declaring a
`"node-red"` key. This is inherently best-effort: an unrelated `/data` that
happens to contain such a package is a (rare) false positive, and a real
userDir laid out differently is a false negative that silently falls back
to the ephemeral default. For a reliable, explicit alternative, pass
`--docker-userdir <path>` to name the in-container directory directly —
it takes precedence over the auto-probe (but is itself still overridden by
an explicit `--user-dir`). Whichever wins, that directory is used as
`userDir` and, like an explicit `--user-dir`, never deleted afterward.

Fails fast with a clear `node-red-cli: docker unavailable: ...` error if
the Docker CLI/daemon isn't reachable, or `node-red-cli: docker build
Expand Down Expand Up @@ -326,12 +332,11 @@ echo '{"payload":"Summarize this repo in one sentence.","cwd":"/repo"}' \

The same flow runs sandboxed via `--docker <image>` against an image that
already ships `opencode` + `node-red-agents`, e.g.
[`ghcr.io/tbrandenburg/agentic-workflow-dev-env`](https://github.com/tbrandenburg/agentic-workflow-dev-env)
(`--network` is required for network access, since the agent calls out to
its own API; `--node-modules`/`--user-dir` are still required too, since
Node-RED only discovers node types from a userDir it actually loaded, and
`--docker` doesn't yet reuse an image's own pre-populated default userDir —
see [#31](https://github.com/tbrandenburg/node-red-cli/issues/31)):
[`ghcr.io/tbrandenburg/agentic-workflow-dev-env`](https://github.com/tbrandenburg/agentic-workflow-dev-env),
which pre-installs its node packages into `/data` (`NODE_RED_HOME=/data`) —
exactly the layout the `/data` auto-probe discovers automatically, with
no `--node-modules`/`--user-dir` needed (`--network` is still required for
network access, since the agent calls out to its own API):

```bash
echo '{"payload":"Summarize this repo in one sentence.","cwd":"/repo"}' \
Expand All @@ -343,8 +348,7 @@ echo '{"payload":"Summarize this repo in one sentence.","cwd":"/repo"}' \
"cwd":"cwd","cwdType":"msg","wires":[["return"],[]]},
{"id":"return","type":"link out","z":"tab","name":"return","mode":"return"}
]' ask --docker ghcr.io/tbrandenburg/agentic-workflow-dev-env:latest \
--node-modules @tbrandenburg/node-red-agents --user-dir --network \
--timeout=120000 --format=json
--network --timeout=120000 --format=json
```

## Host API 🛠️
Expand Down
15 changes: 9 additions & 6 deletions bin/node-red-cli-sandbox-entry.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@
* the shared `runFlowInvocation` (the exact same logic the host CLI uses
* for its non-Docker path), and writes the formatted result to stdout.
*
* The `NODE_RED_CLI_DEFAULT_USERDIR` env-var convention (see #31), which
* lets an image's own pre-populated default userDir be discovered when
* `--user-dir` isn't given, is resolved entirely inside the shared
* `runFlowInvocation` (`src/run-envelope.js`) -- nothing to do here beyond
* the existing pass-through of `envelope.options`.
* Sets `options.probeContainerDefault: true` before delegating to
* `runFlowInvocation`, so its `/data` auto-probe (see #33,
* `resolveContainerDefaultUserDir` in `src/run-envelope.js`) is only ever
* attempted here -- inside the container -- and never on the host CLI
* path, since `/data` has no reserved meaning outside a container. It only
* takes effect as a fallback: an explicit `userDir` (named-volume mount) or
* `dockerUserDir` (`--docker-userdir`) already present on `envelope.options`
* still wins, per `resolveEffectiveUserDir`'s precedence.
*/

const { runFlowInvocation } = require("../src/run-envelope");
Expand Down Expand Up @@ -41,7 +44,7 @@ async function main() {
const { output } = await runFlowInvocation({
flow: envelope.flow,
msg: envelope.msg,
options: envelope.options || {}
options: { ...(envelope.options || {}), probeContainerDefault: true }
});
process.stdout.write(`${output}\n`);
} catch (error) {
Expand Down
37 changes: 29 additions & 8 deletions bin/node-red-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,16 @@ const HELP_TEXT = [
"network access independent of installing any package), --read-only",
"rootfs with a /tmp tmpfs, --cap-drop=ALL, --security-opt=no-new-privileges.",
"When combined with --user-dir, persistence uses a named Docker volume,",
"never a host bind mount. Image authors can set",
"NODE_RED_CLI_DEFAULT_USERDIR=<path> so a pre-installed userDir is",
"discovered automatically when --user-dir isn't given.",
"never a host bind mount. Without --user-dir, the container's own /data",
"is auto-probed for a pre-populated Node-RED userDir (best-effort; see",
"--docker-userdir for a reliable, explicit alternative).",
"",
"--docker-userdir <path> tells --docker to use <path> (a directory inside",
"the container, e.g. one a base image already pre-installs Node-RED node",
"packages into) as the userDir, instead of the default ephemeral tmpdir or",
"the best-effort /data auto-probe. Ignored outside --docker mode.",
"Precedence: --user-dir > --docker-userdir > the /data auto-probe > the",
"ephemeral default.",
"",
"Example:",
' echo \'{"payload":{"x":4,"y":5}}\' | node-red-cli flows.json calculate',
Expand Down Expand Up @@ -197,10 +204,18 @@ async function run(args, options) {

const persistentUserDir = resolveUserDir(options.userDir);
if (nodeModules.length > 0 && !persistentUserDir) {
console.error(
"node-red-cli: --node-modules requires an explicit --user-dir (a persistent directory); " +
"using it with the default ephemeral userDir would reinstall from npm on every run"
);
if (options.dockerUserdir || options.docker) {
console.error(
"node-red-cli: --node-modules requires an explicit --user-dir (a persistent directory); " +
"--docker-userdir and the best-effort /data auto-probe are not guaranteed persistent, " +
"so installing into them would just reinstall from npm on every run"
);
} else {
console.error(
"node-red-cli: --node-modules requires an explicit --user-dir (a persistent directory); " +
"using it with the default ephemeral userDir would reinstall from npm on every run"
);
}
process.exitCode = 1;
return;
}
Expand All @@ -226,7 +241,8 @@ async function run(args, options) {
timeoutMs: options.timeout,
format: options.format,
nodeModules,
userDir: volumeName ? CONTAINER_USER_DIR : undefined
userDir: volumeName ? CONTAINER_USER_DIR : undefined,
dockerUserDir: options.dockerUserdir
}
};

Expand Down Expand Up @@ -302,6 +318,11 @@ program
"run the invocation sandboxed in a disposable Docker container; bare = cached default image, " +
"'<image[:tag]>' = explicit image (installed into if missing), '@path'/URL = build from a Dockerfile"
)
.option(
"--docker-userdir <path>",
"in --docker mode, use <path> (inside the container) as the userDir instead of the default " +
"ephemeral tmpdir or the best-effort /data auto-probe; ignored outside --docker mode"
)
.option(
"--network",
"enable network access in --docker mode, independent of --node-modules (default: --network none)"
Expand Down
157 changes: 120 additions & 37 deletions src/run-envelope.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,31 +133,110 @@ function waitForFlowsSettled(RED) {
}

/**
* Resolves the image/host-provided default `userDir` from the
* `NODE_RED_CLI_DEFAULT_USERDIR` environment variable (see #31): community
* Docker images that pre-install Node-RED node packages into their own
* conventional userDir can set this variable so `--docker` (without an
* explicit `--user-dir`) discovers it automatically. Returns `undefined` if
* unset. Fails open, not closed: if the path doesn't exist, isn't a
* directory, or isn't accessible, logs a one-line stderr warning and returns
* `undefined` so the caller falls back to its normal ephemeral tmpdir,
* rather than aborting the invocation.
* True if `dirPath` exists and, following symlinks (npm frequently
* symlinks packages, e.g. in workspace/monorepo installs), is a directory.
* Never throws: any stat failure (missing path, broken symlink, etc.)
* resolves to `false`.
*/
function resolveDefaultUserDir() {
const configuredPath = process.env.NODE_RED_CLI_DEFAULT_USERDIR;
if (!configuredPath) return undefined;
function isDirectory(dirPath) {
try {
return fs.statSync(dirPath).isDirectory();
} catch {
return false;
}
}

/**
* Returns every immediate subdirectory of `node_modules`, including one
* level of scoped-package expansion (`@scope/*`), as a flat list of
* absolute directory paths. Follows symlinks (see `isDirectory`). Never
* throws: an unreadable/missing `node_modules` (or scope dir) simply
* contributes no candidates.
*/
function listNodeModuleDirs(nodeModulesDir) {
let entries;
try {
if (fs.statSync(configuredPath).isDirectory()) return configuredPath;
console.error(
`node-red-cli: NODE_RED_CLI_DEFAULT_USERDIR='${configuredPath}' is not usable (not a directory), falling back to an ephemeral userDir`
);
} catch (error) {
console.error(
`node-red-cli: NODE_RED_CLI_DEFAULT_USERDIR='${configuredPath}' is not usable (${error.message}), falling back to an ephemeral userDir`
);
entries = fs.readdirSync(nodeModulesDir);
} catch {
return [];
}

return entries.flatMap((name) => {
const entryPath = path.join(nodeModulesDir, name);
if (!name.startsWith("@")) return isDirectory(entryPath) ? [entryPath] : [];
if (!isDirectory(entryPath)) return [];

let scopedNames;
try {
scopedNames = fs.readdirSync(entryPath);
} catch {
return [];
}
return scopedNames.map((scoped) => path.join(entryPath, scoped)).filter(isDirectory);
});
}

/** True if `packageDir/package.json` exists, parses, and declares a `"node-red"` key. */
function isNodeRedPackage(packageDir) {
try {
const pkg = JSON.parse(fs.readFileSync(path.join(packageDir, "package.json"), "utf8"));
return Boolean(pkg["node-red"]);
} catch {
return false;
}
return undefined;
}

/**
* Auto-probes `baseDir` (default `/data`, the conventional mount point of
* the motivating `ghcr.io/tbrandenburg/agentic-workflow-dev-env` image, see
* issue #33) for a Node-RED userDir a community image pre-populated with
* its own node packages. Returns `baseDir` when it exists, is a directory,
* and at least one direct or scoped (`@scope/*`) child of its
* `node_modules` declares a `"node-red"` key in `package.json`; returns
* `undefined` otherwise.
*
* Best-effort by design: a real userDir with unrelated packages under
* `node_modules` (false negative) or a `/data` that merely happens to
* contain an unrelated `"node-red"`-keyed package (false positive) are both
* possible; `--docker-userdir <path>` is the reliable, explicit alternative
* when this heuristic doesn't fit an image. Never throws: any missing or
* unreadable path along the way resolves to "not usable".
*
* Only meaningful inside a container (`/data` has no reserved meaning on
* the host), so this is only ever called from the sandbox entrypoint
* (`bin/node-red-cli-sandbox-entry.js`), never from the host CLI path.
*/
function resolveContainerDefaultUserDir(baseDir = "/data") {
if (!isDirectory(baseDir)) return undefined;
const candidateDirs = listNodeModuleDirs(path.join(baseDir, "node_modules"));
return candidateDirs.some(isNodeRedPackage) ? baseDir : undefined;
}

/**
* Resolves which `userDir` source wins, in order of precedence (see #33):
*
* 1. `userDir` -- host-managed, explicit `--user-dir` (or its container
* named-volume mount path).
* 2. `dockerUserDir` -- explicit `--docker-userdir <path>` passthrough.
* 3. the auto-probed `/data` default (see `resolveContainerDefaultUserDir`),
* only attempted when `probeContainerDefault` is set (sandbox entrypoint
* only).
* 4. `undefined` -- caller falls back to an ephemeral tmpdir.
*
* The first three are all treated as persistent (never removed afterward);
* only the ephemeral tmpdir fallback is managed/cleaned up by the caller.
*
* `probeBaseDir` overrides the auto-probed path (defaults to `/data`) --
* only ever used by tests; real callers always probe the real `/data`.
*/
function resolveEffectiveUserDir({ userDir, dockerUserDir, probeContainerDefault, probeBaseDir } = {}) {
if (userDir) return { userDir, persistent: true };
if (dockerUserDir) return { userDir: dockerUserDir, persistent: true };
if (probeContainerDefault) {
const probed = resolveContainerDefaultUserDir(probeBaseDir);
if (probed) return { userDir: probed, persistent: true };
}
return { userDir: undefined, persistent: false };
}

/**
Expand All @@ -172,15 +251,14 @@ function resolveDefaultUserDir() {
* entrypoint (`bin/node-red-cli-sandbox-entry.js`, `--docker` path), so both
* execute the exact same runtime logic.
*
* `options.userDir`, when set, is treated as a persistent directory and is
* never removed afterward (host: an explicit `--user-dir`; container: the
* fixed mount path of a named Docker volume). When omitted, and the
* `NODE_RED_CLI_DEFAULT_USERDIR` environment variable points at an existing
* directory (see `resolveDefaultUserDir`), that directory is used instead —
* also treated as persistent and never removed afterward, letting a Docker
* image's own pre-populated default userDir be discovered automatically
* (see #31). Otherwise an ephemeral tmpdir is created and removed again
* after the call.
* `userDir` resolution follows `resolveEffectiveUserDir`'s precedence:
* `options.userDir` (host: an explicit `--user-dir`; container: the fixed
* mount path of a named Docker volume) > `options.dockerUserDir` (explicit
* `--docker-userdir <path>` passthrough) > the auto-probed `/data` default
* (see `resolveContainerDefaultUserDir`, only attempted when
* `options.probeContainerDefault` is set -- sandbox entrypoint only) > an
* ephemeral tmpdir created fresh and removed again after the call. The
* first three are all treated as persistent and never removed afterward.
*/
async function runFlowInvocation({ flow, flowFile, msg, options }) {
const {
Expand All @@ -189,14 +267,14 @@ async function runFlowInvocation({ flow, flowFile, msg, options }) {
timeoutMs = 5000,
format = "plain",
nodeModules = [],
userDir: fixedUserDir
userDir: fixedUserDir,
dockerUserDir,
probeContainerDefault
} = options;

const persistentUserDir = Boolean(fixedUserDir);
const imageDefaultUserDir = !persistentUserDir ? resolveDefaultUserDir() : undefined;
const userDir =
fixedUserDir || imageDefaultUserDir || fs.mkdtempSync(path.join(os.tmpdir(), "node-red-cli-"));
const managedUserDir = !persistentUserDir && !imageDefaultUserDir;
const resolved = resolveEffectiveUserDir({ userDir: fixedUserDir, dockerUserDir, probeContainerDefault });
const userDir = resolved.userDir || fs.mkdtempSync(path.join(os.tmpdir(), "node-red-cli-"));
const managedUserDir = !resolved.persistent;

try {
if (nodeModules.length > 0) {
Expand Down Expand Up @@ -234,4 +312,9 @@ async function runFlowInvocation({ flow, flowFile, msg, options }) {
}
}

module.exports = { runFlowInvocation, stderrLogHandler, resolveDefaultUserDir };
module.exports = {
runFlowInvocation,
stderrLogHandler,
resolveContainerDefaultUserDir,
resolveEffectiveUserDir
};
Loading