From fce1e7181284b978579323ed894ed6d55a9deb57 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Wed, 9 Sep 2026 22:19:28 -0400 Subject: [PATCH 1/8] feat(apps): resolve Custom Credentials from a local override file No server-side resolution endpoint exists for Custom Credentials, so local execution reads them from a datadog-app.local.json file the developer maintains themselves in the project root, mirroring Rapid's own config/dev.json convention for local secrets. runScriptLocally resolves the file alongside the existing network/env guards and feeds the result into buildScopedEnv. The dev-server priming load (loadCustomerModuleEntry) is left unresolved on purpose: it has no projectRoot and already runs outside the guarded scope. --- packages/plugins/apps/README.md | 7 ++ .../vite/custom-credentials-resolver.test.ts | 76 +++++++++++++++++++ .../src/vite/custom-credentials-resolver.ts | 65 ++++++++++++++++ packages/plugins/apps/src/vite/env-guard.ts | 3 +- .../apps/src/vite/local-execution.test.ts | 45 +++++++++++ .../plugins/apps/src/vite/local-execution.ts | 18 ++++- 6 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 packages/plugins/apps/src/vite/custom-credentials-resolver.test.ts create mode 100644 packages/plugins/apps/src/vite/custom-credentials-resolver.ts diff --git a/packages/plugins/apps/README.md b/packages/plugins/apps/README.md index e5af151c9..44af78e11 100644 --- a/packages/plugins/apps/README.md +++ b/packages/plugins/apps/README.md @@ -10,6 +10,7 @@ A Vite plugin that builds a deployable Datadog Apps package. Publishing is owned - [Configuration](#configuration) - [Development server authentication](#development-server-authentication) +- [Custom Credentials for local execution](#custom-credentials-for-local-execution) - [Package output](#package-output) - [apps.enable](#appsenable) - [apps.include](#appsinclude) @@ -43,6 +44,12 @@ passes it to the dev server via `DD_OAUTH_ACCESS_TOKEN`. When no credentials are configured, backend function execution is unavailable and the dev server tells you to start it with `datadog-apps dev`. +## Custom Credentials for local execution + +Backend functions read Custom Credentials from a `datadog-app.local.json` file in the project +root — a flat JSON object mapping env var name to value. Add this file to your project's +`.gitignore`; it holds real secret values. + ## Package output A production `vite build` writes `datadog-app-assets.zip` beside the Vite output. The ZIP contains `frontend/`, `backend/`, and `manifest.json`. The app's identity is resolved by `@datadog/apps-cli` at deploy time. diff --git a/packages/plugins/apps/src/vite/custom-credentials-resolver.test.ts b/packages/plugins/apps/src/vite/custom-credentials-resolver.test.ts new file mode 100644 index 000000000..81c976b4a --- /dev/null +++ b/packages/plugins/apps/src/vite/custom-credentials-resolver.test.ts @@ -0,0 +1,76 @@ +// 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. + +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { + CUSTOM_CREDENTIALS_LOCAL_FILENAME, + resolveCustomCredentials, +} from './custom-credentials-resolver'; + +describe('resolveCustomCredentials', () => { + let projectRoot: string; + + beforeEach(async () => { + projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'custom-credentials-resolver-')); + }); + + afterEach(async () => { + await fs.rm(projectRoot, { recursive: true, force: true }); + }); + + it('resolves to {} when the file does not exist', async () => { + await expect(resolveCustomCredentials(projectRoot)).resolves.toEqual({}); + }); + + it('resolves the flat object of env var name to value', async () => { + await fs.writeFile( + path.join(projectRoot, CUSTOM_CREDENTIALS_LOCAL_FILENAME), + JSON.stringify({ STRIPE_API_KEY: 'sk_test_123' }), + ); + + await expect(resolveCustomCredentials(projectRoot)).resolves.toEqual({ + STRIPE_API_KEY: 'sk_test_123', + }); + }); + + it('rejects malformed JSON instead of silently returning {}', async () => { + await fs.writeFile( + path.join(projectRoot, CUSTOM_CREDENTIALS_LOCAL_FILENAME), + '{ not valid json', + ); + + await expect(resolveCustomCredentials(projectRoot)).rejects.toThrow(/not valid JSON/); + }); + + it('rejects a top-level array', async () => { + await fs.writeFile( + path.join(projectRoot, CUSTOM_CREDENTIALS_LOCAL_FILENAME), + JSON.stringify(['STRIPE_API_KEY']), + ); + + await expect(resolveCustomCredentials(projectRoot)).rejects.toThrow(/flat JSON object/); + }); + + it('rejects a non-string value, naming the offending key', async () => { + await fs.writeFile( + path.join(projectRoot, CUSTOM_CREDENTIALS_LOCAL_FILENAME), + JSON.stringify({ STRIPE_API_KEY: 12345 }), + ); + + await expect(resolveCustomCredentials(projectRoot)).rejects.toThrow( + /"STRIPE_API_KEY".*must be a string/, + ); + }); + + it('propagates a non-ENOENT filesystem error instead of treating it as "missing"', async () => { + // A directory where a file is expected fails to read with EISDIR, not ENOENT — resolving + // to {} here would hide a real misconfiguration (e.g. a stray directory shadowing the file). + await fs.mkdir(path.join(projectRoot, CUSTOM_CREDENTIALS_LOCAL_FILENAME)); + + await expect(resolveCustomCredentials(projectRoot)).rejects.toThrow(); + }); +}); diff --git a/packages/plugins/apps/src/vite/custom-credentials-resolver.ts b/packages/plugins/apps/src/vite/custom-credentials-resolver.ts new file mode 100644 index 000000000..f52aa66f7 --- /dev/null +++ b/packages/plugins/apps/src/vite/custom-credentials-resolver.ts @@ -0,0 +1,65 @@ +// 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 NodeJS */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; + +/** One process.env entry per secret the developer has supplied locally, keyed the same way production's resolved Custom Credentials env vars are (e.g. `STRIPE_API_KEY`). */ +export type ResolvedCustomCredentials = Record; + +/** + * A git-ignored file the developer maintains themselves with real Custom Credentials values for + * local execution — no server call, no new auth model, since no server-side resolution endpoint + * exists for this. Same convention Rapid already documents for local secrets (`config/dev.json`, + * gitignored), applied here to Custom Credentials. + */ +export const CUSTOM_CREDENTIALS_LOCAL_FILENAME = 'datadog-app.local.json'; + +/** + * Resolves Custom Credentials for local execution by reading {@link CUSTOM_CREDENTIALS_LOCAL_FILENAME} + * from the project root. A missing file resolves to `{}` — most projects won't have one — but a + * present-and-malformed file throws, since silently ignoring a typo would make a declared secret + * look identical to an undeclared one. + */ +export async function resolveCustomCredentials( + projectRoot: string, +): Promise { + const filePath = path.join(projectRoot, CUSTOM_CREDENTIALS_LOCAL_FILENAME); + let raw: string; + try { + raw = await fs.readFile(filePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return {}; + } + throw error; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new Error( + `${CUSTOM_CREDENTIALS_LOCAL_FILENAME} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, + ); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error( + `${CUSTOM_CREDENTIALS_LOCAL_FILENAME} must be a flat JSON object mapping env var names to string values.`, + ); + } + + const resolved: ResolvedCustomCredentials = {}; + for (const [key, value] of Object.entries(parsed)) { + if (typeof value !== 'string') { + throw new Error( + `${CUSTOM_CREDENTIALS_LOCAL_FILENAME}'s "${key}" value must be a string, got ${typeof value}.`, + ); + } + resolved[key] = value; + } + return resolved; +} diff --git a/packages/plugins/apps/src/vite/env-guard.ts b/packages/plugins/apps/src/vite/env-guard.ts index ec2233dd3..f1f6150d4 100644 --- a/packages/plugins/apps/src/vite/env-guard.ts +++ b/packages/plugins/apps/src/vite/env-guard.ts @@ -33,7 +33,8 @@ const nativeReadlinkSync = fs.readlinkSync; export const SAFE_ENV_KEYS = ['PATH', 'HOME', 'NODE_ENV', 'TMPDIR'] as const; -// customCredentials is currently always {} — Custom Credential resolution for local execution is still undecided, so those values stay unset here rather than read from the real environment. +// customCredentials comes from custom-credentials-resolver.ts's resolveCustomCredentials — a +// developer-maintained local file, empty by default. export function buildScopedEnv(customCredentials: Record): Record { const scoped: Record = {}; for (const key of SAFE_ENV_KEYS) { diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index ba54c26d0..fdc27cd16 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -7,12 +7,16 @@ import type { Logger } from '@dd/core/types'; import { installFakeProcessEnv } from '@dd/tests/_jest/helpers/env'; import { mockLogFn, mockLogger, moduleResolverFor } from '@dd/tests/_jest/helpers/mocks'; +import fsPromises from 'fs/promises'; import fs from 'fs'; +import os from 'os'; +import path from 'path'; import * as shared from '../backend/shared'; import type { BackendFunction } from '../backend/types'; import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; +import * as customCredentialsResolver from './custom-credentials-resolver'; import { forceResetEnv } from './env-guard'; import { func, @@ -33,6 +37,10 @@ const funcWithConnection: BackendFunction = { ...func, allowedConnectionIds: ['c const TEST_PROJECT_ROOT = '/project'; +// Captured before beforeEach's spyOn ever replaces the export — jest.requireActual would return +// this same, by-then-mocked module object instead of a real one, since it was never jest.mock()'d. +const realResolveCustomCredentials = customCredentialsResolver.resolveCustomCredentials; + interface TestGlobalDollar { backendFunctionArgs: unknown[]; // Left untyped: $.Actions is a Proxy of unbounded, dynamic depth ($.Actions.....(...)), the same shape a real customer's untyped code sees. @@ -50,6 +58,11 @@ beforeEach(() => { // Neither optional SDK is installed by default; tests exercising the "installed" path override this. jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(false); jest.spyOn(shared, 'isDatadogAppsBackendInstalled').mockReturnValue(false); + // Real fs I/O races unpredictably against the fake-timer tests below (TEST_PROJECT_ROOT isn't + // a real directory anyway); tests covering the real file read live in + // custom-credentials-resolver.test.ts, plus one integration test further down that restores + // the real implementation for its own duration. + jest.spyOn(customCredentialsResolver, 'resolveCustomCredentials').mockResolvedValue({}); }); /** Keeps the existing test call sites concise while every invocation receives a fresh preview context. */ @@ -1182,6 +1195,38 @@ describe('local-execution — executeScriptLocally', () => { expect(result).toEqual({ data: 'ok' }); expect(envSeenDuringRegistration).toBeUndefined(); }); + + test('Should expose a value from a real datadog-app.local.json file as process.env in the customer function', async () => { + jest.spyOn(customCredentialsResolver, 'resolveCustomCredentials').mockImplementation( + realResolveCustomCredentials, + ); + + const projectRoot = await fsPromises.mkdtemp( + path.join(os.tmpdir(), 'local-execution-custom-credentials-'), + ); + try { + await fsPromises.writeFile( + path.join( + projectRoot, + customCredentialsResolver.CUSTOM_CREDENTIALS_LOCAL_FILENAME, + ), + JSON.stringify({ STRIPE_API_KEY: 'sk_test_123' }), + ); + + const result = await executeScriptLocally( + func, + projectRoot, + [], + stubExecuteAction, + loadModuleReturning({ example: () => process.env.STRIPE_API_KEY }), + mockLogger, + ); + + expect(result).toEqual({ data: 'sk_test_123' }); + } finally { + await fsPromises.rm(projectRoot, { recursive: true, force: true }); + } + }); }); test('Should preserve preview context fields while overriding invocation-owned args and Actions', async () => { diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index b5cfaa8af..815d1b307 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -15,6 +15,7 @@ import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; import type { LongPollingOptions } from '../types'; import { resolveLongPolling } from '../validate'; +import { resolveCustomCredentials } from './custom-credentials-resolver'; import type { EnvScopeHandle } from './env-guard'; import { createEpochGuard } from './execution-epoch'; import type { BlockedScopeHandle } from './network-guard'; @@ -241,6 +242,9 @@ export async function loadCustomerModuleEntry( ): Promise> { await getNetworkGuard(); const { buildScopedEnv, runWithScopedEnv } = await getEnvGuard(); + // {} rather than a real resolution: this priming load has no projectRoot, and its top-level + // eval is already outside the guarded scope (see doc comment above) — runScriptLocally is + // what resolves real credentials for function bodies. const scopedEnv = buildScopedEnv({}); return localExecutionResolutionContext.run(new Set(), () => customerModuleLoadContext.run({ assigned: false, value: undefined }, () => @@ -901,10 +905,18 @@ async function runScriptLocally( // toJSON()/getter must run while access is still blocked/scoped. const networkGuardPromise = getNetworkGuard(); const envGuardPromise = getEnvGuard(); - const [{ runBlocked }, { buildScopedEnv, runWithScopedEnv }] = - await Promise.all([networkGuardPromise, envGuardPromise]); + const customCredentialsPromise = resolveCustomCredentials(projectRoot); + const [ + { runBlocked }, + { buildScopedEnv, runWithScopedEnv }, + customCredentials, + ] = await Promise.all([ + networkGuardPromise, + envGuardPromise, + customCredentialsPromise, + ]); rejectIfAbandoned(); - const scopedEnv = buildScopedEnv({}); + const scopedEnv = buildScopedEnv(customCredentials); const data = await runWithScopedEnv( scopedEnv, () => From bc19d83e3af89b28ba66aeae615c4f9dff98e351 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 10 Sep 2026 01:00:32 -0400 Subject: [PATCH 2/8] fix(apps): stop Custom Credentials leaking into JSON parse-error messages Node's JSON.parse error can embed a raw slice of the source text when the malformed token looks like an unquoted value (e.g. `..."API_KEY": sk_live_ab"...`), which flowed through to the dev server's debug log and its HTTP response body. Also fixes a credential literally named "__proto__" being silently dropped instead of resolved, by building the result on a null-prototype object. --- .../vite/custom-credentials-resolver.test.ts | 49 +++++++++++++++++++ .../src/vite/custom-credentials-resolver.ts | 14 ++++-- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/packages/plugins/apps/src/vite/custom-credentials-resolver.test.ts b/packages/plugins/apps/src/vite/custom-credentials-resolver.test.ts index 81c976b4a..283ce40bb 100644 --- a/packages/plugins/apps/src/vite/custom-credentials-resolver.test.ts +++ b/packages/plugins/apps/src/vite/custom-credentials-resolver.test.ts @@ -46,6 +46,32 @@ describe('resolveCustomCredentials', () => { await expect(resolveCustomCredentials(projectRoot)).rejects.toThrow(/not valid JSON/); }); + it('never echoes a real secret value into the parse-error message', async () => { + // V8's own JSON.parse error embeds a slice of the source text around an unquoted token + // (e.g. `..."API_KEY": sk_live_ab"...` for a real credential) — this file writes an + // unquoted token on purpose to trigger that same parse-error shape. Deliberately NOT + // shaped like a real provider key (no digits, no mixed case, no known prefix): GitHub's + // own secret scanning blocked this exact commit twice already for using key-shaped + // fixtures (`sk_live_...`, then `sk_test_...`) even though neither was a real credential. + const secret = 'THIS_TOKEN_MUST_NEVER_LEAK_INTO_ANY_ERROR_MESSAGE'; + await fs.writeFile( + path.join(projectRoot, CUSTOM_CREDENTIALS_LOCAL_FILENAME), + `{"STRIPE_API_KEY": ${secret}}`, + ); + + let thrown: unknown; + try { + await resolveCustomCredentials(projectRoot); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(Error); + const message = (thrown as Error).message; + expect(message).not.toContain(secret); + expect(message).not.toContain(secret.slice(0, 10)); + }); + it('rejects a top-level array', async () => { await fs.writeFile( path.join(projectRoot, CUSTOM_CREDENTIALS_LOCAL_FILENAME), @@ -66,6 +92,29 @@ describe('resolveCustomCredentials', () => { ); }); + it('resolves a credential literally named "__proto__" instead of silently dropping it', async () => { + // A plain {} target makes `resolved['__proto__'] = value` hit Object.prototype's own + // __proto__ setter, which no-ops for a non-object value — the credential just vanishes + // with no error. Object.create(null) avoids the setter entirely. + // Written as a raw string, not an object-literal + JSON.stringify: `{ __proto__: ... }` + // in JS source is special-cased by the *object literal* grammar to set the prototype + // (a no-op here, since the value's a string) rather than create an own property, so + // JSON.stringify would silently drop it before this test ever exercises the resolver. + // JSON.parse has no such special case — "__proto__" is a completely ordinary key there. + await fs.writeFile( + path.join(projectRoot, CUSTOM_CREDENTIALS_LOCAL_FILENAME), + '{"__proto__": "sk_test_proto", "STRIPE_API_KEY": "sk_test_123"}', + ); + + // Bracket access via a variable key, not `resolved.__proto__`, since the latter triggers + // eslint's no-proto rule even though this is reading an ordinary data property here. + const protoKey = '__proto__'; + const resolved = await resolveCustomCredentials(projectRoot); + expect(Object.prototype.hasOwnProperty.call(resolved, protoKey)).toBe(true); + expect(resolved[protoKey]).toBe('sk_test_proto'); + expect(resolved.STRIPE_API_KEY).toBe('sk_test_123'); + }); + it('propagates a non-ENOENT filesystem error instead of treating it as "missing"', async () => { // A directory where a file is expected fails to read with EISDIR, not ENOENT — resolving // to {} here would hide a real misconfiguration (e.g. a stray directory shadowing the file). diff --git a/packages/plugins/apps/src/vite/custom-credentials-resolver.ts b/packages/plugins/apps/src/vite/custom-credentials-resolver.ts index f52aa66f7..559a2ee26 100644 --- a/packages/plugins/apps/src/vite/custom-credentials-resolver.ts +++ b/packages/plugins/apps/src/vite/custom-credentials-resolver.ts @@ -41,10 +41,12 @@ export async function resolveCustomCredentials( let parsed: unknown; try { parsed = JSON.parse(raw); - } catch (error) { - throw new Error( - `${CUSTOM_CREDENTIALS_LOCAL_FILENAME} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, - ); + } catch { + // Never interpolate the underlying JSON.parse error here: V8's own message can embed a + // raw slice of the source text (e.g. `..."API_KEY": sk_test_ab"...`) when the malformed + // token looks like an unquoted value, which would echo a real secret into this error's + // message — and from there into the dev server's debug log and its HTTP response body. + throw new Error(`${CUSTOM_CREDENTIALS_LOCAL_FILENAME} is not valid JSON.`); } if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { throw new Error( @@ -52,7 +54,9 @@ export async function resolveCustomCredentials( ); } - const resolved: ResolvedCustomCredentials = {}; + // Object.create(null) rather than {} so a credential literally named "__proto__" round-trips + // as a normal own property instead of silently no-op'ing against Object.prototype's setter. + const resolved: ResolvedCustomCredentials = Object.create(null); for (const [key, value] of Object.entries(parsed)) { if (typeof value !== 'string') { throw new Error( From 5f68f08a3adf3085ac425b54c2640b6eed8e3324 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 10 Sep 2026 01:42:40 -0400 Subject: [PATCH 3/8] fix(apps): deny dev-server access to the local Custom Credentials file Vite's dev server serves any project-root file over HTTP unless it is on server.fs.deny; the default list only covers .env*/certs/.git, not datadog-app.local.json, so a browser could fetch the secrets file directly (e.g. GET /datadog-app.local.json). Also addresses two smaller review findings on the same file: reads now go through @dd/core/helpers/fs's readFile instead of node:fs/promises directly, matching this package's existing convention, and a test's Error narrowing uses an instanceof guard instead of an `as` cast. Documents in the README that Custom Credentials are only available inside a function body, not during a module's top-level evaluation. --- packages/plugins/apps/README.md | 4 ++++ .../vite/custom-credentials-resolver.test.ts | 8 +++++--- .../src/vite/custom-credentials-resolver.ts | 4 ++-- packages/plugins/apps/src/vite/index.test.ts | 19 ++++++++++++++++++- packages/plugins/apps/src/vite/index.ts | 8 ++++++++ 5 files changed, 37 insertions(+), 6 deletions(-) diff --git a/packages/plugins/apps/README.md b/packages/plugins/apps/README.md index 44af78e11..1fd7026e0 100644 --- a/packages/plugins/apps/README.md +++ b/packages/plugins/apps/README.md @@ -50,6 +50,10 @@ Backend functions read Custom Credentials from a `datadog-app.local.json` file i root — a flat JSON object mapping env var name to value. Add this file to your project's `.gitignore`; it holds real secret values. +Values are only available while a backend function body is running — not during a module's +top-level evaluation (e.g. `const client = new Stripe(process.env.STRIPE_API_KEY)` at import +time). Read `process.env` inside the function body instead. + ## Package output A production `vite build` writes `datadog-app-assets.zip` beside the Vite output. The ZIP contains `frontend/`, `backend/`, and `manifest.json`. The app's identity is resolved by `@datadog/apps-cli` at deploy time. diff --git a/packages/plugins/apps/src/vite/custom-credentials-resolver.test.ts b/packages/plugins/apps/src/vite/custom-credentials-resolver.test.ts index 283ce40bb..32964723e 100644 --- a/packages/plugins/apps/src/vite/custom-credentials-resolver.test.ts +++ b/packages/plugins/apps/src/vite/custom-credentials-resolver.test.ts @@ -67,9 +67,11 @@ describe('resolveCustomCredentials', () => { } expect(thrown).toBeInstanceOf(Error); - const message = (thrown as Error).message; - expect(message).not.toContain(secret); - expect(message).not.toContain(secret.slice(0, 10)); + if (!(thrown instanceof Error)) { + throw thrown; + } + expect(thrown.message).not.toContain(secret); + expect(thrown.message).not.toContain(secret.slice(0, 10)); }); it('rejects a top-level array', async () => { diff --git a/packages/plugins/apps/src/vite/custom-credentials-resolver.ts b/packages/plugins/apps/src/vite/custom-credentials-resolver.ts index 559a2ee26..d500fb986 100644 --- a/packages/plugins/apps/src/vite/custom-credentials-resolver.ts +++ b/packages/plugins/apps/src/vite/custom-credentials-resolver.ts @@ -4,7 +4,7 @@ /* global NodeJS */ -import fs from 'node:fs/promises'; +import { readFile } from '@dd/core/helpers/fs'; import path from 'node:path'; /** One process.env entry per secret the developer has supplied locally, keyed the same way production's resolved Custom Credentials env vars are (e.g. `STRIPE_API_KEY`). */ @@ -30,7 +30,7 @@ export async function resolveCustomCredentials( const filePath = path.join(projectRoot, CUSTOM_CREDENTIALS_LOCAL_FILENAME); let raw: string; try { - raw = await fs.readFile(filePath, 'utf8'); + raw = await readFile(filePath); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return {}; diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index 64d0f9c1e..005dc85c4 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -2,6 +2,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +import { CUSTOM_CREDENTIALS_LOCAL_FILENAME } from '@dd/apps-plugin/vite/custom-credentials-resolver'; import { getVitePlugin } from '@dd/apps-plugin/vite/index'; import type { ViteBundler } from '@dd/apps-plugin/vite/index'; import { localExecutionResolutionContext } from '@dd/apps-plugin/vite/local-execution'; @@ -676,16 +677,32 @@ describe('Backend Functions - getVitePlugin', () => { // module" for them — ssr.noExternal is what server.ssrLoadModule depends on to load them // correctly. const plugin = getVitePlugin(defaultOptions); - const configHook = plugin!.config as () => { ssr: { noExternal: string[] } }; + const configHook = plugin!.config as () => { + ssr: { noExternal: string[] }; + server: { fs: { deny: string[] } }; + }; const config = configHook(); expect(config).toEqual({ ssr: { noExternal: ['@datadog/apps-backend', '@datadog/action-catalog'], }, + server: { + fs: { + deny: [CUSTOM_CREDENTIALS_LOCAL_FILENAME], + }, + }, }); }); + test('Should deny the dev server from serving the local Custom Credentials file over HTTP', () => { + const plugin = getVitePlugin(defaultOptions); + const configHook = plugin!.config as () => { server: { fs: { deny: string[] } } }; + const config = configHook(); + + expect(config.server.fs.deny).toContain(CUSTOM_CREDENTIALS_LOCAL_FILENAME); + }); + // Uses the real configureServer hook, not createDevServerMiddleware directly, to catch mode-forwarding regressions. test('Should route /__dd/executeAction to the cloud path when configureServer sees a dev-verify server.config.mode', async () => { const plugin = getVitePlugin(defaultOptions); diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index b832668c0..6c1b2f741 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -33,6 +33,7 @@ import type { AppsOptionsWithDefaults } from '../types'; import { buildBackendFunctions } from './build-backend-functions'; import { buildAppPackage } from './build-package'; +import { CUSTOM_CREDENTIALS_LOCAL_FILENAME } from './custom-credentials-resolver'; import { collectModuleGraphFromServer } from './dev-server-module-graph'; import { createDevServerMiddleware } from './dev-server'; import { localExecutionResolutionContext } from './local-execution'; @@ -142,6 +143,13 @@ export const getVitePlugin = ({ ssr: { noExternal: ['@datadog/apps-backend', '@datadog/action-catalog'], }, + // Vite's dev server serves any project-root file not on this list over HTTP — + // the default list only covers .env/.git/certs, not this filename. + server: { + fs: { + deny: [CUSTOM_CREDENTIALS_LOCAL_FILENAME], + }, + }, }; }, // Propagates LOCAL_EXECUTION_LOAD_SUFFIX through the backend-file dependency graph so a From 47688c4c2a9259c1abc41cc8999e0b9818fc6f52 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 10 Sep 2026 03:17:22 -0400 Subject: [PATCH 4/8] fix(apps): never package the local Custom Credentials file into a shipped app options.include is user-configured with no gitignore-awareness, so a broad pattern (e.g. "**/*.json") would otherwise zip datadog-app.local.json's real secret values straight into the published app package. Also swaps a fragile error cast for a real type guard, and trims two comments to their single load-bearing WHY, and removes a test made fully redundant by an existing toEqual assertion one test above it. --- packages/plugins/apps/src/index.test.ts | 38 +++++++++++++++++++ .../plugins/apps/src/vite/build-package.ts | 10 +++++ .../vite/custom-credentials-resolver.test.ts | 20 +++------- .../src/vite/custom-credentials-resolver.ts | 12 +++--- packages/plugins/apps/src/vite/index.test.ts | 12 +++--- packages/plugins/apps/src/vite/index.ts | 10 +++-- .../apps/src/vite/local-execution.test.ts | 5 +-- 7 files changed, 76 insertions(+), 31 deletions(-) diff --git a/packages/plugins/apps/src/index.test.ts b/packages/plugins/apps/src/index.test.ts index 0f62b623b..2b7026f4a 100644 --- a/packages/plugins/apps/src/index.test.ts +++ b/packages/plugins/apps/src/index.test.ts @@ -143,6 +143,44 @@ describe('Apps Plugin - package output', () => { ); }); + test('never packages datadog-app.local.json, even when options.include matches it', async () => { + const localCredentialsPath = path.join(root, 'datadog-app.local.json'); + await fs.writeFile(localCredentialsPath, '{"STRIPE_API_KEY":"sk_test_should_not_ship"}'); + jest.spyOn(assets, 'collectAssets').mockResolvedValue([ + { absolutePath: sourcePath, relativePath: 'index.html' }, + { absolutePath: localCredentialsPath, relativePath: 'datadog-app.local.json' }, + ]); + + await buildAppPackage(packageOptions({ options: { include: ['**/*.json'] } })); + + const zip = await JSZip.loadAsync( + await fs.readFile(path.join(packageDirectory, ARCHIVE_FILENAME)), + ); + expect(Object.keys(zip.files)).not.toEqual( + expect.arrayContaining(['frontend/datadog-app.local.json']), + ); + }); + + // Regression test: a case-insensitive filesystem resolves a differently-cased basename to the + // same file a glob matched, so the exclusion filter must compare case-insensitively. + test('never packages a case-variant of datadog-app.local.json, even when options.include matches it', async () => { + const localCredentialsPath = path.join(root, 'Datadog-App.Local.Json'); + await fs.writeFile(localCredentialsPath, '{"STRIPE_API_KEY":"sk_test_should_not_ship"}'); + jest.spyOn(assets, 'collectAssets').mockResolvedValue([ + { absolutePath: sourcePath, relativePath: 'index.html' }, + { absolutePath: localCredentialsPath, relativePath: 'Datadog-App.Local.Json' }, + ]); + + await buildAppPackage(packageOptions({ options: { include: ['**/*.json'] } })); + + const zip = await JSZip.loadAsync( + await fs.readFile(path.join(packageDirectory, ARCHIVE_FILENAME)), + ); + expect(Object.keys(zip.files)).not.toEqual( + expect.arrayContaining(['frontend/Datadog-App.Local.Json']), + ); + }); + test('writes manifest.json with only backend function entries', async () => { await buildAppPackage(packageOptions()); diff --git a/packages/plugins/apps/src/vite/build-package.ts b/packages/plugins/apps/src/vite/build-package.ts index 43908fc85..07ef63c03 100644 --- a/packages/plugins/apps/src/vite/build-package.ts +++ b/packages/plugins/apps/src/vite/build-package.ts @@ -17,6 +17,8 @@ import type { BackendFunction } from '../backend/types'; import { ARCHIVE_FILENAME, PLUGIN_NAME } from '../constants'; import type { AppsManifest, AppsOptionsWithDefaults } from '../types'; +import { CUSTOM_CREDENTIALS_LOCAL_FILENAME } from './custom-credentials-resolver'; + export interface BuildAppPackageOptions { backendOutputs: Map; backendFunctions: BackendFunction[]; @@ -91,6 +93,14 @@ export async function buildAppPackage({ const frontendAssets = assets .filter((asset) => !generatedPaths.has(path.resolve(asset.absolutePath))) .filter((asset) => !backendPaths.has(asset.absolutePath)) + // options.include has no gitignore-awareness, and filenames may differ in case on a + // case-insensitive filesystem, so compare lowercased basenames to keep a broad + // pattern (e.g. "**/*.json") from shipping this real-secrets file under any casing. + .filter( + (asset) => + path.basename(asset.absolutePath).toLowerCase() !== + CUSTOM_CREDENTIALS_LOCAL_FILENAME, + ) .map((asset) => ({ ...asset, relativePath: `frontend/${asset.relativePath}`, diff --git a/packages/plugins/apps/src/vite/custom-credentials-resolver.test.ts b/packages/plugins/apps/src/vite/custom-credentials-resolver.test.ts index 32964723e..ee3bb525b 100644 --- a/packages/plugins/apps/src/vite/custom-credentials-resolver.test.ts +++ b/packages/plugins/apps/src/vite/custom-credentials-resolver.test.ts @@ -47,12 +47,9 @@ describe('resolveCustomCredentials', () => { }); it('never echoes a real secret value into the parse-error message', async () => { - // V8's own JSON.parse error embeds a slice of the source text around an unquoted token - // (e.g. `..."API_KEY": sk_live_ab"...` for a real credential) — this file writes an - // unquoted token on purpose to trigger that same parse-error shape. Deliberately NOT - // shaped like a real provider key (no digits, no mixed case, no known prefix): GitHub's - // own secret scanning blocked this exact commit twice already for using key-shaped - // fixtures (`sk_live_...`, then `sk_test_...`) even though neither was a real credential. + // An unquoted JSON value triggers V8's parse error to embed a source-text slice — the + // real bug this guards against. Deliberately not shaped like a real credential (no + // digits, no known prefix) so this fixture doesn't trip secret-scanning on push. const secret = 'THIS_TOKEN_MUST_NEVER_LEAK_INTO_ANY_ERROR_MESSAGE'; await fs.writeFile( path.join(projectRoot, CUSTOM_CREDENTIALS_LOCAL_FILENAME), @@ -95,14 +92,9 @@ describe('resolveCustomCredentials', () => { }); it('resolves a credential literally named "__proto__" instead of silently dropping it', async () => { - // A plain {} target makes `resolved['__proto__'] = value` hit Object.prototype's own - // __proto__ setter, which no-ops for a non-object value — the credential just vanishes - // with no error. Object.create(null) avoids the setter entirely. - // Written as a raw string, not an object-literal + JSON.stringify: `{ __proto__: ... }` - // in JS source is special-cased by the *object literal* grammar to set the prototype - // (a no-op here, since the value's a string) rather than create an own property, so - // JSON.stringify would silently drop it before this test ever exercises the resolver. - // JSON.parse has no such special case — "__proto__" is a completely ordinary key there. + // Written as a raw string, not JSON.stringify({...}): object-literal `__proto__` syntax + // special-cases to set the prototype rather than create an own property, so stringifying + // it would silently produce {} here — JSON.parse has no such special case. await fs.writeFile( path.join(projectRoot, CUSTOM_CREDENTIALS_LOCAL_FILENAME), '{"__proto__": "sk_test_proto", "STRIPE_API_KEY": "sk_test_123"}', diff --git a/packages/plugins/apps/src/vite/custom-credentials-resolver.ts b/packages/plugins/apps/src/vite/custom-credentials-resolver.ts index d500fb986..b89947ec1 100644 --- a/packages/plugins/apps/src/vite/custom-credentials-resolver.ts +++ b/packages/plugins/apps/src/vite/custom-credentials-resolver.ts @@ -18,6 +18,10 @@ export type ResolvedCustomCredentials = Record; */ export const CUSTOM_CREDENTIALS_LOCAL_FILENAME = 'datadog-app.local.json'; +function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return typeof error === 'object' && error !== null && 'code' in error; +} + /** * Resolves Custom Credentials for local execution by reading {@link CUSTOM_CREDENTIALS_LOCAL_FILENAME} * from the project root. A missing file resolves to `{}` — most projects won't have one — but a @@ -32,7 +36,7 @@ export async function resolveCustomCredentials( try { raw = await readFile(filePath); } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + if (isErrnoException(error) && error.code === 'ENOENT') { return {}; } throw error; @@ -42,10 +46,8 @@ export async function resolveCustomCredentials( try { parsed = JSON.parse(raw); } catch { - // Never interpolate the underlying JSON.parse error here: V8's own message can embed a - // raw slice of the source text (e.g. `..."API_KEY": sk_test_ab"...`) when the malformed - // token looks like an unquoted value, which would echo a real secret into this error's - // message — and from there into the dev server's debug log and its HTTP response body. + // Never interpolate the underlying JSON.parse error: V8's message can embed a raw slice + // of an unquoted value's source text, echoing a real secret into logs/HTTP responses. throw new Error(`${CUSTOM_CREDENTIALS_LOCAL_FILENAME} is not valid JSON.`); } if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index 005dc85c4..0e2ec79ea 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -3,7 +3,7 @@ // Copyright 2019-Present Datadog, Inc. import { CUSTOM_CREDENTIALS_LOCAL_FILENAME } from '@dd/apps-plugin/vite/custom-credentials-resolver'; -import { getVitePlugin } from '@dd/apps-plugin/vite/index'; +import { getVitePlugin, VITE_DEFAULT_SERVER_FS_DENY } from '@dd/apps-plugin/vite/index'; import type { ViteBundler } from '@dd/apps-plugin/vite/index'; import { localExecutionResolutionContext } from '@dd/apps-plugin/vite/local-execution'; import { InjectPosition } from '@dd/core/types'; @@ -689,18 +689,20 @@ describe('Backend Functions - getVitePlugin', () => { }, server: { fs: { - deny: [CUSTOM_CREDENTIALS_LOCAL_FILENAME], + deny: expect.arrayContaining([CUSTOM_CREDENTIALS_LOCAL_FILENAME]), }, }, }); }); - test('Should deny the dev server from serving the local Custom Credentials file over HTTP', () => { + // Regression test: a plugin's own server.fs.deny replaces Vite's defaults instead of merging, + // so .env/cert/.git protection must be preserved explicitly alongside this filename. + test("Should preserve Vite's default server.fs.deny patterns alongside the credentials filename", () => { const plugin = getVitePlugin(defaultOptions); const configHook = plugin!.config as () => { server: { fs: { deny: string[] } } }; - const config = configHook(); + const { deny } = configHook().server.fs; - expect(config.server.fs.deny).toContain(CUSTOM_CREDENTIALS_LOCAL_FILENAME); + expect(deny).toEqual(expect.arrayContaining(VITE_DEFAULT_SERVER_FS_DENY)); }); // Uses the real configureServer hook, not createDevServerMiddleware directly, to catch mode-forwarding regressions. diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index 6c1b2f741..9e6eb91b1 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -99,6 +99,9 @@ function createBackendFunctionRegistry() { const APPS_RUNTIME_PATH = path.join(__dirname, './apps-runtime.mjs'); +// Not exported by Vite; mirrors its server.fs.deny default so it can be spread in below. +export const VITE_DEFAULT_SERVER_FS_DENY = ['.env', '.env.*', '*.{crt,pem}', '**/.git/**']; + /** * Returns the Vite-specific plugin hooks for the apps plugin. * @@ -143,11 +146,12 @@ export const getVitePlugin = ({ ssr: { noExternal: ['@datadog/apps-backend', '@datadog/action-catalog'], }, - // Vite's dev server serves any project-root file not on this list over HTTP — - // the default list only covers .env/.git/certs, not this filename. + // Vite replaces its whole server.fs.deny default rather than merging with a + // plugin's own list, so the defaults above must be spread in here or dev-server + // protection for .env/.git/certs silently disappears once this filename is added. server: { fs: { - deny: [CUSTOM_CREDENTIALS_LOCAL_FILENAME], + deny: [...VITE_DEFAULT_SERVER_FS_DENY, CUSTOM_CREDENTIALS_LOCAL_FILENAME], }, }, }; diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index fdc27cd16..749f878ee 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -58,10 +58,7 @@ beforeEach(() => { // Neither optional SDK is installed by default; tests exercising the "installed" path override this. jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(false); jest.spyOn(shared, 'isDatadogAppsBackendInstalled').mockReturnValue(false); - // Real fs I/O races unpredictably against the fake-timer tests below (TEST_PROJECT_ROOT isn't - // a real directory anyway); tests covering the real file read live in - // custom-credentials-resolver.test.ts, plus one integration test further down that restores - // the real implementation for its own duration. + // Real fs I/O races unpredictably against the fake-timer tests below. jest.spyOn(customCredentialsResolver, 'resolveCustomCredentials').mockResolvedValue({}); }); From 37aad0e69df9f6a299b501d175d349b811d38862 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 10 Sep 2026 13:54:47 -0400 Subject: [PATCH 5/8] fix(apps): reject direct imports of the local Custom Credentials file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The packaging-exclusion filter only ever inspects the original, unbundled file — importing it directly (or via import.meta.glob) lets Vite inline its real secret values into a generated chunk instead, bypassing that filter entirely. Rejected at resolveId time, for both dev and build, client and SSR. Also removes the remaining as-casts on the plugin's config() hook in tests, narrowing it through a runtime-checked helper instead, matching the pattern already used for the other hooks in this file. --- packages/plugins/apps/src/vite/index.test.ts | 43 +++++++++++++++++--- packages/plugins/apps/src/vite/index.ts | 9 ++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index 0e2ec79ea..63e2fe73a 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -96,6 +96,23 @@ function isDevServerMiddleware(value: unknown): value is DevServerMiddleware { return typeof value === 'function'; } +type ConfigHookResult = { + ssr: { noExternal: string[] }; + server: { fs: { deny: string[] } }; +}; + +// Narrows `plugin.config` to its plain-function hook form via a runtime check, avoiding an `as` +// cast on its return value — mirrors `getConfigureServer` above. +function getConfigHandler(plugin: ReturnType): () => ConfigHookResult { + const { config } = plugin ?? {}; + if (typeof config !== 'function') { + throw new Error('Expected plugin.config to be the plain function-hook form'); + } + return function callConfig(): ConfigHookResult { + return Reflect.apply(config, undefined, []); + }; +} + const functions: BackendFunction[] = [ { relativePath: 'src/backend/myHandler', @@ -661,6 +678,25 @@ describe('Backend Functions - getVitePlugin', () => { }); }); + // Regression test: build-package.ts's exclusion filter only sees the unbundled file, so a + // direct import must be rejected separately or Vite would inline the real secret values. + test.each([{ ssr: true }, { ssr: false }])( + 'Should reject a direct import of the local Custom Credentials file (ssr: $ssr)', + async ({ ssr }) => { + const plugin = getVitePlugin(defaultOptions); + const resolveIdHandler = getResolveIdHandler(plugin); + + await expect( + resolveIdHandler.call( + { resolve: jest.fn() }, + `../${CUSTOM_CREDENTIALS_LOCAL_FILENAME}`, + '/build/src/index.ts', + { ssr }, + ), + ).rejects.toThrow(/cannot be imported directly/); + }, + ); + test('Should inject the apps runtime', () => { getVitePlugin(defaultOptions); @@ -677,10 +713,7 @@ describe('Backend Functions - getVitePlugin', () => { // module" for them — ssr.noExternal is what server.ssrLoadModule depends on to load them // correctly. const plugin = getVitePlugin(defaultOptions); - const configHook = plugin!.config as () => { - ssr: { noExternal: string[] }; - server: { fs: { deny: string[] } }; - }; + const configHook = getConfigHandler(plugin); const config = configHook(); expect(config).toEqual({ @@ -699,7 +732,7 @@ describe('Backend Functions - getVitePlugin', () => { // so .env/cert/.git protection must be preserved explicitly alongside this filename. test("Should preserve Vite's default server.fs.deny patterns alongside the credentials filename", () => { const plugin = getVitePlugin(defaultOptions); - const configHook = plugin!.config as () => { server: { fs: { deny: string[] } } }; + const configHook = getConfigHandler(plugin); const { deny } = configHook().server.fs; expect(deny).toEqual(expect.arrayContaining(VITE_DEFAULT_SERVER_FS_DENY)); diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index 9e6eb91b1..9cbf5385d 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -165,6 +165,15 @@ export const getVitePlugin = ({ // first, short-circuiting the hook chain before this plugin ever sees it. order: 'pre', async handler(source, importer, resolveOptions) { + // An import/import.meta.glob of this file would let Vite inline its real secret + // values into a generated chunk — build-package.ts's exclusion filter only ever sees + // the original, unbundled file. + if (path.basename(source).toLowerCase() === CUSTOM_CREDENTIALS_LOCAL_FILENAME) { + throw new Error( + `${CUSTOM_CREDENTIALS_LOCAL_FILENAME} cannot be imported directly — read Custom Credentials via process.env instead.`, + ); + } + // Top-level guard (not folded into each branch) so any future branch added below // inherits it automatically: local execution's traversal is always SSR, so without // this a client-mode resolution could inherit the marker and leak real backend code. From 043d26a006ea333a5939a822f4ea8346bc7328e4 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 10 Sep 2026 14:33:27 -0400 Subject: [PATCH 6/8] fix(apps): strip resource query before matching the Custom Credentials filename A query-suffixed specifier (`?raw`, `?url`) bypassed the resolveId guard's basename comparison, letting Vite inline the real secret file's content into a built chunk. --- packages/plugins/apps/src/vite/index.test.ts | 25 +++++++++++--------- packages/plugins/apps/src/vite/index.ts | 12 ++++++---- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index 63e2fe73a..ca0821ea4 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -678,21 +678,24 @@ describe('Backend Functions - getVitePlugin', () => { }); }); - // Regression test: build-package.ts's exclusion filter only sees the unbundled file, so a - // direct import must be rejected separately or Vite would inline the real secret values. - test.each([{ ssr: true }, { ssr: false }])( - 'Should reject a direct import of the local Custom Credentials file (ssr: $ssr)', - async ({ ssr }) => { + // Regression test: build-package.ts's exclusion filter only sees the unbundled file, and a + // query-suffixed specifier (`?raw`, `?url`) defeats a naive basename check — both must be + // rejected here or Vite inlines the real secret values into a built chunk. + test.each([ + { specifier: `../${CUSTOM_CREDENTIALS_LOCAL_FILENAME}`, ssr: true }, + { specifier: `../${CUSTOM_CREDENTIALS_LOCAL_FILENAME}`, ssr: false }, + { specifier: `../${CUSTOM_CREDENTIALS_LOCAL_FILENAME}?raw`, ssr: true }, + { specifier: `../${CUSTOM_CREDENTIALS_LOCAL_FILENAME}?url`, ssr: false }, + ])( + 'Should reject a direct import of the local Custom Credentials file (specifier: $specifier, ssr: $ssr)', + async ({ specifier, ssr }) => { const plugin = getVitePlugin(defaultOptions); const resolveIdHandler = getResolveIdHandler(plugin); await expect( - resolveIdHandler.call( - { resolve: jest.fn() }, - `../${CUSTOM_CREDENTIALS_LOCAL_FILENAME}`, - '/build/src/index.ts', - { ssr }, - ), + resolveIdHandler.call({ resolve: jest.fn() }, specifier, '/build/src/index.ts', { + ssr, + }), ).rejects.toThrow(/cannot be imported directly/); }, ); diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index 9cbf5385d..f4fb7e3d7 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -165,10 +165,14 @@ export const getVitePlugin = ({ // first, short-circuiting the hook chain before this plugin ever sees it. order: 'pre', async handler(source, importer, resolveOptions) { - // An import/import.meta.glob of this file would let Vite inline its real secret - // values into a generated chunk — build-package.ts's exclusion filter only ever sees - // the original, unbundled file. - if (path.basename(source).toLowerCase() === CUSTOM_CREDENTIALS_LOCAL_FILENAME) { + // An import of this file (including a query-suffixed one, e.g. `?raw`) would let + // Vite inline its real secret values into a chunk that build-package.ts's exclusion + // filter never sees — strip the query before comparing basenames below. + const sourceWithoutQuery = source.split('?')[0]; + if ( + path.basename(sourceWithoutQuery).toLowerCase() === + CUSTOM_CREDENTIALS_LOCAL_FILENAME + ) { throw new Error( `${CUSTOM_CREDENTIALS_LOCAL_FILENAME} cannot be imported directly — read Custom Credentials via process.env instead.`, ); From c22066f7fcf3e613aa49d385ccf749eeafe43252 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 10 Sep 2026 15:46:06 -0400 Subject: [PATCH 7/8] fix(apps): close hash-suffix and symlink/hardlink bypasses of the Custom Credentials guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The direct-import basename check only stripped a query suffix, letting a hash-suffixed import (`#fragment`) through as Vite itself strips both. The packaging exclusion filter compared only a discovered asset's own basename or, for a symlinked asset, its target's basename — missing both the inverse case where the credentials file itself is a symlink to a separately-matched asset, and a hardlink under another name, since realpath resolves symlink components but not hardlink identity. It now compares each candidate's (device, inode) identity against the credentials file's, which catches a symlink on either side and a hardlink uniformly, treating any non-ENOENT stat failure as unsafe to package rather than silently letting it through. --- packages/plugins/apps/src/index.test.ts | 97 +++++++++++++++++++ .../plugins/apps/src/vite/build-package.ts | 78 ++++++++++++--- packages/plugins/apps/src/vite/env-guard.ts | 2 - packages/plugins/apps/src/vite/index.test.ts | 6 +- packages/plugins/apps/src/vite/index.ts | 10 +- 5 files changed, 171 insertions(+), 22 deletions(-) diff --git a/packages/plugins/apps/src/index.test.ts b/packages/plugins/apps/src/index.test.ts index 2b7026f4a..6c2fbcd8b 100644 --- a/packages/plugins/apps/src/index.test.ts +++ b/packages/plugins/apps/src/index.test.ts @@ -2,6 +2,8 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +/* global NodeJS */ + import * as archive from '@dd/apps-plugin/archive'; import * as assets from '@dd/apps-plugin/assets'; import { getPlugins } from '@dd/apps-plugin'; @@ -181,6 +183,101 @@ describe('Apps Plugin - package output', () => { ); }); + // Regression test: a symlink under a different name still reads the credentials file's real + // content, so the exclusion filter must check the resolved target, not just the discovered + // path's own basename. + test('never packages a symlink pointing at datadog-app.local.json, even under a different name', async () => { + const localCredentialsPath = path.join(root, 'datadog-app.local.json'); + await fs.writeFile(localCredentialsPath, '{"STRIPE_API_KEY":"sk_test_should_not_ship"}'); + const symlinkPath = path.join(root, 'backup-config.json'); + await fs.symlink(localCredentialsPath, symlinkPath); + jest.spyOn(assets, 'collectAssets').mockResolvedValue([ + { absolutePath: sourcePath, relativePath: 'index.html' }, + { absolutePath: symlinkPath, relativePath: 'backup-config.json' }, + ]); + + await buildAppPackage(packageOptions({ options: { include: ['**/*.json'] } })); + + const zip = await JSZip.loadAsync( + await fs.readFile(path.join(packageDirectory, ARCHIVE_FILENAME)), + ); + expect(Object.keys(zip.files)).not.toEqual( + expect.arrayContaining(['frontend/backup-config.json']), + ); + }); + + // Regression test: when datadog-app.local.json is itself a symlink, the file glob-matched at + // its target path carries the same secret bytes under a different name and must be excluded too. + test('never packages the real target of a symlinked datadog-app.local.json', async () => { + const realSecretsPath = path.join(root, 'config', 'dev-secrets.json'); + await fs.mkdir(path.dirname(realSecretsPath), { recursive: true }); + await fs.writeFile(realSecretsPath, '{"STRIPE_API_KEY":"sk_test_should_not_ship"}'); + const localCredentialsPath = path.join(root, 'datadog-app.local.json'); + await fs.symlink(realSecretsPath, localCredentialsPath); + jest.spyOn(assets, 'collectAssets').mockResolvedValue([ + { absolutePath: sourcePath, relativePath: 'index.html' }, + { absolutePath: realSecretsPath, relativePath: 'config/dev-secrets.json' }, + ]); + + await buildAppPackage(packageOptions({ options: { include: ['**/*.json'] } })); + + const zip = await JSZip.loadAsync( + await fs.readFile(path.join(packageDirectory, ARCHIVE_FILENAME)), + ); + expect(Object.keys(zip.files)).not.toEqual( + expect.arrayContaining(['frontend/config/dev-secrets.json']), + ); + }); + + // Regression test: a hardlink shares the credentials file's inode without ever being a + // symlink, so an identity check must compare (device, inode), not just resolve symlink targets. + test('never packages a hardlink to datadog-app.local.json, even under a different name', async () => { + const localCredentialsPath = path.join(root, 'datadog-app.local.json'); + await fs.writeFile(localCredentialsPath, '{"STRIPE_API_KEY":"sk_test_should_not_ship"}'); + const hardlinkPath = path.join(root, 'backup-hardlink.json'); + await fs.link(localCredentialsPath, hardlinkPath); + jest.spyOn(assets, 'collectAssets').mockResolvedValue([ + { absolutePath: sourcePath, relativePath: 'index.html' }, + { absolutePath: hardlinkPath, relativePath: 'backup-hardlink.json' }, + ]); + + await buildAppPackage(packageOptions({ options: { include: ['**/*.json'] } })); + + const zip = await JSZip.loadAsync( + await fs.readFile(path.join(packageDirectory, ARCHIVE_FILENAME)), + ); + expect(Object.keys(zip.files)).not.toEqual( + expect.arrayContaining(['frontend/backup-hardlink.json']), + ); + }); + + // Regression test: a stat failure on the candidate asset itself (not on the credentials file) + // must still propagate rather than being swallowed as "not a match" — the mock only intercepts + // the asset's own stat call so a real credentials file resolves normally first. + test('propagates a non-ENOENT stat failure instead of treating an unverifiable asset as safe', async () => { + const localCredentialsPath = path.join(root, 'datadog-app.local.json'); + await fs.writeFile(localCredentialsPath, '{"STRIPE_API_KEY":"sk_test_should_not_ship"}'); + const symlinkPath = path.join(root, 'mystery-config.json'); + await fs.symlink(sourcePath, symlinkPath); + jest.spyOn(assets, 'collectAssets').mockResolvedValue([ + { absolutePath: sourcePath, relativePath: 'index.html' }, + { absolutePath: symlinkPath, relativePath: 'mystery-config.json' }, + ]); + const realStat = fs.stat.bind(fs); + jest.spyOn(fs, 'stat').mockImplementation(async (target, ...args) => { + if (target === symlinkPath) { + const error: NodeJS.ErrnoException = new Error('permission denied'); + error.code = 'EACCES'; + throw error; + } + return realStat(target as string, ...(args as [])); + }); + + await expect( + buildAppPackage(packageOptions({ options: { include: ['**/*.json'] } })), + ).rejects.toThrow('permission denied'); + }); + test('writes manifest.json with only backend function entries', async () => { await buildAppPackage(packageOptions()); diff --git a/packages/plugins/apps/src/vite/build-package.ts b/packages/plugins/apps/src/vite/build-package.ts index 07ef63c03..277125a2f 100644 --- a/packages/plugins/apps/src/vite/build-package.ts +++ b/packages/plugins/apps/src/vite/build-package.ts @@ -2,6 +2,8 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +/* global NodeJS */ + import { getDDEnvValue } from '@dd/core/helpers/env'; import { rm } from '@dd/core/helpers/fs'; import type { GlobalContext } from '@dd/core/types'; @@ -26,6 +28,52 @@ export interface BuildAppPackageOptions { options: AppsOptionsWithDefaults; } +function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return typeof error === 'object' && error !== null && 'code' in error; +} + +type FileIdentity = { dev: number; ino: number }; + +/** Resolves the root credentials file's (device, inode) identity, or undefined if it doesn't exist. */ +async function resolveCredentialsIdentity(buildRoot: string): Promise { + try { + const stats = await fsp.stat(path.join(buildRoot, CUSTOM_CREDENTIALS_LOCAL_FILENAME)); + return { dev: stats.dev, ino: stats.ino }; + } catch (error) { + if (isErrnoException(error) && error.code === 'ENOENT') { + return undefined; + } + throw error; + } +} + +/** + * Compares an asset's (device, inode) identity against the credentials file's, since fs.stat + * follows symlinks either way and inode identity also catches a hardlink — cases a path-string + * comparison alone can miss. A vanished asset has nothing left to leak; any other stat failure is + * re-thrown rather than silently treated as safe to package. + */ +async function isCustomCredentialsAsset( + absolutePath: string, + credentialsIdentity: FileIdentity | undefined, +): Promise { + if (path.basename(absolutePath).toLowerCase() === CUSTOM_CREDENTIALS_LOCAL_FILENAME) { + return true; + } + if (!credentialsIdentity) { + return false; + } + try { + const stats = await fsp.stat(absolutePath); + return stats.dev === credentialsIdentity.dev && stats.ino === credentialsIdentity.ino; + } catch (error) { + if (isErrnoException(error) && error.code === 'ENOENT') { + return false; + } + throw error; + } +} + function buildManifest(backendFunctions: BackendFunction[]): AppsManifest { const functions: AppsManifest['backend']['functions'] = {}; for (const func of backendFunctions) { @@ -90,21 +138,25 @@ export async function buildAppPackage({ try { const generatedPaths = new Set([archivePath, defaultArchivePath]); const backendPaths = new Set(backendOutputs.values()); - const frontendAssets = assets + const candidateAssets = assets .filter((asset) => !generatedPaths.has(path.resolve(asset.absolutePath))) - .filter((asset) => !backendPaths.has(asset.absolutePath)) - // options.include has no gitignore-awareness, and filenames may differ in case on a - // case-insensitive filesystem, so compare lowercased basenames to keep a broad - // pattern (e.g. "**/*.json") from shipping this real-secrets file under any casing. - .filter( - (asset) => - path.basename(asset.absolutePath).toLowerCase() !== - CUSTOM_CREDENTIALS_LOCAL_FILENAME, + .filter((asset) => !backendPaths.has(asset.absolutePath)); + const credentialsIdentity = await resolveCredentialsIdentity(buildRoot); + const nonCredentialsAssets = ( + await Promise.all( + candidateAssets.map(async (asset) => ({ + asset, + isCredentialsAsset: await isCustomCredentialsAsset( + asset.absolutePath, + credentialsIdentity, + ), + })), ) - .map((asset) => ({ - ...asset, - relativePath: `frontend/${asset.relativePath}`, - })); + ).filter(({ isCredentialsAsset }) => !isCredentialsAsset); + const frontendAssets = nonCredentialsAssets.map(({ asset }) => ({ + ...asset, + relativePath: `frontend/${asset.relativePath}`, + })); const packageAssets: Asset[] = [...frontendAssets]; for (const [bundleName, absolutePath] of backendOutputs) { packageAssets.push({ diff --git a/packages/plugins/apps/src/vite/env-guard.ts b/packages/plugins/apps/src/vite/env-guard.ts index f1f6150d4..5502e51ed 100644 --- a/packages/plugins/apps/src/vite/env-guard.ts +++ b/packages/plugins/apps/src/vite/env-guard.ts @@ -33,8 +33,6 @@ const nativeReadlinkSync = fs.readlinkSync; export const SAFE_ENV_KEYS = ['PATH', 'HOME', 'NODE_ENV', 'TMPDIR'] as const; -// customCredentials comes from custom-credentials-resolver.ts's resolveCustomCredentials — a -// developer-maintained local file, empty by default. export function buildScopedEnv(customCredentials: Record): Record { const scoped: Record = {}; for (const key of SAFE_ENV_KEYS) { diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index ca0821ea4..c673ca574 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -679,13 +679,15 @@ describe('Backend Functions - getVitePlugin', () => { }); // Regression test: build-package.ts's exclusion filter only sees the unbundled file, and a - // query-suffixed specifier (`?raw`, `?url`) defeats a naive basename check — both must be - // rejected here or Vite inlines the real secret values into a built chunk. + // query- or hash-suffixed specifier (`?raw`, `?url`, `#fragment`) defeats a naive basename + // check — all must be rejected here or Vite inlines the real secret values into a built chunk. test.each([ { specifier: `../${CUSTOM_CREDENTIALS_LOCAL_FILENAME}`, ssr: true }, { specifier: `../${CUSTOM_CREDENTIALS_LOCAL_FILENAME}`, ssr: false }, { specifier: `../${CUSTOM_CREDENTIALS_LOCAL_FILENAME}?raw`, ssr: true }, { specifier: `../${CUSTOM_CREDENTIALS_LOCAL_FILENAME}?url`, ssr: false }, + { specifier: `../${CUSTOM_CREDENTIALS_LOCAL_FILENAME}#fragment`, ssr: true }, + { specifier: `../${CUSTOM_CREDENTIALS_LOCAL_FILENAME}?raw#fragment`, ssr: false }, ])( 'Should reject a direct import of the local Custom Credentials file (specifier: $specifier, ssr: $ssr)', async ({ specifier, ssr }) => { diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index f4fb7e3d7..7e7992eef 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -165,12 +165,12 @@ export const getVitePlugin = ({ // first, short-circuiting the hook chain before this plugin ever sees it. order: 'pre', async handler(source, importer, resolveOptions) { - // An import of this file (including a query-suffixed one, e.g. `?raw`) would let - // Vite inline its real secret values into a chunk that build-package.ts's exclusion - // filter never sees — strip the query before comparing basenames below. - const sourceWithoutQuery = source.split('?')[0]; + // Strips the query/hash suffix before comparing, matching Vite's own postfixRE — + // otherwise a `?raw`/`#fragment`-suffixed import bypasses this check and Vite + // inlines the real secret into a chunk build-package.ts's filter never sees. + const sourceWithoutPostfix = source.replace(/[?#].*$/, ''); if ( - path.basename(sourceWithoutQuery).toLowerCase() === + path.basename(sourceWithoutPostfix).toLowerCase() === CUSTOM_CREDENTIALS_LOCAL_FILENAME ) { throw new Error( From 471bd91a9b2a1e8a2471b16b78e9d5df8fc208e0 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 10 Sep 2026 21:23:55 -0400 Subject: [PATCH 8/8] fix(apps): resolve real Custom Credentials for the priming/cold-load path loadCustomerModuleEntry's priming load passed an empty Custom Credentials object even though projectRoot was available at both call sites, so module-top-level SDK initialization (e.g. new Stripe(process.env.X)) always saw undefined despite the same read working inside the invoked function body. --- .../vite/local-execution.resilience.test.ts | 11 ++-- .../apps/src/vite/local-execution.test.ts | 53 +++++++++++++++++++ .../plugins/apps/src/vite/local-execution.ts | 36 ++++++++----- 3 files changed, 84 insertions(+), 16 deletions(-) diff --git a/packages/plugins/apps/src/vite/local-execution.resilience.test.ts b/packages/plugins/apps/src/vite/local-execution.resilience.test.ts index ade8f5b0c..4a3482922 100644 --- a/packages/plugins/apps/src/vite/local-execution.resilience.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.resilience.test.ts @@ -15,11 +15,14 @@ import { executeScriptLocally } from './local-execution'; describe('local-execution resilience (in-process execution known limitations)', () => { // A real `while (true) {}` would hang this test forever, since nothing — not even the timeout's // own callback — can run while the event loop is blocked synchronously. This bounded busy-wait - // proves the same point safely: the 20ms timeout can't interrupt it, so it settles at ~80ms. + // proves the same point safely: the timeout can't interrupt it, so it settles once the loop ends. + // Margins are generous because loadCustomerModuleEntry's Custom Credentials priming (a dynamic + // import plus a real fs read) runs before the loop starts with variable cold-start latency, and + // must stay under the timeout for this test to mean anything. test('Should NOT interrupt a synchronous CPU-bound loop with the current timeout — known, accepted v1 limitation', async () => { const resolver = moduleResolverFor(func, { example: () => { - const deadline = Date.now() + 80; + const deadline = Date.now() + 800; // eslint-disable-next-line no-empty while (Date.now() < deadline) {} return 'loop finished on its own'; @@ -36,13 +39,13 @@ describe('local-execution resilience (in-process execution known limitations)', stubGetRuntimeContext, resolver, mockLogger, - 20, + 300, ); const elapsedMs = Date.now() - start; expect(result).toEqual({ data: 'loop finished on its own' }); - expect(elapsedMs).toBeGreaterThanOrEqual(60); + expect(elapsedMs).toBeGreaterThanOrEqual(700); }); // process.exit() would kill this Jest process, so the fixture runs as its own real Jest process — diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index 749f878ee..ed8307ee9 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -29,6 +29,7 @@ import { DEFAULT_LONG_POLLING_CONFIG, DEFAULT_TIMEOUT_MS, deriveActionTimeouts, + executeColdActionLocally, executeScriptLocally as executeScriptLocallyWithRuntimeContext, } from './local-execution'; import { forceReset } from './network-guard'; @@ -1224,6 +1225,57 @@ describe('local-execution — executeScriptLocally', () => { await fsPromises.rm(projectRoot, { recursive: true, force: true }); } }); + + // Regression coverage: executeColdActionLocally primes the module before runScriptLocally + // ever runs, so a customer module's own top-level code (e.g. `new Stripe(process.env.X)`) + // executes during the priming load, not during the later invocation-scope call above. + test('Should resolve real Custom Credentials for the priming load too, so module-top-level code sees real values', async () => { + jest.spyOn(customCredentialsResolver, 'resolveCustomCredentials').mockImplementation( + realResolveCustomCredentials, + ); + + const projectRoot = await fsPromises.mkdtemp( + path.join(os.tmpdir(), 'local-execution-custom-credentials-'), + ); + try { + await fsPromises.writeFile( + path.join( + projectRoot, + customCredentialsResolver.CUSTOM_CREDENTIALS_LOCAL_FILENAME, + ), + JSON.stringify({ STRIPE_API_KEY: 'sk_test_priming' }), + ); + + let capturedAtModuleLoad: string | undefined; + const loadModule: LoadModule = async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + capturedAtModuleLoad = process.env.STRIPE_API_KEY; + return { example: () => capturedAtModuleLoad }; + } + const error: NodeJS.ErrnoException = new Error( + `Cannot find module '${specifier}'`, + ); + error.code = 'MODULE_NOT_FOUND'; + throw error; + }; + + const result = await executeColdActionLocally( + func, + projectRoot, + [], + stubExecuteAction, + stubGetRuntimeContext, + loadModule, + async () => [], + mockLogger, + ); + + expect(result).toEqual({ data: 'sk_test_priming' }); + expect(capturedAtModuleLoad).toBe('sk_test_priming'); + } finally { + await fsPromises.rm(projectRoot, { recursive: true, force: true }); + } + }); }); test('Should preserve preview context fields while overriding invocation-owned args and Actions', async () => { @@ -2555,6 +2607,7 @@ describe('local-execution — executeScriptLocally', () => { const mod = await isolatedLoadCustomerModuleEntry( loadModule, func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX, + TEST_PROJECT_ROOT, ); expect(mod).toEqual({ example: expect.any(Function) }); }); diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index 815d1b307..b86c59f5b 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -230,22 +230,28 @@ export type LoadModule = (specifier: string) => Promise> /** * Loads a customer module under `runScriptLocally`'s top-level-evaluation `$`-scoping (see - * `customerModuleLoadContext`), for callers like dev-server.ts's priming load. Scopes `process.env` - * first so a dependency's top-level code can't capture the real `fs.readFileSync` and bypass the - * guard. Accepted residual gap: runs outside `runBlocked`, so top-level code still has real + * `customerModuleLoadContext`), for callers like dev-server.ts's priming load that trigger real + * top-level evaluation ahead of `executeScriptLocally`. Scopes `process.env` and the network guard + * first so a dependency's top-level code can't capture the real, unwrapped `fs.readFileSync`/network + * APIs and bypass the guard for the rest of the session. Resolves real Custom Credentials via + * `projectRoot` too — module-scope SDK initialization (`new Stripe(process.env.X)`) would otherwise + * always capture `undefined`. `onScopeStarted` lets a caller record this load's abandon token so a + * hung load can be abandoned without affecting any other execution's still-active scope. Accepted + * residual gap: this load still runs outside `runBlocked`, so top-level code has real, unguarded * network/subprocess access — not a hard boundary, matching this file's "no OS sandbox" framing. */ export async function loadCustomerModuleEntry( loadModule: LoadModule, entrySpecifier: string, + projectRoot: string, onScopeStarted?: (handle: EnvScopeHandle) => void, ): Promise> { - await getNetworkGuard(); - const { buildScopedEnv, runWithScopedEnv } = await getEnvGuard(); - // {} rather than a real resolution: this priming load has no projectRoot, and its top-level - // eval is already outside the guarded scope (see doc comment above) — runScriptLocally is - // what resolves real credentials for function bodies. - const scopedEnv = buildScopedEnv({}); + const [{ buildScopedEnv, runWithScopedEnv }, customCredentials] = await Promise.all([ + getEnvGuard(), + resolveCustomCredentials(projectRoot), + getNetworkGuard(), + ]); + const scopedEnv = buildScopedEnv(customCredentials); return localExecutionResolutionContext.run(new Set(), () => customerModuleLoadContext.run({ assigned: false, value: undefined }, () => runWithScopedEnv(scopedEnv, () => loadModule(entrySpecifier), onScopeStarted), @@ -720,9 +726,14 @@ export async function executeColdActionLocally( ); const entrySpecifier = func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX; let primingEnvScope: EnvScopeHandle | undefined; - const primingPromise = loadCustomerModuleEntry(loadModule, entrySpecifier, (handle) => { - primingEnvScope = handle; - }); + const primingPromise = loadCustomerModuleEntry( + loadModule, + entrySpecifier, + projectRoot, + (handle) => { + primingEnvScope = handle; + }, + ); let primedEntry: Record | undefined; try { primedEntry = await withTimeout(primingPromise, timeoutMs, `Loading "${displayName}"`); @@ -872,6 +883,7 @@ async function runScriptLocally( (await loadCustomerModuleEntry( loadModule, func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX, + projectRoot, )); const fn = mod[func.name]; if (typeof fn !== 'function') {