Skip to content
Merged
Show file tree
Hide file tree
Changes from 41 commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
39f8b77
fix(policy): restrict Hermes Discord binary access
prekshivyas Aug 31, 2026
69630ff
test(policy): cover Discord runtime guidance
prekshivyas Aug 31, 2026
7bd41ca
chore(policy): refresh reviewed runtime bundle
prekshivyas Aug 31, 2026
6b3c817
merge: refresh Hermes Discord fix from main
prekshivyas Aug 31, 2026
f03fdec
merge: refresh Hermes Discord fix from main
prekshivyas Aug 31, 2026
154699d
Merge branch 'main' into codex/fix-hermes-discord-node-policy
cv Sep 1, 2026
86f89a8
Merge branch 'main' into codex/fix-hermes-discord-node-policy
prekshivyas Sep 1, 2026
2bd37f0
fix(policy): validate Hermes Discord with Python
prekshivyas Sep 1, 2026
7961bc8
fix(policy): complete Hermes Discord regression evidence
prekshivyas Sep 1, 2026
9ba480c
test(e2e): cover Hermes Discord REST proof
prekshivyas Sep 1, 2026
b8813ca
merge: sync upstream main
prekshivyas Sep 1, 2026
74b0e67
merge: sync upstream main
prekshivyas Sep 1, 2026
e51270f
test(e2e): prove Hermes Discord REST rewrite
cv Sep 1, 2026
7b174c4
test(e2e): prove Hermes denies Node Discord egress
cv Sep 1, 2026
19e3815
refactor(policy): keep Discord guidance local
cv Sep 1, 2026
98baa26
test(e2e): tighten Hermes Discord denial proof
cv Sep 1, 2026
eb102f5
refactor(policy): restore manifest-owned guidance
cv Sep 1, 2026
928df08
fix(build): refresh reviewed startup runtime bundle
cv Sep 1, 2026
e34b1e0
merge: resolve conflicts with main
github-actions[bot] Sep 1, 2026
e59ea72
fix(policy): document Hermes Discord remediation
prekshivyas Sep 1, 2026
e25b802
Merge remote-tracking branch 'origin/main' into HEAD
prekshivyas Sep 1, 2026
1e6b8c0
Merge remote-tracking branch 'origin/main' into codex/pr10682-final
prekshivyas Sep 1, 2026
e12caf5
merge: resolve conflicts with main
github-actions[bot] Sep 1, 2026
4b621c8
merge(e2e): resolve Hermes Discord fixture conflicts
prekshivyas Sep 1, 2026
b4981ce
merge(main): synchronize PR 10682
prekshivyas Sep 1, 2026
2228126
Merge branch 'main' into codex/fix-hermes-discord-node-policy
prekshivyas Sep 1, 2026
67b53ed
merge(pr): preserve remote PR updates
prekshivyas Sep 1, 2026
1b2a896
merge(main): synchronize PR 10682
prekshivyas Sep 1, 2026
531642e
merge(main): synchronize PR 10682
prekshivyas Sep 1, 2026
864e37d
Merge branch 'main' into codex/fix-hermes-discord-node-policy
prekshivyas Sep 1, 2026
b3fd418
test(e2e): preserve Hermes denial evidence
prekshivyas Sep 1, 2026
e84a83f
merge(main): refresh PR 10682
prekshivyas Sep 1, 2026
db2377f
test(e2e): map Hermes denial probe parity
prekshivyas Sep 1, 2026
ebdcc63
merge(main): refresh PR 10682
prekshivyas Sep 1, 2026
dd7b229
test(e2e): remove redundant Hermes policy read
prekshivyas Sep 2, 2026
388e0fb
Merge remote-tracking branch 'origin/main' into codex/pr10682-e2e-den…
prekshivyas Sep 2, 2026
21a97b0
merge: resolve conflicts with main
github-actions[bot] Sep 2, 2026
df1f627
merge: refresh from upstream main
prekshivyas Sep 2, 2026
c9199a0
fix(ci): refresh reviewed startup runtime
prekshivyas Sep 2, 2026
7cfd627
fix(policy): accept Discord HTTP reachability evidence
prekshivyas Sep 2, 2026
3985a1d
merge: resolve conflicts with main
github-actions[bot] Sep 2, 2026
d858d2c
fix(discord): align Hermes review guidance
prekshivyas Sep 2, 2026
087024c
fix(test): drop retired Hermes Node probe
prekshivyas Sep 2, 2026
106f727
fix(discord): restrict Hermes runtime authority
prekshivyas Sep 2, 2026
9e79653
docs(policy): align Hermes Discord remediation
prekshivyas Sep 2, 2026
05bca26
fix(policy): complete Hermes Discord authority proof
prekshivyas Sep 2, 2026
2b7ede2
test(policy): align Hermes Discord CI coverage
prekshivyas Sep 2, 2026
260cf29
merge: resolve conflicts with main
github-actions[bot] Sep 2, 2026
1d0fb71
test(policy): consolidate Discord reconciliation
prekshivyas Sep 2, 2026
46127b3
test(e2e): share fake messaging policy setup
prekshivyas Sep 2, 2026
eb36cf7
test(e2e): cover explicit fake policy binaries
prekshivyas Sep 2, 2026
5e6bf13
test(e2e): move fake policy parity proof
prekshivyas Sep 2, 2026
1503226
test(e2e): keep parity proof behavior-focused
prekshivyas Sep 2, 2026
2e3e64c
Merge branch 'main' into codex/fix-hermes-discord-node-policy
prekshivyas Sep 2, 2026
5a84c21
merge(main): resolve conflicts for #10682
prekshivyas Sep 3, 2026
355b7ea
fix(pi): refresh Discord qualification receipts
prekshivyas Sep 3, 2026
f4249cd
fix(policy): restore Hermes Discord trust proofs
prekshivyas Sep 3, 2026
e46edd4
fix(security): refresh Pi receipts and harden fixture
prekshivyas Sep 3, 2026
d29047c
test(e2e): require attached fixture providers
prekshivyas Sep 3, 2026
178c820
test(e2e): record Slack credential phase
prekshivyas Sep 3, 2026
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
30 changes: 30 additions & 0 deletions docs/network-policy/apply-policy-presets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,36 @@ Run the same `policy add` command after you edit a maintained preset or a custom

