From ac016556b2d5341c4efcf481b58c4d8a1f6cb9bb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 04:11:18 +0000 Subject: [PATCH 1/2] feat(glitch-invaders): read Sentry settings from the environment over sentry.json SENTRY_DSN, SENTRY_ORG, SENTRY_PROJECT, and optionally SENTRY_URL replace sentry.json's values, so an event can point at its own project through challenge_env. Setting only some of the three fails setup naming the rest. Closes #28 --- challenges/glitch-invaders/README.md | 48 +++++++++---- challenges/glitch-invaders/sentry.json | 2 +- .../glitch-invaders/server/sentry-config.mjs | 71 +++++++++++++++++-- test/glitch-invaders.test.mjs | 58 +++++++++++++++ 4 files changed, 160 insertions(+), 19 deletions(-) diff --git a/challenges/glitch-invaders/README.md b/challenges/glitch-invaders/README.md index 40e5f99..210835a 100644 --- a/challenges/glitch-invaders/README.md +++ b/challenges/glitch-invaders/README.md @@ -15,7 +15,7 @@ leaves the machine. | File | Purpose | | --- | --- | | `challenge.json`, `plugin.json` | Manifest and Agent Plugin metadata | -| `sentry.json` | Public Sentry settings for live mode: DSN, org slug, project slug | +| `sentry.json` | Default public Sentry settings for live mode: DSN, org slug, project slug | | `intro.md` | Intro screen text | | `skills/start-challenge/SKILL.md` | The guide Pi follows | | `monitor/SKILL.md` | Headless browser monitor skill | @@ -44,20 +44,42 @@ the challenge manifest expects 4373. Live mode needs both halves: -- **`sentry.json` filled in.** The DSN, org slug, and project slug are public - values, so they live in the package. While any value is still a - ``, the game runs in demo mode. Once filled in, a malformed value - fails setup naming it. An optional `apiUrl` replaces the Sentry API origin, - `https://sentry.io`, with a subdomain of it or, for the tests' local - stand-in, a loopback address; the token goes to no other host. +- **Public Sentry settings.** The DSN, org slug, and project slug are public + values, so the package ships them in `sentry.json`. While any value there is + still a ``, the game runs in demo mode. Once filled in, a + malformed value fails setup naming it. An optional `apiUrl` replaces the + Sentry API origin, `https://sentry.io`, with a subdomain of it or, for the + tests' local stand-in, a loopback address; the token goes to no other host. - **`SENTRY_AUTH_TOKEN` in the environment.** On a station, the event vault's `sentry` service sets it to a placeholder that the vault proxy swaps for the real token. `boothstrap dev` and a standalone run read the real token from your shell. +Sentry's own variables replace `sentry.json` when set: `SENTRY_DSN`, +`SENTRY_ORG`, `SENTRY_PROJECT`, and optionally `SENTRY_URL` for `apiUrl`. They +pass the same checks. Setting any of the first three requires all three, and +setup fails naming the ones missing; an empty variable counts as unset. An +event points at its own Sentry project by setting them under +`challenge_env.glitch-invaders` in its config: + +```json +{ + "challenge_env": { + "glitch-invaders": { + "SENTRY_DSN": "https://@o.ingest.us.sentry.io/", + "SENTRY_ORG": "", + "SENTRY_PROJECT": "" + } + } +} +``` + +These are public settings only: the token stays in the vault. A station +whose boothstrap predates `challenge_env` uses `sentry.json`. + `boothstrap dev` refuses to play the package unless `SENTRY_AUTH_TOKEN` or `SENTRY_API_KEY` is set, because `vault_services` names `sentry`. Any value -keeps demo mode while `sentry.json` holds placeholders, and `SENTRY_API_KEY` +keeps demo mode while the settings are placeholders, and `SENTRY_API_KEY` satisfies dev but leaves the game in demo mode, since the package reads only `SENTRY_AUTH_TOKEN`. @@ -73,7 +95,7 @@ page or written anywhere. The page's CSP lets it contact only this server and the DSN's ingest origin. If Sentry doesn't accept the report or can't return it within about 15 seconds, that incident shows simulated evidence with a note saying so. Setup prints `(live Sentry mode)` after the URL when live mode is on, -and a note when `sentry.json` is filled in but the token is missing. +and a note when the settings are filled in but the token is missing. Because `vault_services` names `sentry`, the event vault must hold a `sentry` service, or the station lists the challenge as unavailable, on every boothstrap @@ -93,9 +115,11 @@ no token and runs in demo mode. ## Run without boothstrap Needs Node 26 and a `GEMINI_API_KEY`; export `SENTRY_AUTH_TOKEN` too for live -mode. Follow the standalone steps in boothstrap.sh's -`docs/authoring-challenges.md` to install Pi and load the package as an Agent -Plugin, then: +mode. The game reports to the project in `sentry.json` unless you export +`SENTRY_DSN`, `SENTRY_ORG`, and `SENTRY_PROJECT` for another, as +[Live Sentry mode](#live-sentry-mode) describes. Follow the standalone steps +in boothstrap.sh's `docs/authoring-challenges.md` to install Pi and load the +package as an Agent Plugin, then: ```sh challenge=/absolute/path/to/glitch-invaders diff --git a/challenges/glitch-invaders/sentry.json b/challenges/glitch-invaders/sentry.json index a5d8d27..6111f36 100644 --- a/challenges/glitch-invaders/sentry.json +++ b/challenges/glitch-invaders/sentry.json @@ -1,5 +1,5 @@ { - "$comment": "Public settings for live Sentry mode; none is a secret. MLH fills these in from its Sentry project: dsn from Project Settings > Client Keys (DSN), org and project from the slugs in the project's URL. While any value is still a , the game runs in demo mode. Live mode also needs SENTRY_AUTH_TOKEN, which the station's vault provides; never put a token here. An optional apiUrl replaces the Sentry API origin, https://sentry.io, with a subdomain of it or a loopback stand-in for tests; the token goes to no other host.", + "$comment": "Public settings for live Sentry mode; none is a secret. MLH fills these in from its Sentry project: dsn from Project Settings > Client Keys (DSN), org and project from the slugs in the project's URL. While any value is still a , the game runs in demo mode. SENTRY_DSN, SENTRY_ORG, SENTRY_PROJECT, and SENTRY_URL in the environment replace these, so an event can point at its own project. Live mode also needs SENTRY_AUTH_TOKEN, which the station's vault provides; never put a token here. An optional apiUrl replaces the Sentry API origin, https://sentry.io, with a subdomain of it or a loopback stand-in for tests; the token goes to no other host.", "dsn": "https://39390e5e5e8e7a81f7013234e24343ee@o4512131379101696.ingest.us.sentry.io/4512142806941696", "org": "major-league-hacking-xc", "project": "glitch-invaders" diff --git a/challenges/glitch-invaders/server/sentry-config.mjs b/challenges/glitch-invaders/server/sentry-config.mjs index 2d3975e..82b631a 100644 --- a/challenges/glitch-invaders/server/sentry-config.mjs +++ b/challenges/glitch-invaders/server/sentry-config.mjs @@ -1,7 +1,8 @@ /** - * Live or demo: live Sentry mode needs both halves, the public settings in the package's - * `sentry.json` and `SENTRY_AUTH_TOKEN` in the environment, where the station's vault puts a - * placeholder it swaps for the real token on the way out. Anything less is demo mode. + * Live or demo: live Sentry mode needs both halves, the public settings and `SENTRY_AUTH_TOKEN` in + * the environment, where the station's vault puts a placeholder it swaps for the real token on the + * way out. Anything less is demo mode. The settings come from Sentry's own variables when an event + * sets them, else from the package's `sentry.json`. */ import { readFile } from 'node:fs/promises'; import path from 'node:path'; @@ -13,6 +14,17 @@ export const SENTRY_CONFIG_FILE_NAME = 'sentry.json'; /** The variable the vault's `sentry` service sets, and the one Sentry's own tools read. */ export const SENTRY_TOKEN_VARIABLE = 'SENTRY_AUTH_TOKEN'; +/** Sentry's own variable for each setting, which an event's config sets over `sentry.json`. */ +const SENTRY_SETTING_VARIABLES = { + dsn: 'SENTRY_DSN', + org: 'SENTRY_ORG', + project: 'SENTRY_PROJECT', + apiUrl: 'SENTRY_URL', +}; + +/** The settings an event must set together: all of them but the optional API origin. */ +const REQUIRED_SETTINGS = ['dsn', 'org', 'project']; + /** Whether `value` is still a `` waiting for MLH's real setting. */ function isPlaceholder(value) { return typeof value === 'string' && value.startsWith('<') && value.endsWith('>'); @@ -54,14 +66,61 @@ export async function readSentryConfig(packageDir) { } /** - * Decide the attempt's mode from the package config and `env`. + * Read the Sentry settings an event sets in `env`, where an empty variable counts as unset. + * + * @returns {ReturnType | null} the settings, or `null` when none of + * `SENTRY_DSN`, `SENTRY_ORG`, and `SENTRY_PROJECT` is set. + * @throws {Error} naming the missing variables when only some of those three are set, or the + * environment when a value is malformed. + */ +function readSentryEnvironment(env) { + const record = {}; + for (const [setting, variable] of Object.entries(SENTRY_SETTING_VARIABLES)) { + const value = env[variable] ?? ''; + if (value !== '') { + record[setting] = value; + } + } + + const missing = REQUIRED_SETTINGS.filter((setting) => record[setting] === undefined); + if (missing.length === REQUIRED_SETTINGS.length) { + return null; + } + if (missing.length > 0) { + const names = missing.map((setting) => SENTRY_SETTING_VARIABLES[setting]).join(', '); + throw new Error(`Sentry settings in the environment are incomplete: ${names} not set`); + } + try { + return parseSentrySettings(record); + } catch (error) { + throw new Error(`Sentry settings in the environment: ${error.message}`); + } +} + +/** + * Read the attempt's Sentry settings: the event's from `env` when it sets them, else the package's. + * + * @returns {Promise | null>} `null` when neither has + * settings ready for live mode. + */ +async function readSentrySettings(env, packageDir) { + const fromEnvironment = readSentryEnvironment(env); + if (fromEnvironment !== null) { + return fromEnvironment; + } + + return readSentryConfig(packageDir); +} + +/** + * Decide the attempt's mode from the Sentry settings and `env`. * * @returns {Promise<{ sentry: ReturnType | null, note: string | null }>} - * `sentry` is `null` for demo mode; `note` says why live mode is off when the package is ready + * `sentry` is `null` for demo mode; `note` says why live mode is off when the settings are ready * for it, and is `null` otherwise. */ export async function decideMode(env, packageDir) { - const sentry = await readSentryConfig(packageDir); + const sentry = await readSentrySettings(env, packageDir); if (sentry === null) { return { sentry: null, note: null }; } diff --git a/test/glitch-invaders.test.mjs b/test/glitch-invaders.test.mjs index 9f6b892..8027e82 100644 --- a/test/glitch-invaders.test.mjs +++ b/test/glitch-invaders.test.mjs @@ -1256,6 +1256,64 @@ test('setup fails naming sentry.json when a filled-in value is malformed', async assert.match(setup.output, /setup: sentry\.json: invalid runtime config: sentry\.dsn must be a Sentry DSN/); }); +test('setup takes live Sentry settings from SENTRY_DSN, SENTRY_ORG, SENTRY_PROJECT, and SENTRY_URL over sentry.json', async (testContext) => { + const standIn = await startSentryStandIn(testContext); + const packageDir = await makePackageWithSettings({ + dsn: 'https://packagekey@o9.ingest.us.sentry.io/99', + org: 'package-org', + project: 'package-project', + }); + const stack = await startLivePackage(testContext, packageDir, { + SENTRY_AUTH_TOKEN: SENTRY_TOKEN, + SENTRY_DSN: LIVE_DSN, + SENTRY_ORG: 'mlh', + SENTRY_PROJECT: 'glitch-invaders', + SENTRY_URL: standIn.url, + }); + + const config = await (await fetch(`${stack.base}/runtime-config.json`)).json(); + const evidence = await fetchEvidence(stack.base, 'weapon', WEAPON_EVENT_ID); + + assert.match(stack.output, /\(live Sentry mode\)/); + assert.deepEqual(config.sentry, { dsn: LIVE_DSN, org: 'mlh', project: 'glitch-invaders', apiUrl: standIn.url }); + assert.equal(evidence.status, 200); + assert.equal(standIn.requests[0].url.pathname, `/api/0/projects/mlh/glitch-invaders/events/${WEAPON_EVENT_ID}/`); +}); + +test('setup fails naming the unset variables when the environment sets only some Sentry settings', async (testContext) => { + const workspace = await makeTemporaryDirectory(testContext); + const env = { ...hookEnvironment(workspace, await freePort()), SENTRY_DSN: LIVE_DSN, SENTRY_ORG: '' }; + testContext.after(() => runHook('cleanup', env)); + + const setup = await runHook('setup', env); + + assert.equal(setup.code, 1); + assert.match( + setup.stderr, + /setup: Sentry settings in the environment are incomplete: SENTRY_ORG, SENTRY_PROJECT not set/, + ); +}); + +test('setup fails naming the environment when a Sentry setting there is malformed', async (testContext) => { + const workspace = await makeTemporaryDirectory(testContext); + const env = { + ...hookEnvironment(workspace, await freePort()), + SENTRY_DSN: LIVE_DSN, + SENTRY_ORG: 'mlh', + SENTRY_PROJECT: 'glitch-invaders', + SENTRY_URL: 'https://sentry.example.com', + }; + testContext.after(() => runHook('cleanup', env)); + + const setup = await runHook('setup', env); + + assert.equal(setup.code, 1); + assert.match( + setup.stderr, + /setup: Sentry settings in the environment: invalid runtime config: sentry\.apiUrl must be https:\/\/sentry\.io or a subdomain/, + ); +}); + test('live mode fetches the weapon issue from the Sentry API with the token and returns its stack trace', async (testContext) => { const stack = await startLiveStack(testContext); From 618c56fa99c5727d6b8b16090819db8cec6fe97e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 04:21:28 +0000 Subject: [PATCH 2/2] fix(glitch-invaders): accept SENTRY_URL with a trailing slash and name variables in errors Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VdRNSME2KRqwhfNGNYW9AN --- challenges/glitch-invaders/README.md | 9 ++- .../glitch-invaders/server/sentry-config.mjs | 28 +++++++- test/glitch-invaders.test.mjs | 65 +++++++++++++++++-- 3 files changed, 91 insertions(+), 11 deletions(-) diff --git a/challenges/glitch-invaders/README.md b/challenges/glitch-invaders/README.md index 210835a..0c524a7 100644 --- a/challenges/glitch-invaders/README.md +++ b/challenges/glitch-invaders/README.md @@ -57,9 +57,12 @@ Live mode needs both halves: Sentry's own variables replace `sentry.json` when set: `SENTRY_DSN`, `SENTRY_ORG`, `SENTRY_PROJECT`, and optionally `SENTRY_URL` for `apiUrl`. They -pass the same checks. Setting any of the first three requires all three, and -setup fails naming the ones missing; an empty variable counts as unset. An -event points at its own Sentry project by setting them under +pass the same checks, and a malformed one fails setup naming it. `SENTRY_URL` +must be `https://sentry.io` or a subdomain origin, a trailing `/` allowed; +without it, `apiUrl` is `https://sentry.io`, not `sentry.json`'s, and alone it +is ignored. Setting any of the first three requires all three, and setup fails +naming the ones missing; an empty variable counts as unset. An event points at +its own Sentry project by setting them under `challenge_env.glitch-invaders` in its config: ```json diff --git a/challenges/glitch-invaders/server/sentry-config.mjs b/challenges/glitch-invaders/server/sentry-config.mjs index 82b631a..a714c50 100644 --- a/challenges/glitch-invaders/server/sentry-config.mjs +++ b/challenges/glitch-invaders/server/sentry-config.mjs @@ -25,6 +25,27 @@ const SENTRY_SETTING_VARIABLES = { /** The settings an event must set together: all of them but the optional API origin. */ const REQUIRED_SETTINGS = ['dsn', 'org', 'project']; +/** + * Reduce `apiUrl` written with a bare trailing slash, as sentry-cli documents `SENTRY_URL` + * (`https://sentry.io/`), to its origin; any other value is left for validation to judge. + */ +function trimBareTrailingSlash(apiUrl) { + const url = URL.parse(apiUrl); + if (url === null || url.href !== `${url.origin}/`) { + return apiUrl; + } + + return url.origin; +} + +/** Rewrite a validation message's `sentry.` into the variable that set it. */ +function nameVariableIn(message) { + return message.replace( + /sentry\.(\w+)/, + (field, setting) => SENTRY_SETTING_VARIABLES[setting] ?? field, + ); +} + /** Whether `value` is still a `` waiting for MLH's real setting. */ function isPlaceholder(value) { return typeof value === 'string' && value.startsWith('<') && value.endsWith('>'); @@ -71,7 +92,7 @@ export async function readSentryConfig(packageDir) { * @returns {ReturnType | null} the settings, or `null` when none of * `SENTRY_DSN`, `SENTRY_ORG`, and `SENTRY_PROJECT` is set. * @throws {Error} naming the missing variables when only some of those three are set, or the - * environment when a value is malformed. + * malformed variable. */ function readSentryEnvironment(env) { const record = {}; @@ -90,10 +111,13 @@ function readSentryEnvironment(env) { const names = missing.map((setting) => SENTRY_SETTING_VARIABLES[setting]).join(', '); throw new Error(`Sentry settings in the environment are incomplete: ${names} not set`); } + if (record.apiUrl !== undefined) { + record.apiUrl = trimBareTrailingSlash(record.apiUrl); + } try { return parseSentrySettings(record); } catch (error) { - throw new Error(`Sentry settings in the environment: ${error.message}`); + throw new Error(nameVariableIn(error.message)); } } diff --git a/test/glitch-invaders.test.mjs b/test/glitch-invaders.test.mjs index 8027e82..2d0c9b3 100644 --- a/test/glitch-invaders.test.mjs +++ b/test/glitch-invaders.test.mjs @@ -74,6 +74,14 @@ const WEAPON_EVENT_ID = 'a'.repeat(32); const SHIELD_EVENT_ID = 'b'.repeat(32); const BOSS_TRACE_ID = 'c'.repeat(32); +/** An event's Sentry settings and the token, as a station would set them in the hook's environment. */ +const ENVIRONMENT_SENTRY_SETTINGS = { + SENTRY_AUTH_TOKEN: SENTRY_TOKEN, + SENTRY_DSN: LIVE_DSN, + SENTRY_ORG: 'mlh', + SENTRY_PROJECT: 'glitch-invaders', +}; + /** Fake time for the boss loader, which needs `now` and `sleep`. */ class VirtualClock { constructor() { @@ -1264,10 +1272,7 @@ test('setup takes live Sentry settings from SENTRY_DSN, SENTRY_ORG, SENTRY_PROJE project: 'package-project', }); const stack = await startLivePackage(testContext, packageDir, { - SENTRY_AUTH_TOKEN: SENTRY_TOKEN, - SENTRY_DSN: LIVE_DSN, - SENTRY_ORG: 'mlh', - SENTRY_PROJECT: 'glitch-invaders', + ...ENVIRONMENT_SENTRY_SETTINGS, SENTRY_URL: standIn.url, }); @@ -1294,7 +1299,7 @@ test('setup fails naming the unset variables when the environment sets only some ); }); -test('setup fails naming the environment when a Sentry setting there is malformed', async (testContext) => { +test('setup fails naming the variable when a Sentry setting in the environment is malformed', async (testContext) => { const workspace = await makeTemporaryDirectory(testContext); const env = { ...hookEnvironment(workspace, await freePort()), @@ -1310,10 +1315,58 @@ test('setup fails naming the environment when a Sentry setting there is malforme assert.equal(setup.code, 1); assert.match( setup.stderr, - /setup: Sentry settings in the environment: invalid runtime config: sentry\.apiUrl must be https:\/\/sentry\.io or a subdomain/, + /setup: invalid runtime config: SENTRY_URL must be https:\/\/sentry\.io or a subdomain/, ); }); +test('setup reads SENTRY_URL written with a trailing slash as its origin', async (testContext) => { + const packageDir = await makePackageWithSettings({ dsn: LIVE_DSN, org: 'mlh', project: 'glitch-invaders' }); + const stack = await startLivePackage(testContext, packageDir, { + ...ENVIRONMENT_SENTRY_SETTINGS, + SENTRY_URL: 'https://mlh.sentry.io/', + }); + + const config = await (await fetch(`${stack.base}/runtime-config.json`)).json(); + + assert.equal(config.sentry.apiUrl, 'https://mlh.sentry.io'); +}); + +test('setup uses https://sentry.io, not the sentry.json apiUrl, when the environment sets settings without SENTRY_URL', async (testContext) => { + const packageDir = await makePackageWithSettings({ + dsn: 'https://packagekey@o9.ingest.us.sentry.io/99', + org: 'package-org', + project: 'package-project', + apiUrl: 'https://package.sentry.io', + }); + const stack = await startLivePackage(testContext, packageDir, ENVIRONMENT_SENTRY_SETTINGS); + + const config = await (await fetch(`${stack.base}/runtime-config.json`)).json(); + + assert.deepEqual(config.sentry, { + dsn: LIVE_DSN, + org: 'mlh', + project: 'glitch-invaders', + apiUrl: 'https://sentry.io', + }); +}); + +test('setup ignores SENTRY_URL set without the other Sentry settings', async (testContext) => { + const packageDir = await makePackageWithSettings({ + dsn: LIVE_DSN, + org: 'mlh', + project: 'glitch-invaders', + apiUrl: 'https://package.sentry.io', + }); + const stack = await startLivePackage(testContext, packageDir, { + SENTRY_AUTH_TOKEN: SENTRY_TOKEN, + SENTRY_URL: 'https://mlh.sentry.io', + }); + + const config = await (await fetch(`${stack.base}/runtime-config.json`)).json(); + + assert.equal(config.sentry.apiUrl, 'https://package.sentry.io'); +}); + test('live mode fetches the weapon issue from the Sentry API with the token and returns its stack trace', async (testContext) => { const stack = await startLiveStack(testContext);