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
7 changes: 7 additions & 0 deletions .github/workflows/e2e-vitest-scenarios.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4674,6 +4674,12 @@ jobs:
NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1"
NEMOCLAW_SANDBOX_NAME: "e2e-channels-add-remove"
OPENSHELL_GATEWAY: "nemoclaw"
NEMOCLAW_E2E_USE_HOSTED_INFERENCE: "1"
NEMOCLAW_PROVIDER: custom
NEMOCLAW_ENDPOINT_URL: https://inference-api.nvidia.com/v1
NEMOCLAW_MODEL: nvidia/nvidia/nemotron-3-ultra
NEMOCLAW_COMPAT_MODEL: nvidia/nvidia/nemotron-3-ultra
NEMOCLAW_PREFERRED_API: openai-completions
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
Expand Down Expand Up @@ -4733,6 +4739,7 @@ jobs:
# channels remove cleanup.
env:
NVIDIA_INFERENCE_API_KEY: ${{ secrets.NVIDIA_INFERENCE_API_KEY }}
COMPATIBLE_API_KEY: ${{ secrets.NVIDIA_INFERENCE_API_KEY }}
TELEGRAM_BOT_TOKEN: "test-fake-telegram-token-add-remove-e2e"
TELEGRAM_ALLOWED_IDS: "123456789"
TELEGRAM_REQUIRE_MENTION: "0"
Expand Down
81 changes: 81 additions & 0 deletions src/lib/messaging/plan-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,87 @@ describe("parseSandboxMessagingPlan", () => {
});
});

it("keeps compact persisted plans free of derived workflow sections", () => {
const source = makePlan({
networkPolicy: {
presets: ["telegram"],
entries: [
{
channelId: "telegram",
presetName: "telegram",
policyKeys: ["telegram"],
source: "manifest",
},
],
},
agentRender: [
{
channelId: "telegram",
agent: "openclaw",
target: "openclaw.json",
kind: "json-fragment",
path: "channels.telegram",
value: { enabled: true },
templateRefs: [],
},
],
buildSteps: [
{
channelId: "telegram",
kind: "package-install",
outputId: "telegram-openclaw-plugin",
required: true,
value: "npm:@openclaw/telegram",
},
],
runtimeSetup: {
nodePreloads: [],
envAliases: [],
secretScans: [
{
channelId: "telegram",
path: "/sandbox/.openclaw/openclaw.json",
pattern: "TELEGRAM_BOT_TOKEN",
},
],
},
stateUpdates: [
{
channelId: "telegram",
kind: "persist-inputs",
stateKey: "allowedIds.telegram",
inputIds: ["allowedIds"],
},
],
healthChecks: [
{
channelId: "telegram",
phase: "health-check",
requiredBefore: "lifecycle-success",
hookIds: ["telegram-openclaw-bridge-health"],
},
],
});

const compact = compactSandboxMessagingPlanForPersistence(source);

expect(compact.networkPolicy).toEqual(source.networkPolicy);
expect(compact).not.toHaveProperty("agentRender");
expect(compact).not.toHaveProperty("buildSteps");
expect(compact).not.toHaveProperty("runtimeSetup");
expect(compact).not.toHaveProperty("stateUpdates");
expect(compact).not.toHaveProperty("healthChecks");
expect(compact.channels).toEqual([
{
channelId: "telegram",
active: true,
configured: true,
disabled: false,
inputs: [{ inputId: "allowedIds", value: "123" }],
},
]);
});

it("rejects mismatched selectors, duplicate channels, and unsupported channels", () => {
expect(parseSandboxMessagingPlan(makePlan(), { sandboxName: "other" })).toBeNull();
expect(parseSandboxMessagingPlan(makePlan(), { agent: "hermes" })).toBeNull();
Expand Down
17 changes: 8 additions & 9 deletions test/e2e-scenario/live/channels-add-remove.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,14 @@ function expectHostTelegramPlan(expected: "active" | "removed", context: string)
: {};
const networkEntries = planArray(networkPolicy, "entries");
const networkPresets = stringArray(networkPolicy.presets);
const agentRender = planArray(plan, "agentRender");

expect(Object.hasOwn(plan, "agentRender"), "messaging.plan.agentRender should not persist").toBe(
false,
);
expect(
channels.some((entry) => Object.hasOwn(entry, "hooks")),
"messaging.plan.channels hooks should not persist",
).toBe(false);

if (expected === "active") {
expect(
Expand All @@ -194,10 +201,6 @@ function expectHostTelegramPlan(expected: "active" | "removed", context: string)
),
`telegram TELEGRAM_BOT_TOKEN credential binding missing ${context}`,
).toBe(true);
expect(
agentRender.some((entry) => entry.channelId === "telegram" && entry.agent === "openclaw"),
`telegram openclaw agent render entry missing ${context}`,
).toBe(true);
expect(disabledChannels, `telegram unexpectedly disabled ${context}`).not.toContain("telegram");
return;
}
Expand All @@ -218,10 +221,6 @@ function expectHostTelegramPlan(expected: "active" | "removed", context: string)
credentialBindings.some((entry) => entry.channelId === "telegram"),
`telegram credential binding still present ${context}`,
).toBe(false);
expect(
agentRender.some((entry) => entry.channelId === "telegram"),
`telegram agent render entry still present ${context}`,
).toBe(false);
}

