fix(workflows): expose args as env vars for shell-safe usage

This commit is contained in:
vignesh07
2026-03-05 17:38:02 -08:00
parent 2707687b63
commit 83e115a8bf
3 changed files with 92 additions and 0 deletions
+23
View File
@@ -230,3 +230,26 @@ steps:
### Passing data between steps (no temp files)
Use `stdin: $stepId.stdout` to pipe output from one step into the next.
## Args and shell-safety
`${arg}` substitution is a raw string replace into the shell command text.
For anything that may contain quotes, `$`, backticks, or newlines, prefer env vars:
- every resolved workflow arg is exposed as `LOBSTER_ARG_<NAME>` (uppercased, non-alnum → `_`)
- the full args object is also available as `LOBSTER_ARGS_JSON`
Example:
```yaml
args:
text:
default: ""
steps:
- id: safe
env:
TEXT: "$LOBSTER_ARG_TEXT"
command: |
jq -n --arg text "$TEXT" '{"result": $text}'
```
+22
View File
@@ -270,6 +270,17 @@ function mergeEnv(
results: Record<string, WorkflowStepResult>,
) {
const env = { ...base } as Record<string, string | undefined>;
// Expose resolved args as env vars so shell commands can safely reference them
// without embedding raw values into the command string.
// Example: $LOBSTER_ARG_TEXT
env.LOBSTER_ARGS_JSON = JSON.stringify(args ?? {});
for (const [key, value] of Object.entries(args ?? {})) {
const normalized = normalizeArgEnvKey(key);
if (!normalized) continue;
env[`LOBSTER_ARG_${normalized}`] = String(value);
}
const apply = (source?: Record<string, string>) => {
if (!source) return;
for (const [key, value] of Object.entries(source)) {
@@ -278,11 +289,22 @@ function mergeEnv(
}
}
};
// Allow explicit env blocks to override injected defaults.
apply(workflowEnv);
apply(stepEnv);
return env;
}
function normalizeArgEnvKey(key: string): string | null {
const trimmed = String(key ?? '').trim();
if (!trimmed) return null;
// Keep it predictable for shells: uppercase and [A-Z0-9_]
const up = trimmed.toUpperCase();
const normalized = up.replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '');
return normalized || null;
}
function resolveCwd(cwd: string | undefined, args: Record<string, unknown>) {
if (!cwd) return undefined;
return resolveArgsTemplate(cwd, args);
+47
View File
@@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { promises as fsp } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { runWorkflowFile } from '../src/workflows/file.js';
test('workflow file exposes args as LOBSTER_ARG_* env vars (safe for quotes)', async () => {
const workflow = {
name: 'args-env',
args: {
text: { default: '' },
},
steps: [
{
id: 'echo',
// Avoid embedding the arg into the shell command; read from env instead.
command:
"node -e \"process.stdout.write(JSON.stringify({text: process.env.LOBSTER_ARG_TEXT}))\"",
},
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-workflow-args-env-'));
const stateDir = path.join(tmpDir, 'state');
const filePath = path.join(tmpDir, 'workflow.lobster');
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), 'utf8');
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
const text = 'hello "world" $HOME `backticks` $(whoami)';
const result = await runWorkflowFile({
filePath,
args: { text },
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: 'tool',
},
});
assert.equal(result.status, 'ok');
assert.deepEqual(result.output, [{ text }]);
});