diff --git a/pkgs/modules/pup/default.nix b/pkgs/modules/pup/default.nix index 77d63397..4f34dbd2 100644 --- a/pkgs/modules/pup/default.nix +++ b/pkgs/modules/pup/default.nix @@ -3,6 +3,23 @@ let version = "0.64.0"; + # First release whose getCliConfig() supports Datadog. The wrapper resolves it + # from $out/libexec/node_modules, so the module has to ship it. + sdkVersion = "0.4.2"; + + connectorsSdk = pkgs.fetchurl { + url = "https://registry.npmjs.org/@replit/connectors-sdk/-/connectors-sdk-${sdkVersion}.tgz"; + hash = "sha512-1FZsc7IWsvtogvTiWJod58cWmdebk5O7Qu5cbe3xp1CVSErmZxKmti5AIgMtKTcuCaFiGQ4JkKWDcPed69PRDg=="; + }; + + # The wrapper and its tests share one store path so the test file can import + # the module it exercises by relative path. + wrapperSource = pkgs.runCommand "pup-wrapper-source" { } '' + mkdir -p "$out" + cp ${./pup-wrapper.mjs} "$out/pup-wrapper.mjs" + cp ${./pup-wrapper.test.mjs} "$out/pup-wrapper.test.mjs" + ''; + pup = pkgs.stdenv.mkDerivation (finalAttrs: { pname = "pup"; inherit version; @@ -15,6 +32,7 @@ let nativeBuildInputs = [ pkgs.autoPatchelfHook pkgs.makeWrapper + pkgs.nodejs_22 ]; buildInputs = [ pkgs.stdenv.cc.cc.lib ]; @@ -28,7 +46,16 @@ let runHook preInstall install -Dm755 pup "$out/libexec/pup" - install -Dm644 ${./pup-wrapper.mjs} "$out/libexec/pup-wrapper.mjs" + install -Dm644 ${wrapperSource}/pup-wrapper.mjs "$out/libexec/pup-wrapper.mjs" + + sdkDir="$out/libexec/node_modules/@replit/connectors-sdk" + mkdir -p "$sdkDir" + tar \ + --extract \ + --gzip \ + --file ${connectorsSdk} \ + --strip-components=1 \ + --directory "$sdkDir" makeWrapper "${pkgs.nodejs_22}/bin/node" "$out/bin/pup" \ --add-flags "$out/libexec/pup-wrapper.mjs" \ @@ -42,6 +69,9 @@ let installCheckPhase = '' runHook preInstallCheck + PUP_INSTALLED_WRAPPER="$out/libexec/pup-wrapper.mjs" \ + HOME="$TMPDIR" \ + node --test ${wrapperSource}/pup-wrapper.test.mjs HOME="$TMPDIR" "$out/bin/pup" --help >/dev/null runHook postInstallCheck diff --git a/pkgs/modules/pup/pup-wrapper.mjs b/pkgs/modules/pup/pup-wrapper.mjs index 719d11cb..ea36fa6f 100644 --- a/pkgs/modules/pup/pup-wrapper.mjs +++ b/pkgs/modules/pup/pup-wrapper.mjs @@ -1,27 +1,378 @@ -import {spawn} from 'node:child_process' +import {execFile, spawn} from 'node:child_process' +import {mkdtemp, mkdir, readFile, rename, rm, writeFile} from 'node:fs/promises' +import {createRequire} from 'node:module' +import {homedir, tmpdir} from 'node:os' +import {dirname, join, resolve} from 'node:path' +import {pathToFileURL} from 'node:url' +import {promisify} from 'node:util' -const realPup = process.env.PUP_REAL_BINARY +const execFileAsync = promisify(execFile) +const require = createRequire(import.meta.url) -if (!realPup) { - process.stderr.write('PUP_REAL_BINARY is not set\n') - process.exit(1) +const CONNECTOR_NAME = 'datadog' +const DEFAULT_CONNECTORS_HOST = 'connectors.replit.com' +const CONNECTORS_BASE_URL = `https://${DEFAULT_CONNECTORS_HOST}` +const CACHE_TTL_MS = 5 * 60 * 1000 + +// OpenInt caps CLI proxy path parameters at 128 characters, so bound the id +// rather than accepting an arbitrarily long path segment from the cache file. +const CONNECTION_ID_PATTERN = new RegExp( + `^conn_${CONNECTOR_NAME}_[a-z0-9_-]{1,115}$`, + 'i', +) + +const AUTHLESS_FLAGS = new Set(['--help', '-h', '--version']) +const AUTHLESS_COMMANDS = new Set(['help', 'version']) +const SUPPORTED_COMMAND_ROOTS = new Set([ + 'dashboards', + 'logs', + 'metrics', + 'monitors', + 'traces', +]) +const GLOBAL_FLAGS = new Set(['--no-agent', '--read-only']) +const GLOBAL_FLAGS_WITH_VALUE = new Set(['-o', '--output']) +const SUPPORTED_COMMANDS = new Set([ + 'logs search', + 'logs aggregate', + 'metrics query', + 'traces search', + 'traces aggregate', + 'monitors list', + 'monitors get', + 'dashboards list', + 'dashboards get', + 'dashboards create', + 'dashboards update', +]) + +// The SDK has equivalent helpers, but normal pup commands must always reach +// the production connectors host. Honoring a caller-controlled host here could +// send a freshly minted Replit identity to an arbitrary server. +export function resolveAudience() { + return CONNECTORS_BASE_URL +} + +export async function resolveIdentityToken(env, execFn = execFileAsync) { + try { + const {stdout} = await execFn( + env.REPLIT_CLI || 'replit', + ['identity', 'create', '--audience', resolveAudience()], + {encoding: 'utf8', env}, + ) + const token = stdout.trim() + if (token) { + return token + } + } catch { + // The CLI is unavailable in some runtimes; fall back to the env strategies. + } + + if (env.REPL_IDENTITY) { + return `repl ${env.REPL_IDENTITY}` + } + if (env.WEB_REPL_RENEWAL) { + return `depl ${env.WEB_REPL_RENEWAL}` + } + + throw new Error( + 'Replit identity not found. Could not run `replit identity create`, and ' + + 'neither REPL_IDENTITY nor WEB_REPL_RENEWAL is set. Are you running ' + + 'inside a Repl?', + ) +} + +export function resolveCachePath(env) { + const root = env.XDG_CACHE_HOME || join(env.HOME || homedir(), '.cache') + return join(root, 'replit', 'pup-openint.json') +} + +/** + * Return the connection id encoded in an OpenInt CLI proxy URL, or null when + * the URL is not one. The cache file is user-writable, so this also stops a + * tampered entry from redirecting the Replit identity to another host. + */ +export function parseConnectionId(proxyUrl, baseUrl) { + let base + let proxy + try { + base = new URL(baseUrl) + proxy = new URL(proxyUrl) + } catch { + return null + } + + const prefix = `${base.pathname.replace(/\/$/, '')}/api/v2/cli-proxy/${CONNECTOR_NAME}/` + + if ( + proxy.origin !== base.origin || + proxy.username || + proxy.password || + proxy.search || + proxy.hash || + !proxy.pathname.startsWith(prefix) + ) { + return null + } + + const connectionId = decodeURIComponent(proxy.pathname.slice(prefix.length)) + return CONNECTION_ID_PATTERN.test(connectionId) ? connectionId : null +} + +export function readCacheEntry(raw, baseUrl, now) { + if (raw === null || typeof raw !== 'object') { + return null + } + + // Every comparison against NaN is false, so a non-numeric timestamp would + // slip past the age check below and pin the cache entry forever. + if (typeof raw.cachedAt !== 'number' || !Number.isFinite(raw.cachedAt)) { + return null + } + + const age = now - raw.cachedAt + if (age < 0 || age > CACHE_TTL_MS) { + return null + } + + const connectionId = parseConnectionId(raw.proxyUrl, baseUrl) + if (!connectionId || connectionId !== raw.connectionId) { + return null + } + + return {connectionId, proxyUrl: raw.proxyUrl} +} + +async function loadCacheEntry(cachePath, baseUrl, now) { + try { + const raw = JSON.parse(await readFile(cachePath, 'utf8')) + return readCacheEntry(raw, baseUrl, now) + } catch { + return null + } +} + +async function saveCacheEntry(cachePath, entry) { + await mkdir(dirname(cachePath), {recursive: true, mode: 0o700}) + + const temporaryPath = `${cachePath}.${process.pid}.tmp` + try { + await writeFile(temporaryPath, `${JSON.stringify(entry)}\n`, {mode: 0o600}) + await rename(temporaryPath, cachePath) + } catch (error) { + await rm(temporaryPath, {force: true}) + throw error + } +} + +async function discoverCliConfig() { + let sdk + try { + sdk = require('@replit/connectors-sdk') + } catch (error) { + throw new Error( + `Could not load @replit/connectors-sdk: ${error.message}. The pup module ` + + 'must ship the SDK beside the wrapper.', + ) + } + + const previousAudience = process.env.REPLIT_CONNECTORS_AUDIENCE + process.env.REPLIT_CONNECTORS_AUDIENCE = CONNECTORS_BASE_URL + + try { + return await new sdk.ReplitConnectors({ + baseUrl: CONNECTORS_BASE_URL, + }).getCliConfig(CONNECTOR_NAME) + } finally { + if (previousAudience === undefined) { + delete process.env.REPLIT_CONNECTORS_AUDIENCE + } else { + process.env.REPLIT_CONNECTORS_AUDIENCE = previousAudience + } + } +} + +/** + * Resolve the proxy URL and a Replit identity for one pup invocation. Only the + * non-secret proxy URL is cached; the identity is short-lived and always minted + * fresh. + */ +export async function resolveRuntimeConfig({ + env, + now = Date.now(), + discover = discoverCliConfig, + mint = resolveIdentityToken, +}) { + const cachePath = resolveCachePath(env) + + const cached = await loadCacheEntry(cachePath, CONNECTORS_BASE_URL, now) + if (cached) { + return {proxyUrl: cached.proxyUrl, token: await mint(env), cachePath} + } + + const config = await discover() + const connectionId = parseConnectionId(config?.host, CONNECTORS_BASE_URL) + if (!connectionId || typeof config.token !== 'string' || !config.token) { + throw new Error( + `The Connectors SDK returned an unusable ${CONNECTOR_NAME} CLI configuration.`, + ) + } + + try { + await saveCacheEntry(cachePath, { + connectionId, + proxyUrl: config.host, + cachedAt: now, + }) + } catch (error) { + process.stderr.write( + `pup: could not cache the OpenInt proxy URL: ${error.message}\n`, + ) + } + + return {proxyUrl: config.host, token: config.token, cachePath} +} + +export function buildChildEnv(env, {proxyUrl, token, configDir}) { + const childEnv = { + ...env, + PUP_MOCK_SERVER: proxyUrl, + DD_ACCESS_TOKEN: token, + // Isolate pup from ~/.config/pup/config.yaml, which can carry Datadog keys. + PUP_CONFIG_DIR: configDir, + } + + // pup falls back to these for endpoints that reject bearer tokens. Leaving a + // caller's real Datadog keys in place would forward them through the proxy. + delete childEnv.DD_API_KEY + delete childEnv.DD_APP_KEY + delete childEnv.DD_ORG + delete childEnv.DD_SITE + delete childEnv.PUP_DOCS_AI_URL + delete childEnv.PUP_SKIP_OPENINT + + return childEnv +} + +function commandIndex(args) { + let index = 0 + while (true) { + if (GLOBAL_FLAGS.has(args[index] ?? '')) { + index += 1 + continue + } + if (GLOBAL_FLAGS_WITH_VALUE.has(args[index] ?? '')) { + index += 2 + continue + } + break + } + + return index +} + +export function skipsOpenIntAuth(args) { + if (args.length === 0) { + return true + } + + const endOfOptions = args.indexOf('--') + const options = endOfOptions === -1 ? args : args.slice(0, endOfOptions) + const root = options[commandIndex(options)] ?? '' + const hasAuthlessFlag = options.some((arg) => AUTHLESS_FLAGS.has(arg)) + + if (AUTHLESS_COMMANDS.has(root)) { + return true + } + + return ( + options.length === 1 && + AUTHLESS_FLAGS.has(root) + ) || ( + SUPPORTED_COMMAND_ROOTS.has(root) && + hasAuthlessFlag + ) } -const child = spawn(realPup, process.argv.slice(2), { - env: process.env, - stdio: 'inherit', -}) +export function assertSupportedCommand(args) { + if (skipsOpenIntAuth(args)) { + return + } + + const index = commandIndex(args) + const command = `${args[index] ?? ''} ${args[index + 1] ?? ''}` + if (!SUPPORTED_COMMANDS.has(command)) { + throw new Error( + `Unsupported pup command: ${command.trim()}. This workspace supports logs, metrics, traces, monitor reads, and dashboard read/write operations.`, + ) + } +} -child.on('error', (error) => { - process.stderr.write(`Failed to start pup: ${error.message}\n`) - process.exit(1) -}) +function runPup(binary, args, env) { + return new Promise((resolveExit, reject) => { + const child = spawn(binary, args, {env, stdio: 'inherit'}) -child.on('close', (code, signal) => { + child.once('error', (error) => { + reject(new Error(`Failed to start pup: ${error.message}`)) + }) + + child.once('close', (code, signal) => { + resolveExit({code: code ?? 1, signal}) + }) + }) +} + +function exitFromPup({code, signal}) { if (signal) { process.kill(process.pid, signal) - return } + return code +} + +export async function main({env = process.env, args = process.argv.slice(2)} = {}) { + const realPup = env.PUP_REAL_BINARY + if (!realPup) { + throw new Error('PUP_REAL_BINARY is not set') + } + + if (skipsOpenIntAuth(args)) { + return exitFromPup(await runPup(realPup, args, env)) + } + + assertSupportedCommand(args) + + const {proxyUrl, token, cachePath} = await resolveRuntimeConfig({env}) + const configDir = await mkdtemp(join(tmpdir(), 'pup-openint-')) + let signal - process.exit(code ?? 1) -}) + try { + const result = await runPup( + realPup, + args, + buildChildEnv(env, {proxyUrl, token, configDir}), + ) + signal = result.signal + return result.code + } finally { + await rm(configDir, {recursive: true, force: true}) + if (signal) { + process.kill(process.pid, signal) + } + } +} + +const isDirectInvocation = + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(resolve(process.argv[1])).href + +if (isDirectInvocation) { + main().then( + (code) => { + process.exitCode = code + }, + (error) => { + process.stderr.write(`${error.message}\n`) + process.exitCode = 1 + }, + ) +} diff --git a/pkgs/modules/pup/pup-wrapper.test.mjs b/pkgs/modules/pup/pup-wrapper.test.mjs new file mode 100644 index 00000000..d7cde401 --- /dev/null +++ b/pkgs/modules/pup/pup-wrapper.test.mjs @@ -0,0 +1,545 @@ +import assert from 'node:assert/strict' +import {spawn} from 'node:child_process' +import {access, mkdtemp, mkdir, readFile, readdir, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {dirname, join} from 'node:path' +import {pathToFileURL} from 'node:url' +import {test} from 'node:test' + +import { + assertSupportedCommand, + buildChildEnv, + main, + parseConnectionId, + readCacheEntry, + resolveAudience, + resolveCachePath, + resolveIdentityToken, + resolveRuntimeConfig, + skipsOpenIntAuth, +} from './pup-wrapper.mjs' + +const BASE_URL = 'https://connectors.replit.com' +const CONNECTION_ID = 'conn_datadog_abc123' +const PROXY_URL = `${BASE_URL}/api/v2/cli-proxy/datadog/${CONNECTION_ID}` +const NOW = 1_700_000_000_000 + +async function makeSandbox() { + const root = await mkdtemp(join(tmpdir(), 'pup-wrapper-')) + return { + root, + env: { + XDG_CACHE_HOME: root, + // Force the CLI branch to fail so tests never mint a real identity. + REPLIT_CLI: join(root, 'missing-replit'), + }, + } +} + +async function seedCache(env, entry) { + const cachePath = resolveCachePath(env) + await mkdir(dirname(cachePath), {recursive: true}) + await writeFile(cachePath, JSON.stringify(entry)) + return cachePath +} + +async function makeFakePup(root, {exitCode = 0} = {}) { + const binary = join(root, 'fake-pup') + const log = join(root, 'fake-pup.log') + + await writeFile( + binary, + [ + '#!/bin/sh', + '{', + ' echo "args:$*"', + ' echo "PUP_MOCK_SERVER=${PUP_MOCK_SERVER-}"', + ' echo "DD_ACCESS_TOKEN=${DD_ACCESS_TOKEN-}"', + ' echo "DD_API_KEY=${DD_API_KEY-}"', + ' echo "DD_APP_KEY=${DD_APP_KEY-}"', + ' echo "PUP_CONFIG_DIR=${PUP_CONFIG_DIR-}"', + `} > ${JSON.stringify(log)}`, + `exit ${exitCode}`, + '', + ].join('\n'), + {mode: 0o755}, + ) + + return {binary, readLog: () => readFile(log, 'utf8')} +} + +function runChild(command, args, env) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, {env, stdio: 'ignore'}) + child.once('error', reject) + child.once('close', (code, signal) => resolve({code, signal})) + }) +} + +test('rejects a cache entry whose timestamp is not a number', () => { + for (const cachedAt of [undefined, null, 'abc', Number.NaN, {}]) { + const entry = readCacheEntry( + {connectionId: CONNECTION_ID, proxyUrl: PROXY_URL, cachedAt}, + BASE_URL, + NOW, + ) + assert.equal(entry, null, `expected rejection for cachedAt=${String(cachedAt)}`) + } +}) + +test('accepts a fresh entry and rejects an expired or future one', () => { + const entry = {connectionId: CONNECTION_ID, proxyUrl: PROXY_URL} + + assert.deepEqual(readCacheEntry({...entry, cachedAt: NOW - 1_000}, BASE_URL, NOW), { + connectionId: CONNECTION_ID, + proxyUrl: PROXY_URL, + }) + assert.equal(readCacheEntry({...entry, cachedAt: NOW - 5 * 60 * 1000 - 1}, BASE_URL, NOW), null) + assert.equal(readCacheEntry({...entry, cachedAt: NOW + 1_000}, BASE_URL, NOW), null) +}) + +test('rejects a cache entry whose connection id disagrees with its URL', () => { + const entry = readCacheEntry( + {connectionId: 'conn_datadog_other', proxyUrl: PROXY_URL, cachedAt: NOW}, + BASE_URL, + NOW, + ) + assert.equal(entry, null) +}) + +test('parses a connection id only from a matching CLI proxy URL', () => { + assert.equal(parseConnectionId(PROXY_URL, BASE_URL), CONNECTION_ID) + + const rejected = [ + `https://evil.example.com/api/v2/cli-proxy/datadog/${CONNECTION_ID}`, + `http://connectors.replit.com/api/v2/cli-proxy/datadog/${CONNECTION_ID}`, + `${BASE_URL}/api/v2/cli-proxy/databricks/${CONNECTION_ID}`, + `${BASE_URL}/api/v2/cli-proxy/datadog/${CONNECTION_ID}?x=1`, + `${BASE_URL}/api/v2/cli-proxy/datadog/${CONNECTION_ID}#x`, + `https://user:pass@connectors.replit.com/api/v2/cli-proxy/datadog/${CONNECTION_ID}`, + `${BASE_URL}/api/v2/cli-proxy/datadog/../../../evil`, + `${BASE_URL}/api/v2/cli-proxy/datadog/conn_stripe_abc`, + `${BASE_URL}/api/v2/cli-proxy/datadog/`, + 'not-a-url', + undefined, + ] + + for (const proxyUrl of rejected) { + assert.equal(parseConnectionId(proxyUrl, BASE_URL), null, `expected rejection for ${proxyUrl}`) + } +}) + +test('bounds the connection id to the length OpenInt accepts', () => { + const within = `${BASE_URL}/api/v2/cli-proxy/datadog/conn_datadog_${'a'.repeat(115)}` + const beyond = `${BASE_URL}/api/v2/cli-proxy/datadog/conn_datadog_${'a'.repeat(116)}` + + assert.ok(parseConnectionId(within, BASE_URL)) + assert.equal(parseConnectionId(beyond, BASE_URL), null) +}) + +test('uses the fixed production connectors audience', () => { + assert.equal(resolveAudience(), 'https://connectors.replit.com') +}) + +test('falls back to the identity environment variables when the CLI fails', async () => { + const failing = () => { + throw new Error('spawn replit ENOENT') + } + + assert.equal(await resolveIdentityToken({REPL_IDENTITY: 'abc'}, failing), 'repl abc') + assert.equal(await resolveIdentityToken({WEB_REPL_RENEWAL: 'xyz'}, failing), 'depl xyz') + await assert.rejects(() => resolveIdentityToken({}, failing), /Replit identity not found/) +}) + +test('falls back when the identity CLI returns an empty token', async () => { + const empty = async () => ({stdout: ' \n'}) + assert.equal(await resolveIdentityToken({REPL_IDENTITY: 'abc'}, empty), 'repl abc') +}) + +test('passes the resolved audience to the identity CLI', async () => { + const calls = [] + const capture = async (binary, args) => { + calls.push({binary, args}) + return {stdout: 'minted-token\n'} + } + + const token = await resolveIdentityToken( + {REPLIT_CLI: '/custom/replit', REPLIT_CONNECTORS_AUDIENCE: 'https://a.example.com/base'}, + capture, + ) + + assert.equal(token, 'minted-token') + assert.deepEqual(calls, [ + { + binary: '/custom/replit', + args: ['identity', 'create', '--audience', 'https://connectors.replit.com'], + }, + ]) +}) + +test('discovers and caches the proxy URL on a cache miss', async () => { + const {env} = await makeSandbox() + let discoverCalls = 0 + + const config = await resolveRuntimeConfig({ + env, + now: NOW, + discover: async () => { + discoverCalls += 1 + return {host: PROXY_URL, token: 'sdk-token', connectorName: 'datadog'} + }, + mint: async () => assert.fail('a cache miss must reuse the SDK token'), + }) + + assert.equal(discoverCalls, 1) + assert.equal(config.proxyUrl, PROXY_URL) + assert.equal(config.token, 'sdk-token') + + const cached = JSON.parse(await readFile(resolveCachePath(env), 'utf8')) + assert.deepEqual(cached, { + connectionId: CONNECTION_ID, + proxyUrl: PROXY_URL, + cachedAt: NOW, + }) +}) + +test('never writes an identity token to the cache file', async () => { + const {env} = await makeSandbox() + + await resolveRuntimeConfig({ + env, + now: NOW, + discover: async () => ({host: PROXY_URL, token: 'secret-token', connectorName: 'datadog'}), + mint: async () => 'secret-token', + }) + + const contents = await readFile(resolveCachePath(env), 'utf8') + assert.ok(!contents.includes('secret-token')) +}) + +test('reuses a warm cache and mints a fresh identity', async () => { + const {env} = await makeSandbox() + await seedCache(env, {connectionId: CONNECTION_ID, proxyUrl: PROXY_URL, cachedAt: NOW}) + + const config = await resolveRuntimeConfig({ + env, + now: NOW + 1_000, + discover: async () => assert.fail('a warm cache must not call the SDK'), + mint: async () => 'fresh-token', + }) + + assert.equal(config.proxyUrl, PROXY_URL) + assert.equal(config.token, 'fresh-token') +}) + +test('rediscovers when the cached entry is expired or corrupt', async () => { + const cases = [ + {connectionId: CONNECTION_ID, proxyUrl: PROXY_URL, cachedAt: NOW - 6 * 60 * 1000}, + {connectionId: CONNECTION_ID, proxyUrl: PROXY_URL, cachedAt: 'not-a-number'}, + {connectionId: CONNECTION_ID, proxyUrl: 'https://evil.example.com/x', cachedAt: NOW}, + ] + + for (const entry of cases) { + const {env} = await makeSandbox() + await seedCache(env, entry) + + let discoverCalls = 0 + await resolveRuntimeConfig({ + env, + now: NOW, + discover: async () => { + discoverCalls += 1 + return {host: PROXY_URL, token: 'sdk-token', connectorName: 'datadog'} + }, + mint: async () => assert.fail('a rejected cache must not take the warm path'), + }) + + assert.equal(discoverCalls, 1, `expected rediscovery for ${JSON.stringify(entry)}`) + } +}) + +test('does not trust a caller-provided connectors host', async () => { + const {env} = await makeSandbox() + const untrustedEnv = { + ...env, + REPLIT_CONNECTORS_HOSTNAME: 'https://evil.example.com', + } + await seedCache(untrustedEnv, { + connectionId: CONNECTION_ID, + proxyUrl: 'https://evil.example.com/api/v2/cli-proxy/datadog/conn_datadog_abc123', + cachedAt: NOW, + }) + + let discoverCalls = 0 + await resolveRuntimeConfig({ + env: untrustedEnv, + now: NOW, + discover: async () => { + discoverCalls += 1 + return {host: PROXY_URL, token: 'sdk-token', connectorName: 'datadog'} + }, + mint: async () => assert.fail('an untrusted cache must not take the warm path'), + }) + + assert.equal(discoverCalls, 1) +}) + +test('rejects an unusable SDK configuration', async () => { + const {env} = await makeSandbox() + + await assert.rejects( + () => + resolveRuntimeConfig({ + env, + now: NOW, + discover: async () => ({host: 'https://evil.example.com/x', token: 'sdk-token'}), + mint: async () => 'unused', + }), + /unusable datadog CLI configuration/, + ) +}) + +test('replaces Datadog credentials in the child environment', () => { + const childEnv = buildChildEnv( + { + PATH: '/usr/bin', + DD_API_KEY: 'real-key', + DD_APP_KEY: 'real-app-key', + DD_ORG: 'customer-org', + DD_SITE: 'evil.example.com', + PUP_DOCS_AI_URL: 'https://evil.example.com', + PUP_SKIP_OPENINT: '1', + }, + {proxyUrl: PROXY_URL, token: 'identity-token', configDir: '/tmp/pup-config'}, + ) + + assert.equal(childEnv.PUP_MOCK_SERVER, PROXY_URL) + assert.equal(childEnv.DD_ACCESS_TOKEN, 'identity-token') + assert.equal(childEnv.PUP_CONFIG_DIR, '/tmp/pup-config') + assert.equal(childEnv.PATH, '/usr/bin') + assert.ok(!('DD_API_KEY' in childEnv)) + assert.ok(!('DD_APP_KEY' in childEnv)) + assert.ok(!('DD_ORG' in childEnv)) + assert.ok(!('DD_SITE' in childEnv)) + assert.ok(!('PUP_DOCS_AI_URL' in childEnv)) + assert.ok(!('PUP_SKIP_OPENINT' in childEnv)) +}) + +test('skips OpenInt setup only for commands that need no credentials', () => { + assert.ok(skipsOpenIntAuth([], {})) + assert.ok(skipsOpenIntAuth(['--help'], {})) + assert.ok(skipsOpenIntAuth(['logs', 'search', '--help'], {})) + assert.ok(skipsOpenIntAuth(['version'], {})) + + assert.ok(!skipsOpenIntAuth(['monitors', 'list'], {})) + assert.ok(!skipsOpenIntAuth(['logs', 'search', '--query', 'status:error'], {})) + assert.ok(!skipsOpenIntAuth(['monitors', 'list'], {PUP_SKIP_OPENINT: '1'})) +}) + +test('allows only the approved first-release command surface', () => { + const allowed = [ + ['logs', 'search'], + ['logs', 'aggregate'], + ['metrics', 'query'], + ['traces', 'search'], + ['traces', 'aggregate'], + ['monitors', 'list'], + ['monitors', 'get', '123'], + ['dashboards', 'list'], + ['dashboards', 'get', 'abc'], + ['dashboards', 'create', '--file', 'dashboard.json'], + ['dashboards', 'update', 'abc', '--file', 'dashboard.json'], + ] + const rejected = [ + ['api', 'https://evil.example.com'], + ['bits', 'ask', 'hello'], + ['acp', 'serve'], + ['auth', 'login'], + ['extensions', 'run'], + ['metrics', 'submit'], + ['monitors', 'delete', '123'], + ['dashboards', 'delete', 'abc'], + ] + + for (const args of allowed) { + assert.doesNotThrow(() => assertSupportedCommand(args)) + } + assert.doesNotThrow(() => + assertSupportedCommand(['--no-agent', '-o', 'json', 'monitors', 'list']), + ) + for (const args of rejected) { + assert.throws(() => assertSupportedCommand(args), /Unsupported pup command/) + } +}) + +test('forwards arguments, exit code, and environment to the real binary', async () => { + const {root, env} = await makeSandbox() + const pup = await makeFakePup(root, {exitCode: 3}) + await seedCache(env, {connectionId: CONNECTION_ID, proxyUrl: PROXY_URL, cachedAt: Date.now()}) + + const exitCode = await main({ + env: { + ...env, + PUP_REAL_BINARY: pup.binary, + REPL_IDENTITY: 'identity-material', + DD_API_KEY: 'real-key', + DD_APP_KEY: 'real-app-key', + }, + args: ['monitors', 'list'], + }) + + assert.equal(exitCode, 3) + + const log = await pup.readLog() + assert.match(log, /args:monitors list/) + assert.match(log, new RegExp(`PUP_MOCK_SERVER=${PROXY_URL}`)) + assert.match(log, /DD_ACCESS_TOKEN=repl identity-material/) + assert.match(log, /DD_API_KEY=/) + assert.match(log, /DD_APP_KEY=/) + assert.match(log, /PUP_CONFIG_DIR=\S+/) + + const configDir = log.match(/PUP_CONFIG_DIR=(\S+)/)?.[1] + assert.ok(configDir) + await assert.rejects(() => access(configDir)) +}) + +test('runs authless commands without touching OpenInt', async () => { + const {root, env} = await makeSandbox() + const pup = await makeFakePup(root) + + const exitCode = await main({ + env: {...env, PUP_REAL_BINARY: pup.binary}, + args: ['--help'], + }) + + assert.equal(exitCode, 0) + assert.match(await pup.readLog(), /PUP_MOCK_SERVER=/) +}) + +test('does not treat a value after -- as an authless flag', () => { + const args = ['dashboards', 'get', '--', '--help'] + assert.ok(!skipsOpenIntAuth(args)) + assert.doesNotThrow(() => assertSupportedCommand(args)) +}) + +test('does not run an extension through an authless flag', () => { + const args = ['untrusted-extension', '--version'] + assert.ok(!skipsOpenIntAuth(args)) + assert.throws(() => assertSupportedCommand(args), /Unsupported pup command/) +}) + +test('removes the temporary config before forwarding a child signal', async () => { + const {root, env} = await makeSandbox() + const signalPup = join(root, 'signal-pup') + const wrapper = new URL('./pup-wrapper.mjs', import.meta.url).pathname + await writeFile(signalPup, '#!/bin/sh\nkill -TERM $$\n', {mode: 0o755}) + await seedCache(env, { + connectionId: CONNECTION_ID, + proxyUrl: PROXY_URL, + cachedAt: Date.now(), + }) + + const result = await runChild(process.execPath, [wrapper, 'monitors', 'list'], { + ...process.env, + ...env, + PUP_REAL_BINARY: signalPup, + REPL_IDENTITY: 'identity-material', + TMPDIR: root, + }) + + assert.equal(result.signal, 'SIGTERM') + const entries = await readdir(root) + assert.ok(!entries.some((entry) => entry.startsWith('pup-openint-'))) +}) + +test('fails when the real pup binary is not configured or missing', async () => { + const {root, env} = await makeSandbox() + + await assert.rejects(() => main({env, args: ['--help']}), /PUP_REAL_BINARY is not set/) + await assert.rejects( + () => main({env: {...env, PUP_REAL_BINARY: join(root, 'absent')}, args: ['--help']}), + /Failed to start pup:/, + ) +}) + +test('rejects unsupported commands before resolving OpenInt configuration', async () => { + const {root, env} = await makeSandbox() + const pup = await makeFakePup(root) + + await assert.rejects( + () => + main({ + env: { + ...env, + PUP_REAL_BINARY: pup.binary, + PUP_SKIP_OPENINT: '1', + }, + args: ['api', 'https://evil.example.com'], + }), + /Unsupported pup command/, + ) +}) + +test( + 'installed wrapper loads the bundled SDK for cold-cache discovery', + {skip: !process.env.PUP_INSTALLED_WRAPPER}, + async (t) => { + const {root, env} = await makeSandbox() + const replit = join(root, 'replit') + const pup = await makeFakePup(root) + const requests = [] + const originalFetch = global.fetch + const originalReplitCli = process.env.REPLIT_CLI + + await writeFile(replit, '#!/bin/sh\nprintf "test-identity-token\\n"\n', { + mode: 0o755, + }) + process.env.REPLIT_CLI = replit + global.fetch = async (url, init) => { + const requestUrl = new URL(String(url)) + requests.push({ + url: requestUrl, + authorization: new Headers(init?.headers).get('Replit-Authentication'), + }) + + if ( + requestUrl.origin === 'https://connectors.replit.com' && + requestUrl.pathname === '/api/v2/connection' + ) { + return new Response( + JSON.stringify({ + items: [{id: CONNECTION_ID, connector_name: 'datadog'}], + }), + {status: 200, headers: {'Content-Type': 'application/json'}}, + ) + } + + return new Response('unexpected request', {status: 404}) + } + t.after(() => { + global.fetch = originalFetch + if (originalReplitCli === undefined) { + delete process.env.REPLIT_CLI + } else { + process.env.REPLIT_CLI = originalReplitCli + } + }) + + const installed = await import( + pathToFileURL(process.env.PUP_INSTALLED_WRAPPER).href, + ) + const exitCode = await installed.main({ + env: {...env, PUP_REAL_BINARY: pup.binary}, + args: ['monitors', 'list'], + }) + + assert.equal(exitCode, 0) + assert.equal(requests.length, 1) + assert.equal(requests[0].url.pathname, '/api/v2/connection') + assert.equal( + requests[0].authorization, + 'Bearer test-identity-token', + ) + assert.match(await pup.readLog(), new RegExp(`PUP_MOCK_SERVER=${PROXY_URL}`)) + }, +)