Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 39 additions & 12 deletions challenges/glitch-invaders/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -44,20 +44,45 @@ 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
`<placeholder>`, 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 `<placeholder>`, 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, 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
{
"challenge_env": {
"glitch-invaders": {
"SENTRY_DSN": "https://<public key>@o<org id>.ingest.us.sentry.io/<project id>",
"SENTRY_ORG": "<org slug>",
"SENTRY_PROJECT": "<project slug>"
}
}
}
```

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`.

Expand All @@ -73,7 +98,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
Expand All @@ -93,9 +118,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
Expand Down
2 changes: 1 addition & 1 deletion challenges/glitch-invaders/sentry.json
Original file line number Diff line number Diff line change
@@ -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 <placeholder>, 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 <placeholder>, 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"
Expand Down
95 changes: 89 additions & 6 deletions challenges/glitch-invaders/server/sentry-config.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -13,6 +14,38 @@ 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'];

/**
* 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.<setting>` 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 `<placeholder>` waiting for MLH's real setting. */
function isPlaceholder(value) {
return typeof value === 'string' && value.startsWith('<') && value.endsWith('>');
Expand Down Expand Up @@ -54,14 +87,64 @@ 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<typeof parseSentrySettings> | 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
* malformed variable.
*/
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`);
}
if (record.apiUrl !== undefined) {
record.apiUrl = trimBareTrailingSlash(record.apiUrl);
}
try {
Comment thread
theycallmeswift marked this conversation as resolved.
return parseSentrySettings(record);
} catch (error) {
throw new Error(nameVariableIn(error.message));
}
}

/**
* Read the attempt's Sentry settings: the event's from `env` when it sets them, else the package's.
*
* @returns {Promise<ReturnType<typeof parseSentrySettings> | 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<typeof parseSentrySettings> | 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 };
}
Expand Down
111 changes: 111 additions & 0 deletions test/glitch-invaders.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -1256,6 +1264,109 @@ 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, {
...ENVIRONMENT_SENTRY_SETTINGS,
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 variable when a Sentry setting in the environment 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: 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);

Expand Down
Loading