async function expectSandboxReady(
Expand Down
41 changes: 37 additions & 4 deletions test/e2e-scenario/live/messaging-providers-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,8 @@ if env 2>/dev/null | grep -Fq "$token"; then echo FOUND; else echo ABSENT; fi`
? `token="$(printf '%s' ${shellQuote(tokenB64)} | base64 -d)"
if cat /proc/[0-9]*/cmdline 2>/dev/null | tr '\\0' '\\n' | grep -Fq "$token"; then echo FOUND; else echo ABSENT; fi`
: `token="$(printf '%s' ${shellQuote(tokenB64)} | base64 -d)"
if grep -rIlm1 -F "$token" /sandbox /home /etc /tmp /var 2>/dev/null | head -1; then true; else echo ABSENT; fi`;
match="$(grep -rIlm1 -F "$token" /sandbox /home /etc /tmp /var 2>/dev/null | head -1 || true)"
if [ -n "$match" ]; then printf '%s\n' "$match"; else echo ABSENT; fi`;
return sandboxOutput(sandbox, probe, artifactName, redactionValues);
}

Expand Down Expand Up @@ -797,24 +798,51 @@ function decodeFrame(buffer) {
if (buffer.length < 4) return null;
payloadLength = buffer.readUInt16BE(2);
offset = 4;
} else if (payloadLength === 127) {
if (buffer.length < 10) return null;
payloadLength = Number(buffer.readBigUInt64BE(2));
offset = 10;
}
if (buffer.length < offset + payloadLength) return null;
return { opcode, payload: buffer.slice(offset, offset + payloadLength), totalLength: offset + payloadLength };
}

const socket = net.createConnection({ host, port });
function parseProxyTarget() {
const raw = process.env.HTTP_PROXY || process.env.http_proxy || "";
if (!raw) return null;
let parsed;
try {
parsed = new URL(raw);
} catch {
throw new Error("HTTP proxy for Discord Gateway proof is malformed");
}
if (parsed.protocol !== "http:") throw new Error("Discord Gateway proof only supports HTTP proxies");
const proxyPort = Number(parsed.port || "80");
if (!Number.isInteger(proxyPort) || proxyPort < 1 || proxyPort > 65535) throw new Error("HTTP proxy port for Discord Gateway proof is invalid");
if (parsed.hostname !== "10.200.0.1" || proxyPort !== 3128) throw new Error("unexpected HTTP proxy for Discord Gateway proof");
return { host: parsed.hostname, port: proxyPort };
}

const proxy = parseProxyTarget();
const socket = proxy
? net.createConnection({ host: proxy.host, port: proxy.port })
: net.createConnection({ host, port });
const timer = setTimeout(() => {
socket.destroy();
finish("TIMEOUT");
}, 20000);
let handshake = Buffer.alloc(0);
let framed = Buffer.alloc(0);
let upgraded = false;
let finished = false;

