From 261b3af06f991dff2480f3c908cfa62f3dd16fbd Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Fri, 7 Aug 2026 18:40:16 -0700 Subject: [PATCH 1/5] feat(apps): block net/fetch/child_process during local execution Closes a gap the build-time checks in ast-parsing/ can't reach: those only scan the customer's own .backend.ts file, so a third-party dependency's own net/http/fetch usage (e.g. a Postgres/Redis client) was invisible to them, and nothing else stopped it once local execution moved in-process (no Deno, no process boundary). Blocks net.Socket.prototype.connect, fetch, and child_process's spawn/exec/execSync for the duration of a local execution, exempting only the internal $.Actions call via a ref-counted allow scope (so concurrent $.Actions calls within a single execution don't fight over re-blocking). Co-Authored-By: Claude --- .../apps/src/vite/local-execution.test.ts | 93 ++++++++++ .../plugins/apps/src/vite/local-execution.ts | 15 +- .../apps/src/vite/network-guard.test.ts | 160 ++++++++++++++++++ .../plugins/apps/src/vite/network-guard.ts | 141 +++++++++++++++ 4 files changed, 407 insertions(+), 2 deletions(-) create mode 100644 packages/plugins/apps/src/vite/network-guard.test.ts create mode 100644 packages/plugins/apps/src/vite/network-guard.ts diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index 16b23e6d9..e35904668 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -327,6 +327,99 @@ describe('local-execution — executeScriptLocally', () => { }); }); + describe('network/subprocess guard', () => { + test('Should reject when the customer function tries a raw net.Socket connection', async () => { + await expect( + executeScriptLocally( + func, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const net = require('net'); + return new net.Socket().connect(80, 'example.com'); + }, + }), + mockLogger, + ), + ).rejects.toThrow(/Network access is not allowed/); + }); + + test('Should reject when the customer function tries a raw fetch() call', async () => { + await expect( + executeScriptLocally( + func, + [], + stubExecuteAction, + loadModuleReturning({ example: () => fetch('https://example.com') }), + mockLogger, + ), + ).rejects.toThrow(/Network access is not allowed/); + }); + + test('Should reject when the customer function tries to spawn a subprocess', async () => { + await expect( + executeScriptLocally( + func, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const child_process = require('child_process'); + return child_process.execSync('curl https://example.com'); + }, + }), + mockLogger, + ), + ).rejects.toThrow(/Spawning a subprocess is not allowed/); + }); + + test('Should still let a real $.Actions call through while the rest of the function is network-blocked', async () => { + const executeAction = jest.fn().mockResolvedValue({ ok: true }); + const result = await executeScriptLocally( + func, + [], + executeAction, + loadModuleReturning({ + example: async () => { + const actionResult = await ( + globalThis as Record + ).$.Actions.slack.chat.postMessage({ inputs: { text: 'hi' } }); + // A raw fetch attempted right after the sanctioned + // $.Actions call must still be blocked — the + // exemption is scoped to the one call, not the rest + // of the function. + await expect(fetch('https://example.com')).rejects.toThrow( + /Network access is not allowed/, + ); + return actionResult; + }, + }), + mockLogger, + ); + expect(result).toEqual({ data: { ok: true } }); + expect(executeAction).toHaveBeenCalledWith( + 'com.datadoghq.slack.chat.postMessage', + { text: 'hi' }, + undefined, + ); + }); + + test('Should restore real network access after execution, for whatever the dev server itself does next', async () => { + const realFetch = globalThis.fetch; + await executeScriptLocally( + func, + [], + stubExecuteAction, + loadModuleReturning({ example: () => 'fine' }), + mockLogger, + ); + expect(globalThis.fetch).toBe(realFetch); + }); + }); + describe('serialization of concurrent executions', () => { beforeEach(() => { delete (globalThis as Record)[ORDER_MARKER]; diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index 13ad5a4ec..5bb53299e 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -21,6 +21,8 @@ import type { Logger } from '@dd/core/types'; import type { BackendFunction } from '../backend/types'; +import { runAllowed, runBlocked } from './network-guard'; + type BackendOutputs = { data: unknown }; interface ActionCallArgs { @@ -102,6 +104,10 @@ function enqueue(run: () => Promise): Promise { * $.Actions.slack.chat.postMessage) to a callable that invokes * `executeAction` directly, in-process — no IPC serialization needed, since * there's no separate process to cross. + * + * `executeAction` is wrapped in `runAllowed` — the one sanctioned network + * call exempted from the `runBlocked` guard `runScriptLocally` wraps the + * customer's function in. See `network-guard.ts`. */ function makeActionsProxy(executeAction: ExecuteAction, pathParts: string[] = []): unknown { return new Proxy(function () {}, { @@ -123,7 +129,7 @@ function makeActionsProxy(executeAction: ExecuteAction, pathParts: string[] = [] ); } const fqn = `com.datadoghq.${pathParts.join('.')}`; - return executeAction(fqn, inputs, connectionId); + return runAllowed(() => executeAction(fqn, inputs, connectionId)); }, }); } @@ -270,7 +276,12 @@ async function runScriptLocally( throw new Error(`"${func.name}" is not a function exported from ${func.absolutePath}`); } - const result = await fn(...args); + // Blocks net/fetch/child_process for the duration of the customer's + // function call only — loadModule and the registration calls above + // (both Vite's own transform pipeline, no network) run unguarded. + // $.Actions calls made from inside fn are exempted via `runAllowed` + // in `makeActionsProxy`. See network-guard.ts. + const result = await runBlocked(() => fn(...args)); return { data: assertJsonSerializable(result, func) }; }; diff --git a/packages/plugins/apps/src/vite/network-guard.test.ts b/packages/plugins/apps/src/vite/network-guard.test.ts new file mode 100644 index 000000000..dc166ed93 --- /dev/null +++ b/packages/plugins/apps/src/vite/network-guard.test.ts @@ -0,0 +1,160 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +/* global globalThis */ + +import child_process from 'child_process'; +import net from 'net'; + +import { runAllowed, runBlocked } from './network-guard'; + +describe('network-guard', () => { + describe('runBlocked', () => { + test('Should block a raw net.Socket.connect() call made inside fn', async () => { + await expect( + runBlocked(async () => { + new net.Socket().connect(80, 'example.com'); + }), + ).rejects.toThrow(/Network access is not allowed/); + }); + + test('Should block a fetch() call made inside fn', async () => { + await expect( + runBlocked(async () => { + await fetch('https://example.com'); + }), + ).rejects.toThrow(/Network access is not allowed/); + }); + + test('Should block child_process.spawn/exec/execSync made inside fn', async () => { + await expect( + runBlocked(async () => { + child_process.spawn('curl', ['https://example.com']); + }), + ).rejects.toThrow(/Spawning a subprocess is not allowed/); + await expect( + runBlocked(async () => { + child_process.exec('curl https://example.com'); + }), + ).rejects.toThrow(/Spawning a subprocess is not allowed/); + await expect( + runBlocked(async () => { + child_process.execSync('curl https://example.com'); + }), + ).rejects.toThrow(/Spawning a subprocess is not allowed/); + }); + + test('Should restore the real net.Socket.connect after fn resolves', async () => { + const realConnect = net.Socket.prototype.connect; + await runBlocked(async () => undefined); + expect(net.Socket.prototype.connect).toBe(realConnect); + }); + + test('Should restore the real fetch after fn resolves', async () => { + const realFetch = globalThis.fetch; + await runBlocked(async () => undefined); + expect(globalThis.fetch).toBe(realFetch); + }); + + test('Should restore the real network functions even when fn throws', async () => { + const realConnect = net.Socket.prototype.connect; + const realFetch = globalThis.fetch; + await expect( + runBlocked(async () => { + throw new Error('customer function boom'); + }), + ).rejects.toThrow('customer function boom'); + expect(net.Socket.prototype.connect).toBe(realConnect); + expect(globalThis.fetch).toBe(realFetch); + }); + + test('Should not block a subsequent, separate runBlocked call after an earlier one already restored', async () => { + await expect( + runBlocked(async () => { + throw new Error('first execution boom'); + }), + ).rejects.toThrow('first execution boom'); + + // Confirms the guard doesn't leak a "still blocked" state across + // executions the way a naive boolean (never reset on throw) + // could. + const result = await runBlocked(async () => 'second execution result'); + expect(result).toBe('second execution result'); + }); + }); + + describe('runAllowed', () => { + test('Should let a real network call through when nested inside runBlocked', async () => { + const fetchMock = jest.fn().mockResolvedValue('real response'); + const originalFetch = globalThis.fetch; + (globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch; + + try { + const result = await runBlocked(async () => + runAllowed(async () => fetch('https://api.datadoghq.com')), + ); + expect(result).toBe('real response'); + expect(fetchMock).toHaveBeenCalledWith('https://api.datadoghq.com'); + } finally { + (globalThis as { fetch: typeof fetch }).fetch = originalFetch; + } + }); + + test('Should re-block network once the allowed call finishes, while the outer execution is still running', async () => { + await runBlocked(async () => { + await runAllowed(async () => undefined); + await expect(fetch('https://example.com')).rejects.toThrow( + /Network access is not allowed/, + ); + }); + }); + + test('Should keep network allowed while two concurrent allowed calls overlap, and only re-block once the last one finishes', async () => { + const fetchMock = jest.fn().mockResolvedValue('ok'); + const originalFetch = globalThis.fetch; + (globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch; + const order: string[] = []; + + try { + await runBlocked(async () => { + const first = runAllowed(async () => { + order.push('first-start'); + await new Promise((r) => setTimeout(r, 20)); + order.push('first-end'); + }); + const second = runAllowed(async () => { + order.push('second-start'); + // Finishes before `first` — if re-blocking were a naive + // boolean instead of a depth counter, this would + // re-block network while `first` is still mid-flight. + order.push('second-end'); + }); + + await second; + // Network must still be allowed here: `first` is still in flight. + await expect(fetch('https://example.com')).resolves.toBe('ok'); + await first; + }); + } finally { + (globalThis as { fetch: typeof fetch }).fetch = originalFetch; + } + + expect(order).toEqual(['first-start', 'second-start', 'second-end', 'first-end']); + expect(fetchMock).toHaveBeenCalledWith('https://example.com'); + }); + + test('Should still re-block after the allowed call finishes even if it throws', async () => { + await runBlocked(async () => { + await expect( + runAllowed(async () => { + throw new Error('action call failed'); + }), + ).rejects.toThrow('action call failed'); + await expect(fetch('https://example.com')).rejects.toThrow( + /Network access is not allowed/, + ); + }); + }); + }); +}); diff --git a/packages/plugins/apps/src/vite/network-guard.ts b/packages/plugins/apps/src/vite/network-guard.ts new file mode 100644 index 000000000..da4f7810a --- /dev/null +++ b/packages/plugins/apps/src/vite/network-guard.ts @@ -0,0 +1,141 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +/* global globalThis */ + +import child_process from 'child_process'; +import net from 'net'; + +/** + * Closes a gap the build-time checks in `../backend/ast-parsing` can't reach: + * those only scan the customer's own `*.backend.ts` file, so a third-party + * dependency's own internal `net`/`http`/`fetch` usage (e.g. a Postgres or + * Redis client) is invisible to them. Production's Deno sandbox closes the + * equivalent gap at the OS level (it never grants `--allow-net`, under any + * code path — see `wf-actions-worker`'s `deno.ts`); in-process local + * execution has no process boundary to fall back on, so this closes it at + * the module level instead: block every JS-level path to a real socket for + * the duration of a local execution, and exempt only the one sanctioned + * network call (`$.Actions` → `executeAction`). + * + * Known residual gap, accepted rather than engineered around: a native + * addon that bypasses Node's JS-level `net` stack entirely via its own + * compiled code. Narrower and rarer than the pure-JS case this closes — + * most native modules are for CPU-bound work (crypto, image processing), + * not networking. + */ + +const NETWORK_BLOCKED_MESSAGE = + 'Network access is not allowed directly in backend functions — use $.Actions instead.'; +const SUBPROCESS_BLOCKED_MESSAGE = 'Spawning a subprocess is not allowed in backend functions.'; + +function throwNetworkBlocked(): never { + throw new Error(NETWORK_BLOCKED_MESSAGE); +} + +/** + * `fetch`'s real contract is to always return a Promise, rejecting on + * failure rather than throwing synchronously — a synchronous throw here + * would break any caller using `.catch()` or `expect(...).rejects` directly + * on the call, instead of surfacing as an ordinary fetch failure. + */ +function rejectNetworkBlocked(): Promise { + return Promise.reject(new Error(NETWORK_BLOCKED_MESSAGE)); +} + +function throwSubprocessBlocked(): never { + throw new Error(SUBPROCESS_BLOCKED_MESSAGE); +} + +let savedConnect: typeof net.Socket.prototype.connect | undefined; +let savedFetch: typeof fetch | undefined; +let savedSpawn: typeof child_process.spawn | undefined; +let savedExec: typeof child_process.exec | undefined; +let savedExecSync: typeof child_process.execSync | undefined; + +/** + * Captures whatever is currently installed (the real implementation, or a + * test's mock standing in for it) before overwriting it — never a + * module-load-time snapshot. That's what makes `runAllowed`'s nested + * restore/re-apply correct: each pair captures-then-restores exactly the + * state it found, so nesting composes regardless of call order. + */ +function applyPatches(): void { + savedConnect = net.Socket.prototype.connect; + savedFetch = globalThis.fetch; + savedSpawn = child_process.spawn; + savedExec = child_process.exec; + savedExecSync = child_process.execSync; + + net.Socket.prototype.connect = throwNetworkBlocked as typeof net.Socket.prototype.connect; + globalThis.fetch = rejectNetworkBlocked as typeof fetch; + child_process.spawn = throwSubprocessBlocked as typeof child_process.spawn; + // `exec`'s type carries a `__promisify__` property (it supports + // `util.promisify(exec)`) that a plain function type doesn't + // structurally satisfy — going through `unknown` is the correct escape + // hatch here, not a sign the cast is wrong. + child_process.exec = throwSubprocessBlocked as unknown as typeof child_process.exec; + child_process.execSync = throwSubprocessBlocked as typeof child_process.execSync; +} + +function restorePatches(): void { + if (savedConnect) { + net.Socket.prototype.connect = savedConnect; + } + if (savedFetch) { + globalThis.fetch = savedFetch; + } + if (savedSpawn) { + child_process.spawn = savedSpawn; + } + if (savedExec) { + child_process.exec = savedExec; + } + if (savedExecSync) { + child_process.execSync = savedExecSync; + } +} + +/** + * Runs `fn` with network/subprocess access blocked. Intended to wrap an + * entire local execution (the customer's function body) in + * `local-execution.ts`'s `runScriptLocally`. + */ +export async function runBlocked(fn: () => Promise): Promise { + applyPatches(); + try { + return await fn(); + } finally { + restorePatches(); + } +} + +/** + * Temporarily restores real network access for the duration of `fn`. Only + * meaningful when called from inside an active `runBlocked` scope — used by + * `makeActionsProxy`'s `apply` trap to exempt the one sanctioned network + * call (`executeAction`) without exposing real network to anything else the + * customer's function does. + * + * Ref-counted rather than a boolean: two `$.Actions` calls can legitimately + * overlap within a single execution (e.g. `Promise.all([...])`), and a + * boolean would re-block network the instant the first of two concurrent + * calls finishes, breaking the second one mid-flight. + */ +let allowDepth = 0; + +export async function runAllowed(fn: () => Promise): Promise { + allowDepth += 1; + if (allowDepth === 1) { + restorePatches(); + } + try { + return await fn(); + } finally { + allowDepth -= 1; + if (allowDepth === 0) { + applyPatches(); + } + } +} From 84fd6c49b92c47b7d280b20b82647f5a33ab5560 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Mon, 10 Aug 2026 12:51:26 -0700 Subject: [PATCH 2/5] fix(apps): force-reset network guard patches on abandoned/timed-out executions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promise.race in runScriptLocally abandons whichever of run()/timeout loses without cancelling it. A hung customer function (its promise never settling) meant runBlocked's own finally never ran, leaving net.Socket.connect/fetch/child_process patched to throw for the rest of the process — poisoning every later local execution, and in CI, leaking into unrelated test files that happened to run afterward in the same Jest worker (e.g. rollupConfig.test.ts's real esbuild spawn). forceReset() is a hard backstop independent of runBlocked/runAllowed's own try/finally: it unconditionally restores the real functions and zeroes the ref-count. runScriptLocally calls it directly from the timeout timer, and network-guard.test.ts/local-execution.test.ts now call it in an afterEach regardless of test outcome, since these are real process-wide Node singletons, not per-test-file sandboxed state. --- .../plugins/apps/src/vite/local-execution.ts | 13 ++++++++++++- .../plugins/apps/src/vite/network-guard.test.ts | 14 +++++++++++++- packages/plugins/apps/src/vite/network-guard.ts | 16 ++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index 5bb53299e..88e7b37db 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -21,7 +21,7 @@ import type { Logger } from '@dd/core/types'; import type { BackendFunction } from '../backend/types'; -import { runAllowed, runBlocked } from './network-guard'; +import { forceReset, runAllowed, runBlocked } from './network-guard'; type BackendOutputs = { data: unknown }; @@ -288,6 +288,17 @@ async function runScriptLocally( let timer: ReturnType | undefined; const timeout = new Promise((_resolve, reject) => { timer = setTimeout(() => { + // `Promise.race` below abandons whichever of `run()`/`timeout` + // loses — it doesn't cancel it. A hung `fn` (its returned + // promise never settling) means `run()`'s own `runBlocked` call + // never reaches its `finally`, so the block patch would + // otherwise stay applied for the rest of this process — blocking + // real network/subprocess access for every local execution (and + // anything else in the dev server) that comes after this one. + // Force it back to the real functions here, since the timer + // firing is exactly the signal that `run()` can no longer be + // trusted to unwind its own guard scope in bounded time. + forceReset(); reject(new Error(`Local execution of "${func.name}" timed out after ${timeoutMs}ms`)); }, timeoutMs); }); diff --git a/packages/plugins/apps/src/vite/network-guard.test.ts b/packages/plugins/apps/src/vite/network-guard.test.ts index dc166ed93..6a97541b0 100644 --- a/packages/plugins/apps/src/vite/network-guard.test.ts +++ b/packages/plugins/apps/src/vite/network-guard.test.ts @@ -7,7 +7,19 @@ import child_process from 'child_process'; import net from 'net'; -import { runAllowed, runBlocked } from './network-guard'; +import { forceReset, runAllowed, runBlocked } from './network-guard'; + +/** + * Hard backstop, independent of whatever a given test's own assertions do: + * `net`/`fetch`/`child_process` are real, process-wide singletons, not + * per-test-file sandboxed state — a test that leaves them patched (e.g. a + * bug in one of these tests that skips its own restore) would otherwise leak + * into every test that runs afterward in the same Jest worker, including + * completely unrelated test files elsewhere in the suite. + */ +afterEach(() => { + forceReset(); +}); describe('network-guard', () => { describe('runBlocked', () => { diff --git a/packages/plugins/apps/src/vite/network-guard.ts b/packages/plugins/apps/src/vite/network-guard.ts index da4f7810a..9ef8782fe 100644 --- a/packages/plugins/apps/src/vite/network-guard.ts +++ b/packages/plugins/apps/src/vite/network-guard.ts @@ -139,3 +139,19 @@ export async function runAllowed(fn: () => Promise): Promise { } } } + +/** + * Unconditionally restores the real network/subprocess functions and resets + * `allowDepth` to 0, independently of `runBlocked`/`runAllowed`'s own + * try/finally. Those only unwind when the `fn` they wrap actually settles — + * a `fn` that never resolves (a hung customer function abandoned by + * `runScriptLocally`'s timeout race, a test that forgets to await its own + * assertions) leaves the block patch applied forever, since nothing ever + * reaches the `finally`. This is the caller's-side backstop for exactly that + * case: safe to call at any time, including when nothing is currently + * patched (`restorePatches` is a no-op then) or when called more than once. + */ +export function forceReset(): void { + restorePatches(); + allowDepth = 0; +} From f27f967adb2c4a361b99fe5f5f371c939f6e1476 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Mon, 10 Aug 2026 12:52:21 -0700 Subject: [PATCH 3/5] test(apps): cover concurrent $.Actions overlap through executeScriptLocally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit network-guard.test.ts already proves runAllowed's ref-counting at the unit level, calling it directly. Adds the same proof through the real path a customer's code takes: executeScriptLocally's Promise.all of two $.Actions calls, through makeActionsProxy's apply trap, with the mocked ExecuteAction making its own real fetch call to stand in for the network call the dev server's own implementation makes — network must stay allowed for the slower call the entire time the faster one is finishing and re-blocking. Also adds a regression test for the timeout/forceReset fix, and the same afterEach safety net as network-guard.test.ts. --- .../apps/src/vite/local-execution.test.ts | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index e35904668..2db7cc42f 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -10,6 +10,7 @@ import type { BackendFunction } from '../backend/types'; import type { ExecuteAction, LoadModule } from './local-execution'; import { executeScriptLocally } from './local-execution'; +import { forceReset } from './network-guard'; const func: BackendFunction = { relativePath: 'src/example', @@ -20,6 +21,19 @@ const func: BackendFunction = { const stubExecuteAction: ExecuteAction = async (fqn) => ({ data: null, stub: true, fqn }); +/** + * Hard backstop, independent of whatever a given test's own execution path + * does: `net`/`fetch`/`child_process` are real, process-wide singletons, not + * per-test-file sandboxed state — a test that leaves them patched (e.g. a + * hung function abandoned by the timeout test below) would otherwise leak + * into every test that runs afterward in the same Jest worker, including + * completely unrelated test files. Runs after every test regardless of + * outcome, not just the ones that exercise the guard directly. + */ +afterEach(() => { + forceReset(); +}); + /** * A `loadModule` double that resolves the customer's own function from a map * and rejects anything else (e.g. the action-catalog/apps-backend probes), @@ -170,6 +184,33 @@ describe('local-execution — executeScriptLocally', () => { ).rejects.toThrow(/timed out after 50ms/); }); + test('Should restore real network/subprocess access after a timeout, even though the hung function itself is still abandoned in the background', async () => { + const realFetch = globalThis.fetch; + // eslint-disable-next-line @typescript-eslint/no-require-imports + const realSpawn = require('child_process').spawn; + + await expect( + executeScriptLocally( + func, + [], + stubExecuteAction, + loadModuleReturning({ example: () => new Promise(() => {}) }), + mockLogger, + 50, + ), + ).rejects.toThrow(/timed out after 50ms/); + + // `run()` (wrapping the hung function) is abandoned, not cancelled — + // it's still "running" forever in the background. Without a hard + // reset independent of its own try/finally, real network/subprocess + // access would stay blocked for the rest of this process, breaking + // every execution (and anything else in the dev server) that comes + // after this one. See network-guard.ts's `forceReset`. + expect(globalThis.fetch).toBe(realFetch); + // eslint-disable-next-line @typescript-eslint/no-require-imports + expect(require('child_process').spawn).toBe(realSpawn); + }); + test('Should never expose an auth token to the customer module — only backendFunctionArgs, Actions, and Source are visible on globalThis.$', async () => { const result = await executeScriptLocally( func, @@ -418,6 +459,68 @@ describe('local-execution — executeScriptLocally', () => { ); expect(globalThis.fetch).toBe(realFetch); }); + + test('Should keep network access allowed through two real, overlapping $.Actions calls made concurrently via Promise.all, without either blocking the other mid-flight', async () => { + // network-guard.test.ts already proves the ref-counting itself + // at the unit level, calling `runAllowed` directly. This proves + // it holds through the real path a customer's own code takes: + // `executeScriptLocally` → the customer's `Promise.all` → + // `makeActionsProxy`'s `apply` trap → `runAllowed`. `fetchMock` + // stands in for the real network call the dev server's own + // `ExecuteAction` implementation makes (e.g. a `preview-async` + // HTTP request) — if the ref-counting were a naive boolean + // instead of a depth counter, the fast call finishing first + // would re-block network and this call, made from inside the + // still-in-flight slow call, would throw. + const order: string[] = []; + const executeAction: ExecuteAction = async (fqn) => { + const label = fqn.includes('slow') ? 'slow' : 'fast'; + order.push(`${label}-start`); + if (label === 'slow') { + await new Promise((r) => setTimeout(r, 20)); + } + await fetch(`https://example.com/${label}`); + order.push(`${label}-end`); + return { ok: true, fqn }; + }; + + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn().mockResolvedValue('ok'); + (globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch; + + let result: { data: unknown }; + try { + result = await executeScriptLocally( + func, + [], + executeAction, + loadModuleReturning({ + example: () => { + const $ = (globalThis as Record).$; + return Promise.all([ + $.Actions.slow.action({ inputs: {} }), + $.Actions.fast.action({ inputs: {} }), + ]); + }, + }), + mockLogger, + ); + } finally { + (globalThis as { fetch: typeof fetch }).fetch = originalFetch; + } + + expect(result.data).toEqual([ + { ok: true, fqn: 'com.datadoghq.slow.action' }, + { ok: true, fqn: 'com.datadoghq.fast.action' }, + ]); + // The fast call's fetch must resolve — and the slow call's own + // fetch, made after the fast call's whole allow scope has + // already exited — must resolve too, proving network stayed + // allowed for the slow call the entire time. + expect(order).toEqual(['slow-start', 'fast-start', 'fast-end', 'slow-end']); + expect(fetchMock).toHaveBeenCalledWith('https://example.com/slow'); + expect(fetchMock).toHaveBeenCalledWith('https://example.com/fast'); + }); }); describe('serialization of concurrent executions', () => { From cac7f4e642211e06bf2a9ccbb3b4d0bfa593154f Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Mon, 10 Aug 2026 14:47:44 -0700 Subject: [PATCH 4/5] test(apps): extract shared loadModule test double into @dd/tests mocks local-execution.test.ts and dev-server.test.ts (build-plugins#481) each defined their own near-identical LoadModule resolver double. Factor the common resolve-or-throw logic into moduleResolverFor in the shared mocks helper so both can build on it instead of duplicating it. --- .../apps/src/vite/local-execution.test.ts | 9 ++------- packages/tests/src/_jest/helpers/mocks.ts | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index 2db7cc42f..ec67ec2e9 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -4,7 +4,7 @@ /* global globalThis */ -import { mockLogger } from '@dd/tests/_jest/helpers/mocks'; +import { mockLogger, moduleResolverFor } from '@dd/tests/_jest/helpers/mocks'; import type { BackendFunction } from '../backend/types'; @@ -40,12 +40,7 @@ afterEach(() => { * matching the common case where neither package is installed. */ function loadModuleReturning(exports: Record): LoadModule { - return async (specifier: string) => { - if (specifier === func.absolutePath) { - return exports; - } - throw new Error(`Cannot find module '${specifier}'`); - }; + return moduleResolverFor(func, exports); } const ORDER_MARKER = '__ddLocalExecutionTestOrder'; diff --git a/packages/tests/src/_jest/helpers/mocks.ts b/packages/tests/src/_jest/helpers/mocks.ts index decb1924f..8ea0981d1 100644 --- a/packages/tests/src/_jest/helpers/mocks.ts +++ b/packages/tests/src/_jest/helpers/mocks.ts @@ -2,6 +2,8 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +import type { BackendFunction } from '@dd/apps-plugin/backend/types'; +import type { LoadModule } from '@dd/apps-plugin/vite/local-execution'; import { DEFAULT_SITE } from '@dd/core/constants'; import { checkFile, @@ -120,6 +122,23 @@ export const getMockTimeLogger = (overrides: Partial = {}): TimeLogg return mockTimer; }; +/** + * Builds a `loadModule`-shaped resolver that returns `exports` for `func`'s + * absolute path and rejects any other specifier — matching what a real + * module loader returns when only the target module is actually resolvable. + */ +export const moduleResolverFor = ( + func: BackendFunction, + exports: Record, +): LoadModule => { + return async (specifier: string) => { + if (specifier === func.absolutePath) { + return exports; + } + throw new Error(`Cannot find module '${specifier}'`); + }; +}; + export const mockLogFn = jest.fn((text: any, level: LogLevel) => {}); export const getMockLogger = (overrides: Partial = {}): Logger => ({ getLogger: jest.fn(), From 7d6f536c8eec4f8d4fc158038897088394953c81 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Tue, 11 Aug 2026 12:36:42 -0700 Subject: [PATCH 5/5] fix(apps): mark local execution's ssrLoadModule request for the real function body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server.ssrLoadModule(func.absolutePath) goes through the same transform hook (vite/index.ts) that rewrites *.backend.ts into the client-side RPC-proxy stub — so local execution's "real" import can actually still be the proxy stub, which crashes since globalThis.DD_APPS_RUNTIME doesn't exist server-side. Every existing test here mocks loadModule directly, so none of them exercise the real transform pipeline and would catch this. Append the same query-suffix marker introduced in #481 (matching Vite's own ?raw/?url convention) so the shared transform hook can recognize this specific request and skip proxy generation for it. The transform-hook side of this fix lives in #481, since that's where local execution is actually wired to a real, plugin-registered dev server — this PR only needs its own call site and mocks to stay consistent with that contract so the two branches reconcile cleanly whichever merges first. --- packages/plugins/apps/src/constants.ts | 12 ++++++++++++ .../plugins/apps/src/vite/local-execution.test.ts | 3 ++- packages/plugins/apps/src/vite/local-execution.ts | 5 ++++- packages/tests/src/_jest/helpers/mocks.ts | 9 ++++++--- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/plugins/apps/src/constants.ts b/packages/plugins/apps/src/constants.ts index db612df45..b7bc9d897 100644 --- a/packages/plugins/apps/src/constants.ts +++ b/packages/plugins/apps/src/constants.ts @@ -10,6 +10,18 @@ export const PLUGIN_NAME: PluginName = 'datadog-apps-plugin' as const; export const APPS_API_PATH = 'api/unstable/app-builder-code/apps'; export const ARCHIVE_FILENAME = 'datadog-apps-assets.zip'; export const BACKEND_FILE_RE = /\.backend\.(ts|tsx|js|jsx)$/; + +/** + * Query suffix local execution appends to its `loadModule()`/`ssrLoadModule()` + * call so the transform hook below can tell "give me the real function body to + * run in-process" apart from a normal frontend import of the same file (which + * needs the client-side RPC-proxy stub instead). Follows Vite's own `?raw`/ + * `?url`-style query-suffix convention rather than branching on the generic + * `options.ssr` flag, which would also match unrelated future SSR-context + * loads of the same file. + */ +export const LOCAL_EXECUTION_LOAD_SUFFIX = '?dd-local-exec'; +export const LOCAL_EXECUTION_LOAD_RE = /\.backend\.(ts|tsx|js|jsx)\?dd-local-exec$/; export const BACKEND_CODE_EXTENSIONS = [ '.ts', '.tsx', diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index ec67ec2e9..239374282 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -7,6 +7,7 @@ import { mockLogger, moduleResolverFor } from '@dd/tests/_jest/helpers/mocks'; import type { BackendFunction } from '../backend/types'; +import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; import type { ExecuteAction, LoadModule } from './local-execution'; import { executeScriptLocally } from './local-execution'; @@ -271,7 +272,7 @@ describe('local-execution — executeScriptLocally', () => { | undefined; const loadModule: LoadModule = async (specifier: string) => { - if (specifier === func.absolutePath) { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { return { example: async () => registeredImpl?.('com.datadoghq.slack.chat.postMessage', { diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index 88e7b37db..cb7e363f2 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -20,6 +20,7 @@ import type { Logger } from '@dd/core/types'; import type { BackendFunction } from '../backend/types'; +import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; import { forceReset, runAllowed, runBlocked } from './network-guard'; @@ -270,7 +271,9 @@ async function runScriptLocally( registerBackendRuntimeIfInstalled(loadModule, $), ]); - const mod = await loadModule(func.absolutePath); + // The suffix marks this as local execution's own request for the real + // function body — see LOCAL_EXECUTION_LOAD_SUFFIX's doc comment. + const mod = await loadModule(func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX); const fn = mod[func.name]; if (typeof fn !== 'function') { throw new Error(`"${func.name}" is not a function exported from ${func.absolutePath}`); diff --git a/packages/tests/src/_jest/helpers/mocks.ts b/packages/tests/src/_jest/helpers/mocks.ts index 8ea0981d1..626be2530 100644 --- a/packages/tests/src/_jest/helpers/mocks.ts +++ b/packages/tests/src/_jest/helpers/mocks.ts @@ -3,6 +3,7 @@ // Copyright 2019-Present Datadog, Inc. import type { BackendFunction } from '@dd/apps-plugin/backend/types'; +import { LOCAL_EXECUTION_LOAD_SUFFIX } from '@dd/apps-plugin/constants'; import type { LoadModule } from '@dd/apps-plugin/vite/local-execution'; import { DEFAULT_SITE } from '@dd/core/constants'; import { @@ -124,15 +125,17 @@ export const getMockTimeLogger = (overrides: Partial = {}): TimeLogg /** * Builds a `loadModule`-shaped resolver that returns `exports` for `func`'s - * absolute path and rejects any other specifier — matching what a real - * module loader returns when only the target module is actually resolvable. + * absolute path (as requested by local execution's own suffixed specifier — + * see `LOCAL_EXECUTION_LOAD_SUFFIX`) and rejects any other specifier — + * matching what a real module loader returns when only the target module is + * actually resolvable. */ export const moduleResolverFor = ( func: BackendFunction, exports: Record, ): LoadModule => { return async (specifier: string) => { - if (specifier === func.absolutePath) { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { return exports; } throw new Error(`Cannot find module '${specifier}'`);