The merge starts from the round-trippable base policy returned by `openshell policy get --base`. It excludes provider-composed `_provider_*` entries because OpenShell reserves that namespace and rejects it in `policy set`. Existing presets and baseline entries remain in place.

<AgentOnly variant="hermes">

### Remove the Previous Hermes Discord Node Grant

A Hermes sandbox that was created with an older Discord preset keeps its live policy during rebuild.
Reapply the maintained `discord` preset before the next rebuild to remove the previous `/usr/local/bin/node` grant.

Preview and apply the current preset:

```bash
$$nemoclaw <name> policy add discord --dry-run
$$nemoclaw <name> policy add discord --yes
```

Inspect the resulting live policy:

```bash
$$nemoclaw <name> policy get
```

The `discord` binary list must contain `/usr/bin/python3*` and `/opt/hermes/.venv/bin/python`.
It must not contain `/usr/local/bin/node`.
The reapply operation retains the Discord credential binding and all policy entries outside the maintained `discord` entry.
A later rebuild preserves this reconciled live policy.

If the apply command fails, NemoClaw leaves the live policy unchanged.
Resolve the reported policy or gateway error, then rerun the preview and apply commands.

</AgentOnly>

## List and Remove Presets

List available presets and identify which ones match the current OpenShell policy:
Expand Down
28 changes: 25 additions & 3 deletions docs/reference/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2344,11 +2344,11 @@ In that case:

### Discord preset validation behind a proxy