socket.on("connect", () => {
const key = crypto.randomBytes(16).toString("base64");
const requestTarget = proxy
? \`http://\${host}:\${port}/gateway?v=10&encoding=json\`
: "/gateway?v=10&encoding=json";
socket.write([
"GET /gateway?v=10&encoding=json HTTP/1.1",
\`GET \${requestTarget} HTTP/1.1\`,
\`Host: \${host}:\${port}\`,
"Upgrade: websocket",
"Connection: Upgrade",
Expand Down Expand Up @@ -864,14 +892,19 @@ socket.on("data", (chunk) => {
} else if (message.op === 11) {
results.push("HEARTBEAT_ACK");
clearTimeout(timer);
finished = true;
socket.end();
finish();
}
}
});
socket.on("error", (error) => {
clearTimeout(timer);
finish(\`ERROR \${error.message}\`);
if (!finished) finish(\`ERROR \${error.message}\`);
});
socket.on("close", () => {
clearTimeout(timer);
if (!finished) finish("CLOSED");
});
`,
{
Expand Down
29 changes: 27 additions & 2 deletions test/e2e-scenario/live/messaging-providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,26 @@ process.exit(Array.isArray(channels) && channels.some((c) => c?.channelId === "w
);
expectExitZero(whatsappRebuild, "M-WA4: rebuild completed after WhatsApp channel add");

const whatsappPolicyPost = await runHost(
host,
"openshell",
["policy", "get", "--full", SANDBOX_NAME],
{
artifactName: "whatsapp-policy-post-rebuild-messaging-providers",
env: state.env,
redactionValues,
timeoutMs: 60_000,
},
);
const whatsappPolicyPostText = outputText(whatsappPolicyPost);
check(
policyTextHasHost(whatsappPolicyPostText, "web.whatsapp.com") &&
policyTextHasHost(whatsappPolicyPostText, "whatsapp.net") &&
policyTextHasHost(whatsappPolicyPostText, "raw.githubusercontent.com") &&
/\/usr\/local\/bin\/node|\/usr\/bin\/node/.test(whatsappPolicyPostText),
"M-WA5: WhatsApp policy preset survived rebuild with Node binary scope",
);

const providerList = await runHost(host, "openshell", ["provider", "list"], {
artifactName: "provider-list-messaging-providers",
env: state.env,
Expand Down Expand Up @@ -882,10 +902,15 @@ req.setTimeout(30000, () => { req.destroy(); console.log("TIMEOUT"); });
fakeGateway.captureFile,
(row) => row.event === "identify",
);
check(fs.existsSync(fakeGateway.captureFile), "M13f: fake Gateway capture file exists");
const gatewayCaptureText = fs.readFileSync(fakeGateway.captureFile, "utf8");
check(
gatewayIdentify?.tokenMatchesExpected === true &&
gatewayIdentify?.tokenLooksPlaceholder === false,
"M13f: fake Gateway received host-side Discord token after relay rewrite",
gatewayIdentify?.tokenLooksPlaceholder === false &&
!Object.prototype.hasOwnProperty.call(gatewayIdentify, "token") &&
!gatewayCaptureText.includes(state.tokens.discord) &&
!gatewayCaptureText.includes("openshell:resolve:env:"),
"M13f: fake Gateway proved placeholder-to-token rewrite without logging the raw token",
);

const gatewayPort = await sandboxOutput(
Expand Down
2 changes: 1 addition & 1 deletion test/e2e-scenario/live/openclaw-discord-pairing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ test.skipIf(!shouldRunLiveE2EScenarios())(
expect(configSummary.token).toContain("openshell:resolve:env:");
expect(configSummary.token).toContain("DISCORD_BOT_TOKEN");
expect(configSummary.dmPolicy).not.toBe("allowlist");
expect(configSummary.accountProxy, "Discord account proxy").toMatch(/^http:\/\//);
expect(configSummary.accountProxy, "Discord account proxy").toBe("");
expect(configSummary.managedProxy, "OpenClaw managed proxy").toMatch(/^http:\/\//);

await assertOpenClawStateRoot(sandbox, SANDBOX_NAME, "discord", redactions);
Expand Down
42 changes: 42 additions & 0 deletions tools/e2e-scenarios/workflow-boundary.mts
Original file line number Diff line number Diff line change
Expand Up @@ -5781,8 +5781,39 @@ function validateChannelsAddRemoveVitestJob(
"channels-add-remove-vitest job must force OPENSHELL_GATEWAY=nemoclaw",
);
}
if (jobEnv.NEMOCLAW_E2E_USE_HOSTED_INFERENCE !== "1") {
errors.push(
"channels-add-remove-vitest job must enable hosted-compatible inference mode",
);
}
if (jobEnv.NEMOCLAW_PROVIDER !== "custom") {
errors.push(
"channels-add-remove-vitest job must route hosted inference through the custom provider",
);
}
if (jobEnv.NEMOCLAW_ENDPOINT_URL !== "https://inference-api.nvidia.com/v1") {
errors.push(
"channels-add-remove-vitest job must use the hosted compatible inference endpoint",
);
}
if (jobEnv.NEMOCLAW_MODEL !== "nvidia/nvidia/nemotron-3-ultra") {
errors.push(
"channels-add-remove-vitest job must use the hosted Inference Hub model id",
);
}
if (jobEnv.NEMOCLAW_COMPAT_MODEL !== "nvidia/nvidia/nemotron-3-ultra") {
errors.push(
"channels-add-remove-vitest job must set NEMOCLAW_COMPAT_MODEL to the hosted model id",
);
}
if (jobEnv.NEMOCLAW_PREFERRED_API !== "openai-completions") {
errors.push(
"channels-add-remove-vitest job must prefer openai-completions for hosted inference",
);
}
for (const secret of [
"NVIDIA_INFERENCE_API_KEY",
"COMPATIBLE_API_KEY",
"DOCKERHUB_USERNAME",
"DOCKERHUB_TOKEN",
"GITHUB_TOKEN",
Expand All @@ -5807,6 +5838,12 @@ function validateChannelsAddRemoveVitestJob(
stepEnv,
"NVIDIA_INFERENCE_API_KEY",
);
requireEnvDoesNotExposeSecret(
errors,
stepName,
stepEnv,
"COMPATIBLE_API_KEY",
);
}
if (step.name !== "Authenticate to Docker Hub") {
requireEnvDoesNotExposeSecret(
Expand Down Expand Up @@ -5911,6 +5948,11 @@ function validateChannelsAddRemoveVitestJob(
"channels-add-remove-vitest step must receive NVIDIA_INFERENCE_API_KEY from secrets",
);
}
if (runVitestEnv.COMPATIBLE_API_KEY !== "${{ secrets.NVIDIA_INFERENCE_API_KEY }}") {
errors.push(
"channels-add-remove-vitest step must stage NVIDIA_INFERENCE_API_KEY as COMPATIBLE_API_KEY",
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (
runVitestEnv.TELEGRAM_BOT_TOKEN !==
"test-fake-telegram-token-add-remove-e2e"
Expand Down