diff --git a/README.md b/README.md index 7e4075c09..84b3cca38 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,10 @@ it with `discordAwarenessDeadlineMs` (default 10000ms, clamped to 50..60000ms). Configure the online marker with `discordAwarenessEmoji`; the offline CLI accepts `--emoji`. +For stdio MCPL children, the framework defaults `DISCORD_SUPPRESSED_REACTIONS_BASELINE` to the reactions it places: its refusal markers, the Discord adapter's default awareness marker, the configured awareness marker, and every retained outbox emoji. This covers later removals and replays after a configuration change. Suppression is by emoji, so members' own reactions with those emoji stop reaching the agent too. The default is derived at each spawn of a child (its first connect, each automatic reconnect, a restart), so the next child spawned gets the markers retained since the last spawn. When the awareness ledger exists but can't be read, a runtime connect reads it first and refuses before anything is registered or spawned (a startup connect, whose routing is staged beforehand, before anything spawns), and a restart refuses before it stops the running child. An automatic reconnect of a child that takes the default fails and retries rather than spawn it without the default. A value with tokens in the server's env (case-insensitive on Windows), or one intentionally inherited with `inheritEnv: true`, takes precedence, and the framework logs that it replaces the default. A value with no tokens, as discord-mcpl's parser reads them, counts as unset, since the adapter would read it as suppression off. The parser splits on commas and keeps a token only when its normalizer leaves something of it, after stripping U+FE0F, trimming, and stripping one leading and one trailing colon: so a blank value, commas alone, `:`, `::` or a lone U+FE0F (say from `${A},${B}` or `:${NAME}:` with the variables empty) has no tokens. The adapter's filters file and legacy operator env still take precedence over this variable, and they are where an operator turns suppression off. + +`framework.getPlacedReactionBaseline()` returns a fresh snapshot of that default for other embeddings, including separately managed WebSocket adapters. It returns suppression tokens, with a custom emoji as its bare ID, not marker payloads for adding, removing or replaying a reaction. An unreadable awareness ledger throws instead of producing a partial snapshot. Hosts that previously supplied a refusal-only default must stop supplying it to adopt this framework-derived default; an explicit operator choice should remain intact. + ### MCPL (MCP Live) Optional host-side implementation of the MCP Live protocol. External servers (game engines, dev tools, etc.) can: diff --git a/changelog.d/211-placed-reaction-baseline.fixed.md b/changelog.d/211-placed-reaction-baseline.fixed.md new file mode 100644 index 000000000..23191ee7e --- /dev/null +++ b/changelog.d/211-placed-reaction-baseline.fixed.md @@ -0,0 +1 @@ +- Discord reaction suppression defaults include refusal markers, default/configured awareness markers, and retained recovery-outbox emojis. `framework.getPlacedReactionBaseline()` exposes that snapshot as suppression tokens, converting composite custom-emote markers to stable IDs; stored markers and reaction payloads remain unchanged. Stdio MCPL children get it by default, derived at each spawn (first connect, automatic reconnect, restart). Suppression is by emoji, so members' own reactions with 💤, the configured awareness emoji or a retained recovery emoji stop reaching the agent too, as members' 🛑 already did. When the awareness ledger can't be read, a runtime connect refuses before it registers or spawns anything (a startup connect, before it spawns), any restart refuses before it stops the running child, and an automatic reconnect of a child taking the default fails and retries rather than spawn without it. Explicit server env and intentionally inherited baseline values with tokens retain precedence, and the framework logs when one replaces the default; a value with no tokens, as discord-mcpl's parser reads them (blank, commas alone, or only tokens its normalizer reduces to nothing, such as `:`, `::` or a lone U+FE0F), or a null counts as unset, since the adapter would read it as suppression off (its filters file and legacy operator env are where suppression is turned off), and any other non-string value (a number from a JSON config) is read as its string form, as the child sees it, rather than failing the connect. On Windows, duplicate spellings resolve to the first in sorted order within each source, then declared values take precedence over inherited values. Hosts that inject the old refusal-only default must remove that default to adopt the framework-derived one; remote WebSocket adapters still need their own environment configuration (#95). diff --git a/src/framework.ts b/src/framework.ts index dbb2e4033..fe73ad9aa 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -62,7 +62,7 @@ import type { } from './types/index.js'; import { ProcessQueueImpl } from './queue.js'; import { DEFAULT_SHUTDOWN_TIMEOUT_MS, validateShutdownTimeout, shutdownErrorMessage } from './shutdown.js'; -import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK } from './refusal-reactions.js'; +import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK, REFUSAL_REACTION_BASELINE } from './refusal-reactions.js'; import { Agent } from './agent.js'; import { ModuleRegistry, isStateExistsError } from './module-registry.js'; import { McplServerRegistry } from './mcpl/server-registry.js'; @@ -392,6 +392,7 @@ import { EventGate, formatShadowWarning } from './gate/event-gate.js'; import { UsageTracker, type PersistedUsageState } from './usage/usage-tracker.js'; import type { SessionUsageSnapshot, UsageUpdatedEvent } from './usage/types.js'; import type { McplServerConnection } from './mcpl/server-connection.js'; +import { isWebSocketTransport } from './mcpl/transport.js'; import type { McplServerConfig, McplHostCapabilities, @@ -5100,7 +5101,8 @@ export class AgentFramework { const parts = incoming.channelId.split(':'); if (parts[0] !== 'discord') return; const channelId = parts[parts.length - 1]; - const emoji = REFUSAL_REACTIONS[category] ?? REFUSAL_REACTION_FALLBACK; + // Own names only: an inherited one such as 'constructor' takes the fallback. + const emoji = Object.hasOwn(REFUSAL_REACTIONS, category) ? REFUSAL_REACTIONS[category] : REFUSAL_REACTION_FALLBACK; // Resolve the MCPL server that owns the locus channel and call // tools/call directly on its connection (bare tool name — no prefix // games), bypassing the agent event queue so no synthetic tool-result @@ -10804,7 +10806,7 @@ export class AgentFramework { // border is close, rather than noise on every near-threshold // flip. (It must not re-enter any agent's context — the emoji // is suppressed at the surface via - // DISCORD_SUPPRESS_REACTION_EMOJIS; without that, marking a + // DISCORD_SUPPRESSED_REACTIONS_BASELINE; without that, marking a // refusal feeds the next one.) if (!handledByRewind) { void this.reactToRefusal(agent.name, category); @@ -14244,6 +14246,14 @@ export class AgentFramework { throw new Error('MCPL subsystem is not initialized'); } + // Every connect needs a readable awareness ledger: a runtime connect's + // awareness barrier reconciles it, and a stdio child taking the + // protective default derives that from it. Read it first, so an + // unreadable ledger refuses the connect before anything is registered + // under its id or spawned (a reconnecting server would otherwise be left + // registered as a closed stub). With no outbox configured nothing is read. + this.getPlacedReactionBaseline(); + // Register prefix + config for tool dispatch routing (idempotent with // the pre-registration pass in initializeMcpl). const prefix = config.toolPrefix ?? `mcpl--${config.id}`; @@ -14259,7 +14269,33 @@ export class AgentFramework { ); } - const connection = await this.mcplServerRegistry.addServer(config, this.mcplHostCapabilities); + // A stdio child's protective reaction default, DISCORD_SUPPRESSED_REACTIONS_BASELINE + // (WebSocket servers own their environment outside this process). The + // caller's config stays unchanged, so a runtime restart derives afresh. + // The default is derived at each spawn: the env below is a getter, which + // the transport reads at first connect, at every automatic reconnect and + // at a stub's retry, so retained markers added since the last spawn are + // covered. + let connectionConfig = config; + if (!isWebSocketTransport(config)) { + const declared = this.declaredReactionBaseline(config); + if (declared) { + console.error( + `[mcpl] ${config.id}: DISCORD_SUPPRESSED_REACTIONS_BASELINE ${declared.source === 'env' ? 'from its env' : 'inherited from the host'} ` + + `replaces the framework's protective default`, + ); + } + connectionConfig = { ...config }; + Object.defineProperty(connectionConfig, 'env', { + enumerable: true, + configurable: true, + get: () => ({ + ...config.env, + DISCORD_SUPPRESSED_REACTIONS_BASELINE: declared?.value ?? this.getPlacedReactionBaseline().join(','), + }), + }); + } + const connection = await this.mcplServerRegistry.addServer(connectionConfig, this.mcplHostCapabilities); // Wire listeners before either startup staging or the runtime global gate // releases control traffic needed for registration and marker service. @@ -14625,6 +14661,92 @@ export class AgentFramework { // Runtime MCPL server lifecycle (agent-facing hot deploy/restart/unload) // ========================================================================== + /** + * Snapshot the protective Discord reaction default for this deployment, as + * suppression tokens: the forms the Discord adapter's suppression matcher + * reads, not marker payloads for an add, a remove or a replay. It covers + * the reactions the framework places: refusal markers, the adapter's own + * default awareness marker, the configured awareness marker, and retained + * outbox markers (including offline recovery choices and marks that can + * later be removed or replayed). A custom emoji appears as its bare ID, + * which suppression matches even after a rename; stored markers and the + * actual add/remove payloads stay unchanged. Explicit adapter or operator + * suppression settings still take precedence. Throws on an unreadable + * ledger rather than reporting a partial baseline. + */ + getPlacedReactionBaseline(): readonly string[] { + let retained: string[]; + try { + retained = this.discordAwarenessOutbox?.batches().map((batch) => batch.emoji) ?? []; + } catch (error) { + throw new DiscordAwarenessAccountingError('reaction-baseline ledger read', error); + } + // Discord events carry the custom emoji's name and ID separately. Full + // marker forms belong in add/remove calls, but suppression matches their + // stable ID (including after a rename), rather than the composite token. + const suppressionToken = (emoji: string): string => { + const custom = /^(?:|(?:a:)?\w+:(\d{17,20}))$/.exec(emoji.trim()); + return custom?.[1] ?? custom?.[2] ?? emoji; + }; + return [...new Set([ + ...REFUSAL_REACTION_BASELINE, + DEFAULT_DISCORD_AWARENESS_EMOJI, + this.discordAwarenessEmoji, + ...retained, + ].map(suppressionToken))].filter((emoji) => emoji.length > 0); + } + + /** + * The reaction baseline an operator chose for a stdio server, if any: a + * value declared in its env, or one inherited from the host with + * `inheritEnv`, the declared one first. A value with no tokens, as + * discord-mcpl's parser reads them, counts as unset: the adapter splits on + * commas and keeps a token only when its normalizer leaves something of it + * (blank, `:`, `::` and a lone U+FE0F leave nothing), so such a value (an + * empty systemd `Environment=`, say, or `${A},${B}` or `:${NAME}:` with the + * variables empty) would otherwise turn the protective default off without + * anyone choosing that. + * + * Windows names are matched in both sources: Workers expose a case- + * sensitive `process.env` copy. Within one source, Node's spawn keeps the + * first spelling in sorted order, so each source is resolved that way. + * + * A value is read as the child will see it. A config loaded from JSON can + * carry a non-string there despite the type, and spawn writes every + * defined value as its string form, so a number is a declared value, not a + * failed connect. A null counts as unset, like a blank: written in JSON it + * means no value, and read as spawn's "null" it would replace the + * protective default with a list that suppresses nothing. + */ + private declaredReactionBaseline( + config: import('./mcpl/types.js').McplServerConfig, + ): { value: string; source: 'env' | 'inherited' } | undefined { + // A token as discord-mcpl's parser keeps one: `parseSuppressionEnvTokens` + // trims each comma-separated token and keeps it when + // `normalizeReactionEmoji` (discord-mcpl `src/filters.ts`) leaves + // something, after it strips U+FE0F, trims, and strips one leading and one + // trailing colon. The same steps in the same order, so the two can be + // compared when either changes. + const isToken = (token: string): boolean => + token.trim().replace(/\uFE0F/g, '').trim().replace(/^:|:$/g, '') !== ''; + const valueIn = (env: NodeJS.ProcessEnv | undefined): string | undefined => { + let raw: unknown; + if (process.platform !== 'win32') raw = env?.DISCORD_SUPPRESSED_REACTIONS_BASELINE; + else { + const key = Object.keys(env ?? {}).sort() + .find((name) => name.toUpperCase() === 'DISCORD_SUPPRESSED_REACTIONS_BASELINE'); + raw = key === undefined ? undefined : env?.[key]; + } + if (raw === undefined || raw === null) return undefined; + const value = String(raw); + return value.split(',').some(isToken) ? value : undefined; + }; + const declared = valueIn(config.env); + if (declared !== undefined) return { value: declared, source: 'env' }; + const inherited = config.inheritEnv ? valueIn(process.env) : undefined; + return inherited !== undefined ? { value: inherited, source: 'inherited' } : undefined; + } + /** * Connect a new MCPL server at runtime. Refreshes the tool list and * notifies the agent of newly available tools. Throws if the MCPL @@ -14707,6 +14829,12 @@ export class AgentFramework { if (!config) { throw new Error(`MCPL server "${id}" is not configured`); } + // Every connect reads the awareness ledger: its awareness barrier + // reconciles it, and a stdio child taking the protective default derives + // that from it. Read it before disconnecting, so an unreadable ledger + // refuses the restart and leaves the running child up, whatever the + // server's baseline or transport. (No outbox configured: nothing to read.) + this.getPlacedReactionBaseline(); await this.disconnectMcplServer(id); await this.connectMcplServer(config); } diff --git a/src/refusal-reactions.ts b/src/refusal-reactions.ts index 2ea585970..4ee0bb715 100644 --- a/src/refusal-reactions.ts +++ b/src/refusal-reactions.ts @@ -1,17 +1,20 @@ /** * Refusal-reaction markers — the single source of truth for the emoji the * framework places on a message when inference is refused (see - * AgentFramework.reactToRefusal), exported so host composition can derive - * a protective suppression baseline from the exact set the framework emits. + * AgentFramework.reactToRefusal). * * These annotations are placed by the framework on the resident's account * and must never re-enter a resident's context as reaction events — that is * the self-amplifying loop behind the 8/3 Mythos incident. The Discord - * adapter suppresses them when its operator config or the host-injected - * `DISCORD_SUPPRESSED_REACTIONS_BASELINE` names them; keeping the emitted - * set and the exported baseline as one constant is what makes drift between - * "what we stamp" and "what we suppress" structurally impossible, rather - * than a promise kept in two files. + * adapter suppresses them when its operator config or + * `DISCORD_SUPPRESSED_REACTIONS_BASELINE` names them. That variable's + * protective default, which the framework injects into stdio children, is + * `framework.getPlacedReactionBaseline()` on an instance: these refusal + * markers plus the awareness markers. REFUSAL_REACTION_BASELINE below is the + * refusal set only. Keeping the emitted refusal set and the exported one as + * one constant is what makes drift between "what we stamp" and "what we + * suppress" structurally impossible, rather than a promise kept in two + * files. */ /** Refusal category → Discord reaction emoji. Unknown categories get the @@ -27,11 +30,10 @@ export const REFUSAL_REACTIONS: Readonly> = { /** Marker used when the refusal category has no dedicated emoji. */ export const REFUSAL_REACTION_FALLBACK = '🛑'; -/** Every marker the framework can emit — the category map plus the - * fallback, deduplicated, in stable declaration order. This IS the - * protective baseline: host composition serializes it (comma-joined) into - * `DISCORD_SUPPRESSED_REACTIONS_BASELINE` for adapters that render - * reactions. */ +/** Every refusal marker the framework can emit: the category map plus the + * fallback, deduplicated in stable declaration order. For the deployment's + * complete protective default, including awareness markers, use + * `framework.getPlacedReactionBaseline()` on an instance. */ export const REFUSAL_REACTION_BASELINE: readonly string[] = [ ...new Set([...Object.values(REFUSAL_REACTIONS), REFUSAL_REACTION_FALLBACK]), ]; diff --git a/test/fixtures/reaction-baseline-mcpl-server.mjs b/test/fixtures/reaction-baseline-mcpl-server.mjs new file mode 100644 index 000000000..a579d793f --- /dev/null +++ b/test/fixtures/reaction-baseline-mcpl-server.mjs @@ -0,0 +1,29 @@ +// A real stdio child exposing the environment it received from the framework. +import { createInterface } from 'node:readline'; + +const reply = (id, result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n'); +const rl = createInterface({ input: process.stdin }); +rl.on('close', () => process.exit(0)); +rl.on('line', (line) => { + const msg = JSON.parse(line); + if (msg.id === undefined || msg.id === null || !msg.method) return; + if (msg.method === 'initialize') { + reply(msg.id, { + protocolVersion: '2024-11-05', + capabilities: { tools: {}, experimental: { mcpl: { version: '0.5' } } }, + serverInfo: { name: 'reaction-baseline', version: '0.0.0' }, + }); + } else if (msg.method === 'tools/list') { + reply(msg.id, { tools: [{ name: 'read_env', description: 'Read fixture environment', inputSchema: { type: 'object', properties: {} } }] }); + } else if (msg.method === 'tools/call' && msg.params?.name === 'crash') { + // Stands in for a child that dies, so a reconnecting host respawns it. + process.exit(3); + } else if (msg.method === 'tools/call') { + reply(msg.id, { content: [{ type: 'text', text: JSON.stringify({ + baseline: process.env.DISCORD_SUPPRESSED_REACTIONS_BASELINE, + extra: process.env.BASELINE_TEST_EXTRA, + }) }] }); + } else { + reply(msg.id, { accepted: true }); + } +}); diff --git a/test/placed-reaction-baseline.test.ts b/test/placed-reaction-baseline.test.ts new file mode 100644 index 000000000..77c7dbee3 --- /dev/null +++ b/test/placed-reaction-baseline.test.ts @@ -0,0 +1,342 @@ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { AgentFramework } from '../src/framework.js'; +import { REFUSAL_REACTION_BASELINE } from '../src/refusal-reactions.js'; +import { DiscordAwarenessOutbox } from '../src/recovery/discord-awareness-outbox.js'; +import type { McplServerConfig } from '../src/mcpl/types.js'; +import { MockMembrane } from './helpers/mock-membrane.js'; + +const ENV_KEY = 'DISCORD_SUPPRESSED_REACTIONS_BASELINE'; +const FIXTURE = fileURLToPath(new URL('./fixtures/reaction-baseline-mcpl-server.mjs', import.meta.url)); + +function server(overrides: Partial = {}): McplServerConfig { + return { id: 'probe', command: process.execPath, args: [FIXTURE], ...overrides }; +} + +function baseline(framework: AgentFramework): readonly string[] { + // Keep this runtime API assertion compilable against the unfixed source. + return (framework as unknown as { getPlacedReactionBaseline(): readonly string[] }).getPlacedReactionBaseline(); +} + +// A refusal for an awareness ledger that can't be read, whichever read met it +// first: the connect's or restart's own read, or a connect's awareness +// barrier. The assertions after each refusal say what it left behind. +function accountingRefusal(error: unknown): boolean { + return error instanceof Error && error.name === 'DiscordAwarenessAccountingError'; +} + +async function childEnv(framework: AgentFramework) { + const registry = (framework as unknown as { + mcplServerRegistry: { getServer(id: string): { sendToolsCall(name: string, args: {}): Promise<{ content: Array<{ text?: string }> }> } }; + }).mcplServerRegistry; + const result = await registry.getServer('probe').sendToolsCall('read_env', {}); + return JSON.parse(result.content[0]!.text!) as { baseline?: string; extra?: string }; +} + +describe('placed-reaction baseline', () => { + let dir: string; + let outboxPath: string; + let framework: AgentFramework | undefined; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'placed-reactions-')); + outboxPath = join(dir, 'outbox.json'); + }); + + afterEach(async () => { + // A corrupt-ledger test must not leave teardown trying to reconcile it. + rmSync(outboxPath, { force: true }); + await framework?.stop(); + framework = undefined; + rmSync(dir, { recursive: true, force: true }); + }); + + async function create(emoji?: string, servers: McplServerConfig[] = []) { + framework = await AgentFramework.create({ + storePath: join(dir, 'test.chronicle'), + membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'resident', model: 'test-model', systemPrompt: 'Resident.' }], + modules: [], + discordAwarenessOutboxPath: outboxPath, + discordAwarenessEmoji: emoji, + mcplServers: servers, + }); + return framework; + } + + function retain(emoji: string) { + const outbox = new DiscordAwarenessOutbox(outboxPath); + return outbox.prepare({ + agentName: 'resident', + sourceBranch: 'main', + targetBranch: 'later-recovery', + emoji, + refs: [{ serverId: 'discord', channelId: 'discord:guild:channel', messageId: emoji }], + })!; + } + + it('includes default awareness and refusal markers once, with a fresh snapshot', async () => { + const fw = await create(); + const expected = [...REFUSAL_REACTION_BASELINE, '💤']; + assert.deepEqual(baseline(fw), expected); + (baseline(fw) as string[]).push('not-a-framework-marker'); + assert.deepEqual(baseline(fw), expected); + }); + + it('keeps the adapter default alongside configured and retained offline awareness markers', async () => { + retain('🫥'); + const completed = retain('👁️'); + const outbox = new DiscordAwarenessOutbox(outboxPath); + outbox.activate(completed.id); + outbox.recordSuccess(completed.id, completed.refs[0]!, 'add'); + const fw = await create('🔕'); + assert.deepEqual(new Set(baseline(fw)), new Set([...REFUSAL_REACTION_BASELINE, '💤', '🔕', '🫥', '👁️'])); + assert.equal(baseline(fw).length, new Set(baseline(fw)).size); + }); + + for (const emoji of ['<:mark:123456789012345678>', '', 'mark:123456789012345678', 'a:mark:123456789012345678']) { + it('injects a stable custom-emote ID for configured marker ' + emoji, async () => { + const fw = await create(emoji, [server()]); + const expected = [...REFUSAL_REACTION_BASELINE, '💤', '123456789012345678']; + assert.deepEqual(baseline(fw), expected); + assert.equal((await childEnv(fw)).baseline, expected.join(',')); + }); + } + + it('canonicalizes retained custom markers before deduplication without changing the ledger', async () => { + const first = retain('<:old_name:123456789012345678>'); + retain('new_name:123456789012345678'); + retain(''); + const fw = await create('123456789012345678'); + assert.deepEqual(baseline(fw), [...REFUSAL_REACTION_BASELINE, '💤', '123456789012345678', '123456789012345679']); + const stored = new DiscordAwarenessOutbox(outboxPath).batches(); + assert.equal(stored.find((batch) => batch.id === first.id)?.emoji, '<:old_name:123456789012345678>'); + }); + + it('preserves Unicode and name tokens while accepting padded full custom markers', async () => { + retain('👩‍💻'); + retain(':name_only:'); + retain('not:a:custom'); + const fw = await create(' <:mark:123456789012345678> '); + assert.deepEqual(baseline(fw), [...REFUSAL_REACTION_BASELINE, '💤', '123456789012345678', '👩‍💻', ':name_only:', 'not:a:custom']); + }); + + it('deduplicates a configured marker already used for refusals', async () => { + const fw = await create(REFUSAL_REACTION_BASELINE[0]); + assert.deepEqual(baseline(fw), [...REFUSAL_REACTION_BASELINE, '💤']); + }); + + it('injects the complete default into a real child at framework startup', async () => { + retain('🫥'); + const config = server({ env: { BASELINE_TEST_EXTRA: 'preserved' } }); + const fw = await create('🔕', [config]); + const env = await childEnv(fw); + assert.deepEqual(new Set(env.baseline?.split(',')), new Set([...REFUSAL_REACTION_BASELINE, '💤', '🔕', '🫥'])); + assert.equal(env.extra, 'preserved'); + assert.deepEqual(config.env, { BASELINE_TEST_EXTRA: 'preserved' }, 'caller config stays unchanged'); + }); + + for (const transport of [undefined, 'stdio'] as const) { + it('injects the baseline with command + URL and ' + (transport ?? 'implicit') + ' transport', async () => { + const fw = await create('🔕', [server({ url: 'ws://127.0.0.1:1', transport })]); + const env = await childEnv(fw); + assert.deepEqual(new Set(env.baseline?.split(',')), new Set([...REFUSAL_REACTION_BASELINE, '💤', '🔕'])); + }); + } + + it('derives the default on runtime connect, including newly retained ledger markers', async () => { + const fw = await create('🔕'); + retain('🫥'); + await fw.connectMcplServer(server()); + const env = await childEnv(fw); + assert.deepEqual(new Set(env.baseline?.split(',')), new Set([...REFUSAL_REACTION_BASELINE, '💤', '🔕', '🫥'])); + retain('👁️'); + await fw.restartMcplServer('probe'); + assert.ok((await childEnv(fw)).baseline?.split(',').includes('👁️'), 'restart recomputes a default rather than retaining it as an explicit override'); + }); + + for (const value of ['🟪', '<:operator_choice:123456789012345678>']) { + it('preserves an explicit server baseline ' + JSON.stringify(value), async () => { + const fw = await create(undefined, [server({ env: { [ENV_KEY]: value } })]); + assert.equal((await childEnv(fw)).baseline, value); + }); + + it('preserves an intentionally inherited baseline ' + JSON.stringify(value), async (t) => { + const previous = process.env[ENV_KEY]; + process.env[ENV_KEY] = value; + t.after(() => { + if (previous === undefined) delete process.env[ENV_KEY]; + else process.env[ENV_KEY] = previous; + }); + const fw = await create(undefined, [server({ inheritEnv: true })]); + assert.equal((await childEnv(fw)).baseline, value); + }); + } + + it('does not use an unrequested parent override; explicit server env wins over inheritance', async (t) => { + const previous = process.env[ENV_KEY]; + process.env[ENV_KEY] = '🟥'; + t.after(() => { + if (previous === undefined) delete process.env[ENV_KEY]; + else process.env[ENV_KEY] = previous; + }); + const fw = await create(undefined, [server()]); + // The literal default, not the function under test's own report. + assert.equal((await childEnv(fw)).baseline, [...REFUSAL_REACTION_BASELINE, '💤'].join(',')); + await fw.disconnectMcplServer('probe'); + await fw.connectMcplServer(server({ inheritEnv: true, env: { [ENV_KEY]: '🟪' } })); + assert.equal((await childEnv(fw)).baseline, '🟪'); + }); + + it('reads a non-string declared baseline as the child sees it, rather than failing the connect, and a null as unset', async () => { + // A config loaded from JSON can carry a number despite the type; spawn + // writes it as its string form. + const fw = await create(undefined, [server({ env: { [ENV_KEY]: 7 as unknown as string } })]); + assert.equal((await childEnv(fw)).baseline, '7'); + // A null means no value: the derived default, not spawn's "null". + await fw.disconnectMcplServer('probe'); + await fw.connectMcplServer(server({ env: { [ENV_KEY]: null as unknown as string } })); + assert.equal((await childEnv(fw)).baseline, [...REFUSAL_REACTION_BASELINE, '💤'].join(',')); + }); + + // discord-mcpl's parser (`parseSuppressionEnvTokens`) splits a value on + // commas, trims each token, and keeps one when `normalizeReactionEmoji` + // (discord-mcpl `src/filters.ts`) leaves something of it: that strips + // U+FE0F, trims, and strips one leading and one trailing colon. A value it + // reads as no tokens turns suppression off, so inherited by accident (an + // empty variable, or `${A},${B}` or `:${NAME}:` with the variables empty) + // it would turn the protective default off: such a value counts as unset. + // A value it keeps a token of stays the operator's. + for (const [value, tokens] of [ + ['', false], + [' ', false], + [',', false], + [' , ', false], + [':', false], + ['::', false], + ['::,::', false], + [' : , :: ', false], + ['\uFE0F', false], + // The adapter trims again after stripping U+FE0F, and only then strips + // the colon. + ['\uFE0F :', false], + [':sigil:', true], + ['sigil', true], + ['🟪', true], + ['<:mark:123456789012345678>', true], + // One colon from each end leaves ':'. + [':::', true], + ] as const) { + const name = JSON.stringify(value).replace(/\uFE0F/g, '\\uFE0F'); + it(`a baseline ${name}, declared or inherited, ${tokens ? 'stays the operator\'s' : 'has no tokens and counts as unset'}`, async (t) => { + const previous = process.env[ENV_KEY]; + process.env[ENV_KEY] = value; + t.after(() => { + if (previous === undefined) delete process.env[ENV_KEY]; + else process.env[ENV_KEY] = previous; + }); + const expected = tokens ? value : [...REFUSAL_REACTION_BASELINE, '💤'].join(','); + const fw = await create(undefined, [server({ env: { [ENV_KEY]: value } })]); + assert.equal((await childEnv(fw)).baseline, expected, 'declared'); + await fw.disconnectMcplServer('probe'); + await fw.connectMcplServer(server({ inheritEnv: true })); + assert.equal((await childEnv(fw)).baseline, expected, 'inherited'); + }); + } + + it("says at connect when an operator's value replaces the protective default", async (t) => { + const lines: string[] = []; + const original = console.error; + console.error = (...args: unknown[]) => { lines.push(args.map(String).join(' ')); }; + t.after(() => { console.error = original; }); + const fw = await create(undefined, [server({ env: { [ENV_KEY]: '🟪' } })]); + console.error = original; + assert.ok(lines.some((line) => line.includes('[mcpl] probe: DISCORD_SUPPRESSED_REACTIONS_BASELINE from its env replaces the framework') ), lines.join('\n')); + assert.equal((await childEnv(fw)).baseline, '🟪'); + }); + + it('a reconnect respawns the child with a default derived at that spawn', async () => { + const fw = await create(undefined, [server({ reconnect: true, reconnectIntervalMs: 20, reconnectMaxIntervalMs: 20 })]); + assert.equal((await childEnv(fw)).baseline?.split(',').includes('🫥'), false); + retain('🫥'); + const registry = (fw as unknown as { + mcplServerRegistry: { getServer(id: string): { sendToolsCall(name: string, args: {}): Promise } }; + }).mcplServerRegistry; + await registry.getServer('probe').sendToolsCall('crash', {}).catch(() => {}); + let env: { baseline?: string } | undefined; + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + env = await childEnv(fw).catch(() => undefined); + if (env) break; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.ok(env, 'the child came back'); + assert.ok(env.baseline?.split(',').includes('🫥'), `the respawned child has the marker retained since: ${env.baseline}`); + }); + + for (const own of [undefined, '🟪']) { + it(`a restart refuses on a corrupt ledger and leaves the child up (${own ? 'its own baseline' : 'the default'})`, async () => { + // Every connect reads the ledger at its awareness barrier, so a server + // with its own baseline can't come back either. + const fw = await create(undefined, [server(own ? { env: { [ENV_KEY]: own } } : {})]); + writeFileSync(outboxPath, '{broken'); + await assert.rejects(() => fw.restartMcplServer('probe'), accountingRefusal); + // A restart that stopped the child leaves no server to ask. + assert.ok(await childEnv(fw).catch(() => undefined), 'the running child still answers'); + }); + } + + it('passes a URL-only WebSocket config through unchanged', async () => { + const fw = await create(); + // Bring up the MCPL subsystem as a first runtime connect does. + await (fw as unknown as { initializeMcpl(servers: McplServerConfig[]): Promise }).initializeMcpl([]); + let added: unknown; + const registry = (fw as unknown as { mcplServerRegistry: { addServer(config: unknown): Promise } }).mcplServerRegistry; + registry.addServer = async (config: unknown) => { added = config; throw new Error('stop before connecting'); }; + const config: McplServerConfig = { id: 'remote', url: 'ws://127.0.0.1:1' }; + await assert.rejects(() => fw.connectMcplServer(config), /stop before connecting/); + assert.equal(added, config, 'the same object'); + assert.equal(config.env, undefined, 'no env added'); + }); + + for (const [marker, converted] of [ + [`<:mark:${'1'.repeat(16)}>`, false], + [`<:mark:${'1'.repeat(17)}>`, true], + [`mark:${'2'.repeat(20)}`, true], + [`<:mark:${'3'.repeat(21)}>`, false], + [`mark:${'4'.repeat(16)}`, false], + [`<:mark:${'5'.repeat(20)}>`, true], + [`mark:${'6'.repeat(17)}`, true], + [`mark:${'7'.repeat(21)}`, false], + ['a:b:1', false], + ] as const) { + it(`a custom marker ${marker} is ${converted ? '' : 'not '}reduced to its ID`, async () => { + const fw = await create(marker); + const id = /(\d+)>?$/.exec(marker)![1]; + assert.equal(baseline(fw).at(-1), converted ? id : marker); + }); + } + + it('fails explicitly on a corrupt retained ledger rather than deriving an incomplete default', async () => { + const fw = await create(undefined, [server()]); + writeFileSync(outboxPath, '{broken'); + assert.throws(() => baseline(fw), /Discord awareness.*ledger/i); + await fw.disconnectMcplServer('probe'); + // Every connect reads the ledger before it registers anything: a + // reconnecting server, with or without its own baseline, would otherwise + // be left registered as a closed stub that blocks a retried connect. + const internals = fw as unknown as { + mcplServerRegistry: { getServer(id: string): unknown }; + mcplServerConfigs: Map; + }; + for (const config of [server(), server({ reconnect: true }), server({ reconnect: true, env: { [ENV_KEY]: '🟪' } })]) { + await assert.rejects(() => fw.connectMcplServer(config), accountingRefusal); + assert.equal(internals.mcplServerRegistry.getServer('probe'), null, 'no connection is left registered'); + assert.equal(internals.mcplServerConfigs.has('probe'), false, 'no config is left registered'); + } + }); +}); diff --git a/test/reaction-baseline-platform.test.ts b/test/reaction-baseline-platform.test.ts new file mode 100644 index 000000000..83b9e1163 --- /dev/null +++ b/test/reaction-baseline-platform.test.ts @@ -0,0 +1,139 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { AgentFramework } from '../src/framework.js'; +import { buildChildEnv } from '../src/mcpl/transport.js'; +import type { McplServerConfig } from '../src/mcpl/types.js'; +import { REFUSAL_REACTION_BASELINE } from '../src/refusal-reactions.js'; + +const KEY = 'DISCORD_SUPPRESSED_REACTIONS_BASELINE'; +/** The framework's protective default for the stub below (awareness emoji 🔕, no ledger). */ +const DEFAULT = [...REFUSAL_REACTION_BASELINE, '💤', '🔕'].join(','); + +/** Exercise the real default-injection boundary, intercepting immediately + * before connection/spawn. Platform/env overrides are synchronous and restored + * before awaiting anything, so asynchronous work sees the real process state. */ +async function connectionEnv( + platform: NodeJS.Platform, + env: Record, + inheritEnv = false, + parentEnv?: NodeJS.ProcessEnv, + transportConfig: Pick = {}, +): Promise> { + let captured: McplServerConfig | undefined; + const stopBeforeSpawn = new Error('captured before spawn'); + const framework = Object.assign(Object.create(AgentFramework.prototype), { + mcplHostCapabilities: {}, + mcplPrefixMap: new Map(), + mcplServerConfigs: new Map(), + channelRegistry: null, + discordAwarenessOutbox: null, + discordAwarenessEmoji: '🔕', + mcplServerRegistry: { + async addServer(config: McplServerConfig) { + captured = config; + throw stopBeforeSpawn; + }, + }, + }) as AgentFramework; + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!; + const originalParentEnv = Object.getOwnPropertyDescriptor(process, 'env')!; + const originalEnv = { ...env }; + let connection: Promise; + try { + Object.defineProperty(process, 'platform', { ...originalPlatform, value: platform }); + if (parentEnv) Object.defineProperty(process, 'env', { ...originalParentEnv, value: parentEnv }); + connection = (framework as unknown as { + connectMcplServerInternal(config: McplServerConfig): Promise; + }).connectMcplServerInternal({ id: 'probe', command: 'unused', env, inheritEnv, ...transportConfig }); + } finally { + Object.defineProperty(process, 'platform', originalPlatform); + Object.defineProperty(process, 'env', originalParentEnv); + } + await assert.rejects(connection!, (error) => error === stopBeforeSpawn); + assert.deepEqual(env, originalEnv, 'connection composition must not mutate the caller env'); + assert.ok(captured?.env); + return captured.env; +} + +/** The value a Windows child gets: the composed env through buildChildEnv's + * win32 merge with the host env, which leaves one spelling of each name. */ +function windowsChildValue(composed: Record, inheritEnv: boolean, host: NodeJS.ProcessEnv): string | undefined { + const child = buildChildEnv({ env: composed, inheritEnv }, host, 'win32'); + const spellings = Object.keys(child).filter((key) => key.toUpperCase() === KEY); + assert.equal(spellings.length, 1, `one spelling reaches the child: ${spellings.join(', ')}`); + return child[spellings[0]!]; +} + +for (const inheritEnv of [false, true]) { + test('Windows reaction baseline honors declared case variants, inheritEnv=' + inheritEnv, async () => { + // A blank value counts as unset: where the spelling Node's spawn keeps is + // blank, the child gets the protective default. + const cases: Array<{ env: Record; expected: string }> = [ + { env: { [KEY.toLowerCase()]: '' }, expected: DEFAULT }, + { env: { Discord_Suppressed_Reactions_Baseline: '🟪' }, expected: '🟪' }, + { env: { [KEY]: 'upper', [KEY.toLowerCase()]: '' }, expected: 'upper' }, + { env: { [KEY.toLowerCase()]: '', [KEY]: 'upper' }, expected: 'upper' }, + { env: { [KEY]: '', [KEY.toLowerCase()]: 'lower' }, expected: DEFAULT }, + { env: { [KEY.toLowerCase()]: 'lower', [KEY]: '' }, expected: DEFAULT }, + ]; + for (const { env, expected } of cases) { + const composed = await connectionEnv('win32', env, inheritEnv); + // The chosen value must survive the spawn's own merge (#205), which + // keeps the first spelling of a folded name within each source. + assert.equal(windowsChildValue(composed, inheritEnv, process.env), expected); + assert.equal(composed[KEY], expected, 'a generated default must not replace an explicit choice'); + } + }); +} + +test('Windows inherited-only baseline lookup handles case-sensitive worker env copies', async () => { + for (const value of ['', '🟪']) { + // A blank inherited value counts as unset, like a blank declared one. + const kept = value === '' ? DEFAULT : value; + const cases: Array<{ parent: NodeJS.ProcessEnv; expected: string }> = [ + { parent: { [KEY.toLowerCase()]: value }, expected: kept }, + { parent: { Discord_Suppressed_Reactions_Baseline: value }, expected: kept }, + { parent: { [KEY]: 'upper', [KEY.toLowerCase()]: value }, expected: 'upper' }, + { parent: { [KEY.toLowerCase()]: value, [KEY]: 'upper' }, expected: 'upper' }, + { parent: { [KEY]: value, [KEY.toLowerCase()]: 'lower' }, expected: kept }, + { parent: { [KEY.toLowerCase()]: 'lower', [KEY]: value }, expected: kept }, + ]; + for (const { parent, expected } of cases) { + const composed = await connectionEnv('win32', {}, true, parent); + assert.equal(composed[KEY], expected); + assert.equal(windowsChildValue(composed, true, parent), expected); + } + } +}); + +test('Windows chooses duplicate spellings within each source, then declared over inherited', async () => { + const parent = { [KEY]: 'host-upper', [KEY.toLowerCase()]: 'host-lower' }; + const composed = await connectionEnv('win32', { [KEY.toLowerCase()]: '', [KEY]: 'declared-upper' }, true, parent); + assert.equal(composed[KEY], 'declared-upper'); + assert.equal(windowsChildValue(composed, true, parent), 'declared-upper'); + const singleDeclared = await connectionEnv('win32', { [KEY.toLowerCase()]: '' }, true, parent); + assert.equal(singleDeclared[KEY], 'host-upper', 'a blank declared value is unset, so the inherited choice stands'); + assert.equal(windowsChildValue(singleDeclared, true, parent), 'host-upper'); +}); + +test('inherited-only baseline lookup remains opt-in and POSIX case-sensitive', async () => { + for (const [platform, inheritEnv] of [['win32', false], ['linux', true]] as const) { + const composed = await connectionEnv(platform, {}, inheritEnv, { [KEY.toLowerCase()]: '' }); + assert.ok(composed[KEY]!.split(',').includes('💤')); + } + const explicitPosix = await connectionEnv('linux', {}, true, { [KEY]: '' }); + assert.equal(explicitPosix[KEY], DEFAULT, 'a blank inherited value is unset'); +}); + +test('explicit WebSocket selection leaves its env alone even when a command is also present', async () => { + const env = { EXTRA: 'value' }; + const composed = await connectionEnv('linux', env, false, undefined, { transport: 'websocket', url: 'ws://unused' }); + assert.deepEqual(composed, env); +}); + +test('POSIX reaction baseline keeps differently cased env names distinct', async () => { + const composed = await connectionEnv('linux', { [KEY.toLowerCase()]: '' }); + assert.equal(composed[KEY.toLowerCase()], ''); + assert.ok(composed[KEY]!.split(',').includes('💤')); + assert.ok(composed[KEY]!.split(',').includes('🔕')); +}); diff --git a/test/refusal-reaction-baseline.test.ts b/test/refusal-reaction-baseline.test.ts index ede230e5a..0f602fdac 100644 --- a/test/refusal-reaction-baseline.test.ts +++ b/test/refusal-reaction-baseline.test.ts @@ -1,9 +1,11 @@ /** * The exported REFUSAL_REACTION_BASELINE must be exactly the set of markers * reactToRefusal can emit — every category's emoji plus the unknown-category - * fallback, nothing more, nothing less. Host composition serializes this - * export into DISCORD_SUPPRESSED_REACTIONS_BASELINE; if the emitted set and - * the export could drift, a framework annotation could re-enter a resident's + * fallback, nothing more, nothing less. It is the refusal set only: the + * protective default the framework injects into DISCORD_SUPPRESSED_REACTIONS_BASELINE + * is framework.getPlacedReactionBaseline(), which adds the awareness markers + * (test/placed-reaction-baseline.test.ts). If the emitted refusal set and the + * export could drift, a framework annotation could re-enter a resident's * context as a reaction event (the 8/3 Mythos self-amplifying refusal loop). * The implementation shares one constant; these tests pin the contract so a * refactor that splits them fails here first. @@ -75,6 +77,12 @@ describe('refusal-reaction baseline export', () => { assert.deepEqual(emitted, new Set(REFUSAL_REACTION_BASELINE)); }); + it('a category named like an inherited property takes the fallback', async () => { + for (const category of ['constructor', 'toString', '__proto__']) { + assert.equal(await emittedFor(category), REFUSAL_REACTION_FALLBACK, category); + } + }); + it('serialized baseline survives the Discord adapter env round-trip (comma-join)', () => { // Host composition joins with ','; discord-mcpl's parseSuppressionEnvTokens // splits on ',' and trims. Entries therefore must not contain commas or