The built-in Discord policy preset intentionally allows the Node binaries used by the messaging runtime and does not allow `curl`. As a result, `curl -s https://discord.com` failing, hanging, or printing no output is not proof that the Discord preset is broken.
The built-in Discord policy preset allows the OpenClaw Node.js runtime and does not allow `curl`. As a result, `curl -s https://discord.com` failing, hanging, or printing no output is not proof that the Discord preset is broken.

Behind the OpenShell proxy, direct DNS-only checks can also be the wrong signal. For example, `dns.resolve("gateway.discord.gg")` can fail even when HTTPS requests routed through the proxy are healthy.

Use Node HTTPS as the manual REST probe:
Use the OpenClaw Node.js runtime for the manual REST probe:

```bash
node - <<'NODE'
Expand Down Expand Up @@ -2384,7 +2384,7 @@ https
NODE
```

Any HTTP status from these probes means the Node process reached the endpoint; the exact status can vary by unauthenticated path. If the Node REST probe works but the Discord channel is still unhealthy, investigate the native gateway path instead of widening the preset. Check the gateway logs and blocked-request output with `openshell term`, and look for `gateway.discord.gg` connection or WebSocket upgrade failures.
Any HTTP status from these probes means the Node.js process reached the endpoint; the exact status can vary by unauthenticated path. A transport error or OpenShell policy denial means the probe failed. If the REST probe works but the Discord channel is still unhealthy, investigate the native gateway path instead of widening the preset. Check the gateway logs and blocked-request output with `openshell term`, and look for `gateway.discord.gg` connection or WebSocket upgrade failures.

### Messaging bridge appears running but no messages arrive

Expand Down Expand Up @@ -4402,6 +4402,28 @@ Continue only when onboarding no longer reports that the Podman service or OpenS

The issues below are common problems you may encounter when running Hermes through `nemohermes`. For setup, refer to [Quickstart with Hermes](../../hermes/get-started/quickstart).

### Discord preset validation behind a proxy

The Hermes Discord policy allows its virtual-environment Python interpreter and does not grant Node.js or `curl` Discord egress. A Node.js or `curl` failure is therefore not proof that the Discord preset is broken.

Use the Hermes virtual-environment Python runtime inside the affected sandbox for the manual REST probe:

```bash
nemohermes <name> exec -- /opt/hermes/.venv/bin/python -c 'import urllib.error, urllib.request; u="https://discord.com/api/v10/gateway";
try: print(urllib.request.urlopen(u, timeout=20).status)
except urllib.error.HTTPError as error: print(error.code)'
```

To check Discord CDN egress, use the same sandbox-qualified Python runtime:

```bash
nemohermes <name> exec -- /opt/hermes/.venv/bin/python -c 'import urllib.error, urllib.request; u="https://cdn.discordapp.com/";
try: print(urllib.request.urlopen(u, timeout=20).status)
except urllib.error.HTTPError as error: print(error.code)'
```

Any HTTP status means the Python process reached the endpoint; the exact status can vary by unauthenticated path. A transport error or OpenShell policy denial means the probe failed. If the REST probe works but the Discord channel is still unhealthy, inspect the gateway logs and blocked-request output with `openshell term`. Look for `gateway.discord.gg` connection or WebSocket upgrade failures instead of widening the preset.

### Hermes dashboard config did not converge

`nemohermes inference set` updates the OpenShell route, registry, and `/sandbox/.hermes/config.yaml` before it refreshes the separate dashboard profile. If the dashboard profile exists but NemoClaw cannot confirm that `/sandbox/.hermes/profiles/dashboard-home/config.yaml` was updated, the command exits nonzero without printing `Inference route synced`. The committed route and main Hermes config are not rolled back.
Expand Down
20 changes: 18 additions & 2 deletions src/lib/actions/sandbox/policy-channel-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,17 +332,33 @@ describe("addSandboxPolicy", () => {
},
);

