Files
software-mansion__argent/packages/tool-server/test/flows/script/flow-script-logs.test.ts
Hubert Gancarczyk 1a7e97fb14 feat(flows): run a local script in a bounded child process (#864)
Adds the executor a flow `script:` step runs on: a fresh Node child per
step,
a protocol over its IPC channel, and the two watchdogs that make a hung
or
runaway script the runner's problem rather than the host's.

The step itself is not here. This branch stops at the executor and its
unit
tests; `feat/flow-script-step` stacks the YAML directive, the runner
integration, and the reference docs on top.

## What it does

- **One child per step.** Spawned with an old-space heap limit, an
explicit
working directory, and an environment built from an allowlist rather
than
  copied from the tool server.
- **A deadline and a lifeline.** A separate watchdog holds the deadline,
so a
script that wedges the event loop still dies on time; a second one reaps
the
whole process tree when the parent goes away, so a grandchild cannot
outlive
  the run.
- **A concurrency queue.** Slots are bounded per server. A step that
never gets
one is refused with a message that says so, and an aborted run frees its
slot
  immediately.
- **Log budgets.** 64 KiB per step and 256 KiB per run, counted on the
bytes
the report keeps: redaction and V8 frame collapsing both run before
anything
is counted, so what the limits bound is the size of the report rather
than
  the size of the script's writes.
- **Secret hold-back.** The scrub walks a chunk and stops where a value
could
still begin, so neither half of a value split across two chunks is
released
  on its own, and a shorter value is never taken where the longer one
  containing it has not arrived yet.
- **A failure taxonomy.** Twelve kinds, split into what the script did
(it
threw, it did not load, it exited non-zero, it wrote an unusable
`output`)
and what the host did to it (a limit, a signal, a spawn that failed, a
queue
  slot it never got).

## Docs

`packages/docs/docs/reference/configuration.mdx` lists the two
configuration
keys this branch adds, `scripts.maxTimeoutMs` and `scripts.heapLimitMb`.
The
`script:` step itself is documented with the step, on
`feat/flow-script-step`.

## Verification

`npm run build`, `npx eslint . --max-warnings 0`, `npx prettier --check
.`,
`npm run knip`, `npm run typecheck:scripts`, the test typecheck across
every
workspace, the tool-server suite (4719 passed, 1 skipped) and `npm run
test:scripts` (92 tests) are green, as is `npx docusaurus build`. The
executor's
own behaviour is covered by the eight `test/flows/script/` files added
here,
against real child processes, and the compiled `dist/` executor was
driven
against its copied runner assets over every path the review reached.

Each review fix carries the run that reproduced it before the change and
the
mutation that proves the new test fails without it: the prefix-secret
leak and
all three npm `node-options` routes were reproduced end to end through
the
executor against real child processes, and the five coverage findings
were
confirmed by re-applying the exact mutation each thread named.




<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added reliable execution of trusted flow scripts with concurrency
control, cancellation, timeouts, memory limits, logging, and detailed
failure reporting.
* Added safeguards for process cleanup, watchdog termination, output
validation, and secret redaction.
* Added global configuration for maximum script runtime and memory
usage.
* **Bug Fixes**
* Improved handling of script failures, stalled processes, malformed
output, and child-process termination.
* **Documentation**
  * Documented script resource limits and global configuration behavior.
* **Chores**
* Updated builds and packaging to include required flow-script runtime
assets.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Hubert Gancarczyk <claude-hubert.gancarczyk@swmansion.com>
2026-09-07 11:34:30 +02:00

661 lines
24 KiB
TypeScript

import { afterEach, describe, expect, it } from "vitest";
import {
createScriptLogBudget,
FlowScriptExecutor,
SCRIPT_STEP_LOG_LIMIT_BYTES,
type FlowScriptExecutorOptions,
type FlowScriptLogBudget,
type FlowScriptSecret,
} from "../../../src/tools/flows/script/flow-script-executor";
import { SCRIPT_MAX_FAILURE_MESSAGE_CHARS } from "../../../src/tools/flows/script/flow-script-protocol";
import { createScriptWorkspace, type ScriptWorkspace } from "../../helpers/flow-script-workspace";
const workspaces: ScriptWorkspace[] = [];
function workspace(): ScriptWorkspace {
const ws = createScriptWorkspace("log");
workspaces.push(ws);
return ws;
}
afterEach(() => {
while (workspaces.length) workspaces.pop()!.cleanup();
});
function executor(options: FlowScriptExecutorOptions = {}) {
return new FlowScriptExecutor({ concurrency: 4, maxTimeoutMs: 60_000, ...options });
}
describe("flow script executor — log capture", () => {
it("captures stdout and stderr, and a subprocess writing to the same streams", async () => {
const ws = workspace();
const script = ws.write(
"logs.mjs",
`import { execFileSync } from "node:child_process";
console.log("from console.log");
console.info("from console.info");
console.warn("from console.warn");
console.error("from console.error");
execFileSync(process.execPath, ["-e", "console.log('from a subprocess')"], { stdio: "inherit" });
output.done = true;`
);
const result = await executor().execute({ scriptPath: script, projectRoot: ws.dir });
expect(result.ok).toBe(true);
for (const line of [
"from console.log",
"from console.info",
"from console.warn",
"from console.error",
"from a subprocess",
]) {
expect(result.log).toContain(line);
}
});
it("keeps the logs of a script that throws", async () => {
const ws = workspace();
const script = ws.write(
"throws.mjs",
`console.log("before the throw"); throw new Error("nope");`
);
const result = await executor().execute({ scriptPath: script, projectRoot: ws.dir });
expect(result.failure?.kind).toBe("runtime");
expect(result.log).toContain("before the throw");
});
it("truncates at the per-step limit without blocking the script", async () => {
const ws = workspace();
const script = ws.write(
"loud.mjs",
`const line = "y".repeat(1023) + "\\n";
for (let i = 0; i < 5 * 1024; i++) process.stdout.write(line);
output.finished = true;`
);
const result = await executor().execute({ scriptPath: script, projectRoot: ws.dir });
expect(result.output).toEqual({ finished: true });
expect(result.logTruncated).toBe(true);
expect(Buffer.byteLength(result.log)).toBeLessThanOrEqual(SCRIPT_STEP_LOG_LIMIT_BYTES);
expect(Buffer.byteLength(result.log)).toBeGreaterThan(SCRIPT_STEP_LOG_LIMIT_BYTES - 2048);
});
it("spends one run budget across every step in the run", async () => {
const ws = workspace();
const script = ws.write(
"chatty.mjs",
`process.stdout.write("z".repeat(64 * 1024)); output.ok = true;`
);
const budget: FlowScriptLogBudget = createScriptLogBudget();
const shared = executor();
const sizes: number[] = [];
// The run budget is 256 KiB and each step fills its own 64 KiB step limit,
// so the fifth step has nothing left to spend.
for (let step = 0; step < 5; step++) {
const result = await shared.execute({
scriptPath: script,
projectRoot: ws.dir,
logBudget: budget,
});
expect(result.ok).toBe(true);
sizes.push(Buffer.byteLength(result.log));
}
expect(sizes.slice(0, 4).every((size) => size > 0)).toBe(true);
expect(sizes[4]).toBe(0);
expect(budget.remainingBytes).toBeLessThanOrEqual(0);
});
});
describe("flow script executor — cutting the log", () => {
it("never cuts a multi-byte character in half", async () => {
const ws = workspace();
const script = ws.write(
"wide.mjs",
`console.log("日本語テキスト".repeat(20000));
output.ok = true;`
);
const result = await executor().execute({ scriptPath: script, projectRoot: ws.dir });
expect(result.logTruncated).toBe(true);
expect(result.log).not.toContain("\uFFFD");
expect(Buffer.byteLength(result.log, "utf8")).toBeLessThanOrEqual(SCRIPT_STEP_LOG_LIMIT_BYTES);
}, 30_000);
it("never cuts a redaction marker in half", async () => {
const ws = workspace();
const secret: FlowScriptSecret = { name: "VERY_LONG_SECRET_NAME", value: "s3cr3t-value" };
// Padded so the budget runs out a few characters into the marker the scrub
// writes for the value printed right after it.
const script = ws.write(
"cut-marker.mjs",
`process.stdout.write("x".repeat(${SCRIPT_STEP_LOG_LIMIT_BYTES - 6}));
process.stdout.write(process.env.SECRET);
output.ok = true;`
);
const result = await executor().execute({
scriptPath: script,
projectRoot: ws.dir,
env: { SECRET: secret.value },
secrets: [secret],
});
expect(result.logTruncated).toBe(true);
expect(result.log).not.toContain(secret.value);
expect(result.log.endsWith("x")).toBe(true);
const opened = result.log.lastIndexOf("{{secret:");
expect(opened === -1 || result.log.includes("}}", opened)).toBe(true);
}, 30_000);
});
describe("flow script executor — the heap verdict", () => {
it("recognises a heap banner split across two pipe chunks", async () => {
const ws = workspace();
const script = ws.write(
"split-heap.mjs",
`const wait = (ms) => new Promise((r) => setTimeout(r, ms));
process.stderr.write("\\n<--- Last few GCs --->\\n\\nFATAL ERROR: Reached ");
await wait(60);
process.stderr.write("heap limit Allocation failed - JavaScript heap out of memory\\n");
await wait(60);
process.abort();`
);
const result = await executor({ heapLimitMb: 64 }).execute({
scriptPath: script,
projectRoot: ws.dir,
timeoutMs: 20_000,
});
expect(result.failure?.kind).toBe("heap");
expect(result.failure?.message).toContain("64 MiB");
}, 30_000);
});
describe("flow script executor — the V8 frame collapser", () => {
it("marks the log truncated when it drops frames", async () => {
const ws = workspace();
const script = ws.write(
"hungry.mjs",
`console.log("allocating"); const held = []; for (;;) held.push("x".repeat(1024 * 1024));`
);
const result = await executor({ heapLimitMb: 64 }).execute({
scriptPath: script,
projectRoot: ws.dir,
timeoutMs: 30_000,
});
expect(result.log).toMatch(/\[\d+ V8 stack frames omitted]/);
expect(result.logTruncated).toBe(true);
}, 60_000);
it("keeps a short run of frame lines verbatim, even after a fatal error", async () => {
const ws = workspace();
const script = ws.write(
"short-run.mjs",
`console.error("FATAL ERROR: something the script printed itself");
console.error(" 1: 0x104941aec first");
console.error(" 2: 0x104b94314 second");
output.ok = true;`
);
const result = await executor().execute({ scriptPath: script, projectRoot: ws.dir });
expect(result.log).toContain("1: 0x104941aec first");
expect(result.log).toContain("2: 0x104b94314 second");
expect(result.log).not.toContain("frames omitted");
});
it("arms on a banner split across two pipe chunks", async () => {
const ws = workspace();
const script = ws.write(
"split-banner.mjs",
`const wait = (ms) => new Promise((r) => setTimeout(r, ms));
process.stderr.write("FATAL ");
await wait(60);
process.stderr.write("ERROR: build step failed\\n");
for (let i = 1; i <= 6; i++) process.stderr.write(\` \${i}: 0x1049\${i}1aec some symbol\\n\`);
output.ok = true;`
);
const result = await executor().execute({ scriptPath: script, projectRoot: ws.dir });
expect(result.log).toContain("FATAL ERROR: build step failed");
expect(result.log).toMatch(/\[6 V8 stack frames omitted]/);
expect(result.log).not.toContain("0x104931aec");
}, 30_000);
it("flushes a partial line the collapser cannot classify rather than holding it", async () => {
const ws = workspace();
const script = ws.write(
"long-partial.mjs",
`const wait = (ms) => new Promise((r) => setTimeout(r, ms));
process.stderr.write("FATAL ERROR: build step failed\\n");
await wait(60);
process.stderr.write("p".repeat(10 * 1024));
await wait(60);
process.stdout.write("[upload finished]\\n");
output.ok = true;`
);
const result = await executor().execute({ scriptPath: script, projectRoot: ws.dir });
expect(result.log.indexOf("ppp")).toBeLessThan(result.log.indexOf("[upload finished]"));
}, 30_000);
it("leaves frame-shaped lines from the script alone", async () => {
const ws = workspace();
const script = ws.write(
"hexdump.mjs",
`for (let i = 1; i <= 6; i++) console.error(\` \${i}: 0x1049\${i}1aec some symbol\`);
output.ok = true;`
);
const result = await executor().execute({ scriptPath: script, projectRoot: ws.dir });
expect(result.log).toContain("1: 0x104911aec some symbol");
expect(result.log).toContain("6: 0x104961aec some symbol");
expect(result.logTruncated).toBe(false);
});
});
describe("flow script executor — order", () => {
const WRITES = `const wait = (ms) => new Promise((r) => setTimeout(r, ms));
process.stderr.write("Downloading... ");
await wait(60);
process.stdout.write("[stdout line 1]\\n");
await wait(60);
process.stderr.write("done\\n");
output.ok = true;`;
it("keeps what the script wrote in the order it wrote it", async () => {
const ws = workspace();
const result = await executor().execute({
scriptPath: ws.write("order.mjs", WRITES),
projectRoot: ws.dir,
});
expect(result.ok).toBe(true);
expect(result.log).toBe("Downloading... [stdout line 1]\ndone\n");
});
it("keeps that order when a secret is configured", async () => {
const ws = workspace();
const result = await executor().execute({
scriptPath: ws.write("order.mjs", WRITES),
projectRoot: ws.dir,
secrets: [{ name: "TOK", value: "0123456789abcdef0123456789abcdef" }],
});
expect(result.log).toBe("Downloading... [stdout line 1]\ndone\n");
});
it("keeps that order when the held tail is a prefix of the secret", async () => {
const ws = workspace();
const source = `const wait = (ms) => new Promise((r) => setTimeout(r, ms));
process.stderr.write("Resolving packages: 42% h");
await wait(60);
process.stdout.write("installed 128 packages\\n");
await wait(60);
process.stderr.write("\\n");`;
const result = await executor().execute({
scriptPath: ws.write("prefix-order.mjs", source),
projectRoot: ws.dir,
secrets: [{ name: "TOK", value: "hunter2-abcdef0123456789" }],
});
expect(result.log).toBe("Resolving packages: 42% hinstalled 128 packages\n\n");
});
it("redacts a value the two streams wrote half of each, in place", async () => {
const ws = workspace();
const source = `const wait = (ms) => new Promise((r) => setTimeout(r, ms));
process.stdout.write("token=sk-live-9d");
await wait(60);
process.stderr.write("3f0a1b2c3d4e5f\\n");`;
const result = await executor().execute({
scriptPath: ws.write("split-order.mjs", source),
projectRoot: ws.dir,
secrets: [{ name: "TOKEN", value: "sk-live-9d3f0a1b2c3d4e5f" }],
});
expect(result.log).toBe("token={{secret:TOKEN}}\n");
});
it("keeps that order for a value that overlaps itself", async () => {
const ws = workspace();
const source = `const wait = (ms) => new Promise((r) => setTimeout(r, ms));
for (const piece of ["[", "abca", "b", "]"]) {
process.stdout.write(piece);
await wait(60);
}`;
const result = await executor().execute({
scriptPath: ws.write("self-overlap.mjs", source),
projectRoot: ws.dir,
secrets: [{ name: "S", value: "abcab" }],
});
expect(result.log).toBe("[{{secret:S}}]");
});
it("keeps that order for a self-overlapping value with the other stream between", async () => {
const ws = workspace();
const source = `const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const writes = [
[process.stdout, "["],
[process.stdout, "abca"],
[process.stderr, "MID"],
[process.stdout, "b"],
[process.stdout, "]"],
];
for (const [stream, piece] of writes) {
stream.write(piece);
await wait(60);
}`;
const result = await executor().execute({
scriptPath: ws.write("self-overlap-streams.mjs", source),
projectRoot: ws.dir,
secrets: [{ name: "S", value: "abcab" }],
});
expect(result.log).toBe("[MID{{secret:S}}]");
});
});
describe("flow script executor — redaction", () => {
const SECRET: FlowScriptSecret = { name: "API_KEY", value: "s3cr3t-token-value" };
it("replaces a secret written in one piece", async () => {
const ws = workspace();
const script = ws.write("plain.mjs", `console.log("auth: " + process.env.API_KEY);`);
const result = await executor().execute({
scriptPath: script,
projectRoot: ws.dir,
env: { API_KEY: SECRET.value },
secrets: [SECRET],
});
expect(result.log).not.toContain(SECRET.value);
expect(result.log).toContain("auth: {{secret:API_KEY}}");
});
it("replaces a secret in the failure message and stack, not only in the log", async () => {
const ws = workspace();
const script = ws.write(
"assert.mjs",
`import assert from "node:assert/strict";
assert.equal("sk-live-WRONG", process.env.API_KEY);`
);
const result = await executor().execute({
scriptPath: script,
projectRoot: ws.dir,
env: { API_KEY: SECRET.value },
secrets: [SECRET],
});
expect(result.failure?.kind).toBe("runtime");
expect(result.failure?.message).not.toContain(SECRET.value);
expect(result.failure?.message).toContain("{{secret:API_KEY}}");
expect(result.failure?.stack).not.toContain(SECRET.value);
});
it("replaces a secret in the output document, at any depth and in a key", async () => {
const ws = workspace();
const script = ws.write(
"echo.mjs",
`const key = process.env.API_KEY;
console.log("using " + key);
output.session = { token: key, scopes: ["read", key] };
output[key] = "keyed";`
);
const result = await executor().execute({
scriptPath: script,
projectRoot: ws.dir,
env: { API_KEY: SECRET.value },
secrets: [SECRET],
});
expect(result.ok).toBe(true);
expect(JSON.stringify(result.output)).not.toContain(SECRET.value);
expect(result.output).toEqual({
"session": { token: "{{secret:API_KEY}}", scopes: ["read", "{{secret:API_KEY}}"] },
"{{secret:API_KEY}}": "keyed",
});
});
it("leaves a marker well formed when a value occurs inside another secret's name", async () => {
const ws = workspace();
const script = ws.write("marker.mjs", `console.log("value=Q");`);
const result = await executor().execute({
scriptPath: script,
projectRoot: ws.dir,
secrets: [{ name: "Q0", value: "Q" }],
});
expect(result.log).toBe("value={{secret:Q0}}\n");
});
it("leaves a marker well formed when two secrets swap name and value", async () => {
const ws = workspace();
const script = ws.write("swapped.mjs", `console.log("id=TOKEN_ABC and OKEN");`);
const result = await executor().execute({
scriptPath: script,
projectRoot: ws.dir,
secrets: [
{ name: "TOKEN_ABC", value: "OKEN" },
{ name: "OKEN", value: "TOKEN_ABC" },
],
});
expect(result.log).toBe("id={{secret:OKEN}} and {{secret:TOKEN_ABC}}\n");
});
it("refuses a document whose redacted key would replace a sibling", async () => {
const ws = workspace();
const script = ws.write("collide.mjs", `output.doc = { "ab": 1, "{{secret:s}}": 2 };`);
const result = await executor().execute({
scriptPath: script,
projectRoot: ws.dir,
secrets: [{ name: "s", value: "ab" }],
});
expect(result.ok).toBe(false);
expect(result.failure?.kind).toBe("output");
expect(result.failure?.message).toContain("Two keys in the script's output become \"");
expect(result.failure?.message).toContain("{{secret:s}}");
expect(result.output).toBeUndefined();
});
it("replaces a value that starts inside marker-shaped text the script printed", async () => {
const ws = workspace();
// The shape a script echoing an unresolved placeholder writes: it looks
// like a marker, so skipping it whole would carry the value out in plain
// text — no pass ever visits a position inside a span that was jumped.
const script = ws.write("echoed.mjs", `console.log("head {{secret:TOK}}TAIL tail");`);
const result = await executor().execute({
scriptPath: script,
projectRoot: ws.dir,
secrets: [
{ name: "TOK", value: "tok" },
{ name: "V", value: "TOK}}TAIL" },
],
});
expect(result.log).not.toContain("TOK}}TAIL");
expect(result.log).toContain("{{secret:V}}");
});
it("replaces a secret split across two pipe chunks", async () => {
const ws = workspace();
const script = ws.write(
"split.mjs",
`const value = process.env.API_KEY;
process.stdout.write("auth: " + value.slice(0, 6));
await new Promise((r) => setTimeout(r, 120));
process.stdout.write(value.slice(6) + "\\n");
output.ok = true;`
);
const result = await executor().execute({
scriptPath: script,
projectRoot: ws.dir,
env: { API_KEY: SECRET.value },
secrets: [SECRET],
});
expect(result.ok).toBe(true);
expect(result.log).not.toContain(SECRET.value);
expect(result.log).toContain("auth: {{secret:API_KEY}}");
});
it("replaces the longer of two nested secrets when the chunk splits between them", async () => {
const ws = workspace();
const host: FlowScriptSecret = { name: "HOST", value: "api.internal.example.com" };
const url: FlowScriptSecret = {
name: "URL",
value: `https://${host.value}/tenant/9f3a0b1c2d3e4f50`,
};
const script = ws.write(
"nested.mjs",
`process.stdout.write("calling https://" + ${JSON.stringify(host.value)});
await new Promise((r) => setTimeout(r, 120));
process.stdout.write("/tenant/9f3a0b1c2d3e4f50\\n");
output.ok = true;`
);
const result = await executor().execute({
scriptPath: script,
projectRoot: ws.dir,
secrets: [host, url],
});
expect(result.ok).toBe(true);
expect(result.log).toBe("calling {{secret:URL}}\n");
});
it("replaces the longer of two secrets when the shorter one is its prefix", async () => {
const ws = workspace();
const prefix: FlowScriptSecret = { name: "PFX", value: "sk-" };
const full: FlowScriptSecret = { name: "FULL", value: "sk-live-9d3f0a1b" };
// The chunk ends where both values start, so taking the short one there
// settles it and leaves the long one's remainder for the next chunk to
// release in plaintext.
const script = ws.write(
"prefix.mjs",
`process.stdout.write("tok sk-");
await new Promise((r) => setTimeout(r, 120));
process.stdout.write("live-9d3f0a1b end\\n");
output.ok = true;`
);
const result = await executor().execute({
scriptPath: script,
projectRoot: ws.dir,
secrets: [prefix, full],
});
expect(result.ok).toBe(true);
expect(result.log).toBe("tok {{secret:FULL}} end\n");
});
it("replaces every occurrence of a value that starts with its own tail", async () => {
const ws = workspace();
const value = "0123456789".repeat(4);
const script = ws.write(
"periodic.mjs",
`const block = ${JSON.stringify(value)}.repeat(128);
for (let i = 0; i < 40; i++) process.stdout.write(block);`
);
const result = await executor().execute({
scriptPath: script,
projectRoot: ws.dir,
secrets: [{ name: "P", value }],
});
// The value is all digits and its marker has none, so a surviving digit is
// a surviving fragment of the value.
expect(result.log).not.toMatch(/[0-9]/);
expect(result.log).toContain("{{secret:P}}");
}, 30_000);
it("keeps a secret that straddles the truncation cut out of the report", async () => {
const ws = workspace();
const script = ws.write(
"straddle.mjs",
`process.stdout.write("f".repeat(${SCRIPT_STEP_LOG_LIMIT_BYTES} - 6));
process.stdout.write(process.env.API_KEY + "\\n");
output.ok = true;`
);
const result = await executor().execute({
scriptPath: script,
projectRoot: ws.dir,
env: { API_KEY: SECRET.value },
secrets: [SECRET],
});
expect(result.ok).toBe(true);
expect(result.logTruncated).toBe(true);
expect(result.log).not.toContain(SECRET.value);
expect(result.log).not.toContain(SECRET.value.slice(0, 8));
});
it("keeps a secret that straddles the failure-message ceiling out of the report", async () => {
const ws = workspace();
const script = ws.write(
"long-throw.mjs",
`throw new Error(
"p".repeat(${SCRIPT_MAX_FAILURE_MESSAGE_CHARS} - 16) + process.env.API_KEY + " trailing"
);`
);
const result = await executor().execute({
scriptPath: script,
projectRoot: ws.dir,
env: { API_KEY: SECRET.value },
secrets: [SECRET],
});
expect(result.failure?.kind).toBe("runtime");
expect(result.failure?.message).not.toContain(SECRET.value.slice(0, 8));
expect(result.failure?.message).toMatch(/… \[\d+ more characters omitted]$/);
expect(result.failure?.stack).not.toContain(SECRET.value.slice(0, 8));
});
it("redacts a value that straddled a chunk boundary before its secret was known", async () => {
const ws = workspace();
const value = "sk-live-9d3f0a1b2c3d4e5f6071";
const script = ws.write(
"straddles.mjs",
`const value = ${JSON.stringify(value)};
process.stdout.write("Authorization: Bearer " + value.slice(0, 10));
await new Promise((r) => setTimeout(r, 400));
process.stdout.write(value.slice(10) + "\\n");
output.ok = true;`
);
const secrets: FlowScriptSecret[] = [];
const pending = executor().execute({ scriptPath: script, projectRoot: ws.dir, secrets });
setTimeout(() => secrets.push({ name: "TOKEN", value }), 200);
const result = await pending;
expect(result.log).not.toContain(value);
expect(result.log).toContain("Bearer {{secret:TOKEN}}");
});
it("reads the secret set live, so a value added mid-run still redacts", async () => {
const ws = workspace();
const script = ws.write(
"later.mjs",
`console.log("first: " + process.env.EARLY);
await new Promise((r) => setTimeout(r, 150));
console.log("second: " + process.env.LATE);
output.ok = true;`
);
const secrets: FlowScriptSecret[] = [{ name: "EARLY", value: "early-value-aaaa" }];
const pending = executor().execute({
scriptPath: script,
projectRoot: ws.dir,
env: { EARLY: "early-value-aaaa", LATE: "late-value-bbbb" },
secrets,
});
// Pushed from a later turn of the loop, after the step has read the set
// once: pushing in the same turn as the call lands before `runOne` ever
// looks, so an implementation that snapshotted the array once would pass.
setTimeout(() => secrets.push({ name: "LATE", value: "late-value-bbbb" }), 60);
const result = await pending;
expect(result.log).toContain("first: {{secret:EARLY}}");
expect(result.log).toContain("second: {{secret:LATE}}");
});
});