it("prints Discord validation guidance when the preset name is provided", async () => {
it("prints Hermes Python Discord validation guidance when the preset name is provided", async () => {
arrangeSandbox("hermes");

await addSandboxPolicy("test-sandbox", { preset: "discord", yes: true });

expect(printedText()).toContain("curl is not in the preset binary allowlist");
expect(printedText()).toContain("Node HTTPS");
expect(printedText()).toContain("nemohermes <name> exec -- /opt/hermes/.venv/bin/python -c");
expect(printedText()).toContain("except urllib.error.HTTPError as error: print(error.code)");
expect(printedText()).toContain("Any HTTP response confirms reachability");
expect(printedText()).not.toContain("prints 200 on success");
expect(printedText()).not.toMatch(/^\/opt\/hermes\/\.venv\/bin\/python -c/mu);
expect(printedText()).not.toContain("node -e");
expect(promptMock).not.toHaveBeenCalled();
expect(applyPresetMock).toHaveBeenCalledWith("test-sandbox", "discord", {
suppressDisclosure: true,
});
});

it("prints OpenClaw Node Discord validation guidance without the Hermes probe", async () => {
arrangeSandbox("openclaw");

await addSandboxPolicy("test-sandbox", { preset: "discord", yes: true });

expect(printedText()).toContain("node -e");
expect(printedText()).not.toContain("/opt/hermes/.venv/bin/python");
});

it("does not print messaging guidance when a non-messaging preset is selected", async () => {
await addSandboxPolicy("test-sandbox");

Expand Down
4 changes: 3 additions & 1 deletion src/lib/actions/sandbox/policy-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,9 @@ async function addSandboxPolicyUnlocked(
policies.logOpenClawNpmCompatibilityDisclosure();
}

const presetWarning = policies.getPresetValidationWarning(answer);
const messagingAgent =
sandboxAgent === "openclaw" || sandboxAgent === "hermes" ? sandboxAgent : undefined;
const presetWarning = policies.getPresetValidationWarning(answer, { agent: messagingAgent });
if (presetWarning) {
console.log("");
console.log(` ${presetWarning}`);
Expand Down
18 changes: 15 additions & 3 deletions src/lib/messaging/channels/discord/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,23 @@ export const discordManifest = {
validationWarningLines: [
"For Discord preset validation, do not use curl as the success signal:",
"curl is not in the preset binary allowlist, so curl probes can fail even",
"when the policy is working. Use Node HTTPS against",
"https://discord.com/api/v10/gateway or validate the configured",
'messaging bridge/gateway path. DNS-only checks such as dns.resolve("gateway.discord.gg")',
"when the policy is working. Validate the configured messaging bridge/gateway path.",
Comment thread
cjagwani marked this conversation as resolved.
'DNS-only checks such as dns.resolve("gateway.discord.gg")',
"can also be inconclusive behind a proxy.",
"The agent-specific gateway probe prints an HTTP status when it reaches Discord.",
"Any HTTP response confirms reachability. A transport error or OpenShell policy",
"denial means validation failed.",
],
validationWarningLinesByAgent: {
openclaw: [
"OpenClaw validation uses its Node runtime:",
`node -e "require('node:https').get('https://discord.com/api/v10/gateway',r=>console.log(r.statusCode)).on('error',e=>{console.error(e.message);process.exitCode=1})"`,
],
hermes: [
"Hermes validation uses its virtual-environment Python runtime:",
`nemohermes <name> exec -- /opt/hermes/.venv/bin/python -c "import urllib.error, urllib.request; u='https://discord.com/api/v10/gateway';\ntry: print(urllib.request.urlopen(u, timeout=20).status)\nexcept urllib.error.HTTPError as error: print(error.code)"`,
],
},
},
],
render: [
Expand Down
1 change: 0 additions & 1 deletion src/lib/messaging/channels/discord/policy/hermes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ network_policies:
rules:
- allow: { method: GET, path: "/**" }
binaries:
- { path: /usr/local/bin/node }
- { path: /usr/bin/python3* }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1][Security testing] The shipped Hermes Discord policy is Python-only here, but test/e2e/live/hermes-discord.test.ts:145-164 rebuilds its fake policy with /usr/local/bin/node and /usr/bin/node, then :686-716 performs the REST proof through sandboxNode. Impact: the live gate validates a wider authority than production and can stay green while the least-privilege contract is broken or unexercised. Smallest safe fix: remove both Node grants from the fake policy and replace the REST probe with /opt/hermes/.venv/bin/python (or the reviewed Hermes Python helper), preserving the placeholder, credential-binding, inspected REST/WebSocket, timeout, and token-isolation assertions. Regression: make the lane fail if Node is reintroduced or if the Python runtime cannot reach the credential-bound Discord route.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in commit 2bd37f0. The Hermes fake Discord policy no longer grants either Node binary. The WebSocket and credential-bound REST proofs both run through /opt/hermes/.venv/bin/python, and the live lane reads the effective policy and fails if Node appears in either the production or fake Discord policy. Focused policy/onboarding tests (125), Hermes E2E-support tests (4), CLI type-check, docs, and npm run validate:pr pass. Fresh exact-head hermes-discord E2E will be dispatched after normal candidate checks and reviews stabilize.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Migrate the durable live policy, not only the preset source. Impact: existing Hermes Discord sandboxes retain the old /usr/local/bin/node grant across upgrade/rebuild because rebuild preserves the captured OpenShell policy; fresh-onboard tests cannot prove this security fix reaches deployed sandboxes. Smallest safe fix: reconcile the discord preset on a supported lifecycle path, or emit a named per-sandbox remediation that reapplies the preset and verifies Node is absent. Regression: start from a persisted prior Discord policy containing Node, run the supported upgrade/rebuild/remediation, and prove Node is removed while the Python binaries and credential binding remain exactly once.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed on exact head 1e6b8c07d78f74106cd5968797df6175b7815b6e by e59ea726fc0d5ea22ac04066096a91dfaca8e502. The Hermes-only policy documentation now names the supported per-sandbox remediation: preview and reapply the maintained discord preset, then inspect the live policy. The new test/runtime/policy/hermes-discord-policy-reconciliation.test.ts regression registers a configured Hermes Discord sandbox, persists the prior live policy with /usr/local/bin/node, invokes the supported applyPreset(..., "discord") path, and proves the resulting binary list is exactly /usr/bin/python3* plus /opt/hermes/.venv/bin/python. It also proves the discord.com credential-bound endpoint remains exactly once with provider hermes-sandbox-discord-bridge. npm run validate:pr passes on this exact head based on canonical main f68ccab0c9cde2afbef32425fea1f2472a0f8532; the focused extraction plus policy and growth coverage passes 142 tests, the docs build passes, and the reviewed runtime bundle deterministically regenerates.

- { path: /usr/bin/python3.13 }
- { path: /opt/hermes/.venv/bin/python3 }
Expand Down
21 changes: 20 additions & 1 deletion src/lib/messaging/channels/metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,27 @@ describe("built-in messaging channel metadata", () => {
"teams",
]);
expect(getMessagingPolicyPresetValidationWarnings().discord).toContain(
"https://discord.com/api/v10/gateway or validate the configured",
"Any HTTP response confirms reachability. A transport error or OpenShell policy",
);
const openClawDiscordWarning = getMessagingPolicyPresetValidationWarnings({
agent: "openclaw",
}).discord;
expect(openClawDiscordWarning).toContain("OpenClaw validation uses its Node runtime:");
expect(openClawDiscordWarning).not.toContain(
"Hermes validation uses its virtual-environment Python runtime:",
);
const hermesDiscordWarning = getMessagingPolicyPresetValidationWarnings({
agent: "hermes",
}).discord;
expect(hermesDiscordWarning).toContain(
"Hermes validation uses its virtual-environment Python runtime:",
);
const renderedHermesDiscordWarning = hermesDiscordWarning.join("\n");
expect(renderedHermesDiscordWarning).toContain(
"except urllib.error.HTTPError as error: print(error.code)",
);
expect(renderedHermesDiscordWarning).not.toContain("prints 200 on success");
expect(hermesDiscordWarning).not.toContain("OpenClaw validation uses its Node runtime:");
expect(listOpenClawManagedChannelNames()).toEqual([
"telegram",
"discord",
Expand Down
12 changes: 10 additions & 2 deletions src/lib/messaging/channels/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ export interface MessagingPolicyPresetMetadata {
readonly agentPolicyKeys: Partial<Record<MessagingAgentId, readonly string[]>>;
readonly requiredAtCreate: boolean;
readonly validationWarningLines: readonly string[];
readonly validationWarningLinesByAgent: Partial<
Record<MessagingAgentId, readonly string[]>
>;
}

export interface OpenClawRuntimeChannelMetadata {
Expand Down Expand Up @@ -267,6 +270,7 @@ export function listMessagingPolicyPresetMetadata(
agentPolicyKeys: normalized.agentPolicyKeys ?? {},
requiredAtCreate: normalized.requiredAtCreate === true,
validationWarningLines: normalized.validationWarningLines ?? [],
validationWarningLinesByAgent: normalized.validationWarningLinesByAgent ?? {},
};
}),
);
Expand Down Expand Up @@ -341,10 +345,14 @@ export function getMessagingPolicyPresetValidationWarnings(
): Readonly<Record<string, readonly string[]>> {
const result: Record<string, string[]> = {};
for (const preset of listMessagingPolicyPresetMetadata(options)) {
if (preset.validationWarningLines.length === 0) continue;
const agentLines = options.agent
? (preset.validationWarningLinesByAgent[options.agent] ?? [])
: Object.values(preset.validationWarningLinesByAgent).flatMap((lines) => lines ?? []);
const warningLines = [...preset.validationWarningLines, ...agentLines];
if (warningLines.length === 0) continue;
result[preset.presetName] = uniqueStrings([
...(result[preset.presetName] ?? []),
...preset.validationWarningLines,
...warningLines,
]);
}
return result;
Expand Down
3 changes: 3 additions & 0 deletions src/lib/messaging/manifest/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ export interface ChannelPolicyPresetSpec {
readonly agentPolicyKeys?: Partial<Record<MessagingAgentId, readonly string[]>>;
readonly requiredAtCreate?: boolean;
readonly validationWarningLines?: readonly string[];
readonly validationWarningLinesByAgent?: Partial<
Record<MessagingAgentId, readonly string[]>
>;
}

/** How a channel obtains credential or session material. */
Expand Down
13 changes: 8 additions & 5 deletions src/lib/policy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,10 +479,10 @@ const MESSAGING_PRESET_LABELS: Readonly<Record<string, string>> = Object.fromEnt
}),
);

const MESSAGING_PRESET_VALIDATION_WARNING_LINES: Readonly<Record<string, readonly string[]>> =
getMessagingPolicyPresetValidationWarnings();

function getPresetValidationWarning(presetName: string): string | null {
function getPresetValidationWarning(
presetName: string,
options: { agent?: "openclaw" | "hermes" } = {},
): string | null {
if (presetName === "jira") {
return [
"Jira preset validation uses per-binary policy signals.",
Expand All @@ -509,7 +509,10 @@ function getPresetValidationWarning(presetName: string): string | null {
"configuration are wired up at onboard time and are not added by applying",
"this preset alone.",
];
lines.push(...(MESSAGING_PRESET_VALIDATION_WARNING_LINES[presetName] ?? []));
const validationWarningLines = getMessagingPolicyPresetValidationWarnings({
agent: options.agent,
});
lines.push(...(validationWarningLines[presetName] ?? []));

return lines.join("\n ");
}
Expand Down
107 changes: 107 additions & 0 deletions test/e2e/lib/fake-discord-message-api.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#!/usr/bin/env -S node --experimental-strip-types
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import fs from "node:fs";
import http from "node:http";

const host = process.env.FAKE_DISCORD_MESSAGE_API_HOST || "0.0.0.0";
const rawPort = process.env.FAKE_DISCORD_MESSAGE_API_PORT || "0";
const port = Number(rawPort);
const portFile = process.env.FAKE_DISCORD_MESSAGE_API_PORT_FILE || "";
const captureFile = process.env.FAKE_DISCORD_MESSAGE_API_CAPTURE_FILE || "";
const expectedToken = process.env.FAKE_DISCORD_MESSAGE_API_EXPECTED_TOKEN || "";

if (!Number.isInteger(port) || port < 0 || port > 65_535) {
throw new Error(
`FAKE_DISCORD_MESSAGE_API_PORT must be an integer between 0 and 65535 (received: ${rawPort})`,
);
}

if (!expectedToken) {
throw new Error("FAKE_DISCORD_MESSAGE_API_EXPECTED_TOKEN is required");
}

function record(event: Record<string, unknown>): void {
if (captureFile) {
fs.appendFileSync(captureFile, `${JSON.stringify({ at: Date.now(), ...event })}\n`);
}
}

function tokenFromAuthorization(value: string | undefined): string {
const raw = String(value || "");
if (raw.length < 4 || raw.slice(0, 3).toLowerCase() !== "bot") return raw;
const next = raw.charCodeAt(3);
if (next !== 0x20 && next !== 0x09) return raw;
let index = 4;
while (index < raw.length) {
const code = raw.charCodeAt(index);
if (code !== 0x20 && code !== 0x09) break;
index += 1;
}
return raw.slice(index);
}

function tokenLooksPlaceholder(value: string): boolean {
return value.includes("openshell:resolve:env:");
}

function writeJson(response: http.ServerResponse, status: number, body: unknown): void {
response.writeHead(status, { "content-type": "application/json" });
response.end(JSON.stringify(body));
}

const server = http.createServer((request, response) => {
const url = new URL(request.url || "/", "http://fake-discord.local");
const token = tokenFromAuthorization(request.headers.authorization);
const tokenMatchesExpected = token === expectedToken;

record({
event: "request",
method: request.method,
path: url.pathname,
tokenMatchesExpected,
tokenLooksPlaceholder: tokenLooksPlaceholder(token),
authorizationPresent: Boolean(request.headers.authorization),
authorizationRedacted: true,
});

if (!tokenMatchesExpected) {
writeJson(response, 401, { message: "401: Unauthorized", code: 0 });
return;
}

if (request.method === "GET" && url.pathname === "/api/v10/users/@me") {
writeJson(response, 200, {
id: "420000000000000000",
username: "NemoClaw E2E",
bot: true,
});
return;
}

writeJson(response, 404, { message: "Unknown Endpoint", code: 10_001 });
});

server.on("error", (error) => {
record({ event: "server_error", error: error.message });
console.error(error.stack || error.message);
});

server.listen(port, host, () => {
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("fake Discord message API did not bind a TCP port");
}
if (portFile) {
fs.writeFileSync(portFile, `${address.port}\n`, { mode: 0o600 });
}
record({ event: "listening", host, port: address.port });
});

for (const signal of ["SIGTERM", "SIGINT"] as const) {
process.on(signal, () => {
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 1000).unref();
});
}
2 changes: 2 additions & 0 deletions test/e2e/mock-parity.json
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,8 @@
"src/lib/onboard/messaging-policy-presets.test.ts",
"test/channels/channels-add-preset.test.ts",
"test/e2e/support/hermes-discord-policy-binding.test.ts",
"test/e2e/support/hermes-discord-proxy-request.test.ts",
"test/onboarding/effective-policy-contracts.test.ts",
"test/e2e/support/messaging-providers-runtime-proofs.test.ts",
"test/onboarding/onboard-preset-diff.test.ts",
"test/e2e/support/e2e-cleanup-resources.test.ts",
Expand Down
Loading