diff --git a/.github/workflows/npm-telegram-beta-e2e.yml b/.github/workflows/npm-telegram-beta-e2e.yml index 20dab8c23455..f240a6774471 100644 --- a/.github/workflows/npm-telegram-beta-e2e.yml +++ b/.github/workflows/npm-telegram-beta-e2e.yml @@ -348,6 +348,7 @@ jobs: qa/scenarios/channels/telegram-progress-tool-visibility.yaml qa/scenarios/channels/telegram-queue-invalid-mode.yaml qa/scenarios/channels/telegram-rich-inline-composition.yaml + qa/scenarios/channels/telegram-repeated-command-authorization.yaml src/agents/embedded-agent-subscribe.ts - name: Resolve frozen package Telegram scenarios diff --git a/scripts/e2e/lib/npm-telegram-live/resolve-target-scenarios.mts b/scripts/e2e/lib/npm-telegram-live/resolve-target-scenarios.mts index 2e7dff81ad10..eaa7e19242b4 100644 --- a/scripts/e2e/lib/npm-telegram-live/resolve-target-scenarios.mts +++ b/scripts/e2e/lib/npm-telegram-live/resolve-target-scenarios.mts @@ -1,7 +1,7 @@ import { execFileSync } from "node:child_process"; -import { appendFileSync, readFileSync } from "node:fs"; +import { appendFileSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; -import { pathToFileURL } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; const PARTIAL_FAILURE_RECOVERY_SCENARIO = "telegram-partial-failure-recovery"; const SETTLED_EMPTY_RESPONSE_SCENARIO = "telegram-empty-response-after-write-recovery"; @@ -13,6 +13,37 @@ const POLICY_HOT_RELOAD_SCENARIOS = [ "telegram-policy-hot-reload", "telegram-group-policy-hot-reload", ]; +const REPEATED_COMMAND_SCENARIO = + "qa/scenarios/channels/telegram-repeated-command-authorization.yaml"; +const SILENT_DENIAL = " - waitForNoOutbound:\n quietMs: 3000\n"; +const EXPLICIT_DENIAL = + " - waitForOutbound:\n" + + " conversation: { id: telegram-command-room, kind: channel }\n" + + " textIncludes: You are not authorized to use this command.\n" + + " timeoutMs: 60000\n"; + +function resolveFrozenTelegramCommandScenario(sourceRoot: string): string | undefined { + const target = readSource(sourceRoot, REPEATED_COMMAND_SCENARIO); + if (target === undefined) { + return undefined; + } + const hasExplicitDenial = target.split(EXPLICIT_DENIAL).length === 2; + const hasSilentDenial = target.split(SILENT_DENIAL).length === 2; + if (hasExplicitDenial === hasSilentDenial) { + throw new Error("unrecognized frozen Telegram repeated-command denial contract"); + } + if (!hasExplicitDenial) { + return undefined; + } + // Target source selects a known contract; only trusted tooling supplies executable YAML. + // Retire this projection when no supported frozen release returns explicit denials. + const trustedRoot = fileURLToPath(new URL("../../../../", import.meta.url)); + const trusted = readFileSync(path.join(trustedRoot, REPEATED_COMMAND_SCENARIO), "utf8"); + if (trusted.split(SILENT_DENIAL).length !== 2 || trusted.includes(EXPLICIT_DENIAL)) { + throw new Error("trusted Telegram repeated-command denial action changed"); + } + return trusted.replace(SILENT_DENIAL, EXPLICIT_DENIAL); +} function readSource(sourceRoot: string, relativePath: string): string | undefined { try { @@ -100,6 +131,21 @@ function main(): void { if (actualSha !== selectedSha) { throw new Error("frozen Telegram source checkout does not match package source SHA"); } + const commandScenario = resolveFrozenTelegramCommandScenario(sourceRoot); + if (commandScenario !== undefined) { + const overlayDir = mkdtempSync( + path.join(path.dirname(path.resolve(sourceRoot)), "telegram-contract-"), + ); + const overlay = path.join(overlayDir, path.basename(REPEATED_COMMAND_SCENARIO)); + writeFileSync( + overlay, + `# Frozen package source ${actualSha}: explicit authorization denial.\n${commandScenario}`, + ); + appendFileSync(output, `OPENCLAW_NPM_TELEGRAM_COMMAND_SCENARIO=${overlay}\n`); + process.stdout.write( + `Telegram repeated-command authorization: explicit denial contract from ${actualSha}\n`, + ); + } const omittedScenarios = resolveFrozenTelegramScenarioOmissions(sourceRoot); if (omittedScenarios.length > 0) { appendFileSync( diff --git a/scripts/e2e/npm-telegram-live-docker.sh b/scripts/e2e/npm-telegram-live-docker.sh index 6558d02251c6..50ae0d7ad8d5 100755 --- a/scripts/e2e/npm-telegram-live-docker.sh +++ b/scripts/e2e/npm-telegram-live-docker.sh @@ -429,6 +429,14 @@ EOF # Mount the trusted current-source QA harness separately from the installed # package candidate. The candidate remains the absolute CLI/runtime SUT. +command_scenario_mount_args=() +if [ -n "${OPENCLAW_NPM_TELEGRAM_COMMAND_SCENARIO:-}" ]; then + if [ ! -f "$OPENCLAW_NPM_TELEGRAM_COMMAND_SCENARIO" ]; then + echo "Frozen Telegram command scenario is missing" >&2 + exit 1 + fi + command_scenario_mount_args=(-v "$OPENCLAW_NPM_TELEGRAM_COMMAND_SCENARIO:/app/qa/scenarios/channels/telegram-repeated-command-authorization.yaml:ro") +fi run_logged_print_heartbeat "npm-telegram-live-suite" 60 docker_e2e_run_with_harness \ "${docker_env[@]}" \ -v "$ROOT_DIR/.artifacts:/app/.artifacts" \ @@ -442,6 +450,7 @@ run_logged_print_heartbeat "npm-telegram-live-suite" 60 docker_e2e_run_with_harn -v "$ROOT_DIR/.agents:/app/.agents:ro" \ -v "$ROOT_DIR/taxonomy.yaml:/app/taxonomy.yaml:ro" \ -v "$ROOT_DIR/qa/scenarios:/app/qa/scenarios:ro" \ + ${command_scenario_mount_args[@]+"${command_scenario_mount_args[@]}"} \ ${prepublish_registry_mount_args[@]+"${prepublish_registry_mount_args[@]}"} \ -v "$npm_prefix_host:/npm-global" \ -i "$IMAGE_NAME" bash -s <<'EOF' diff --git a/test/scripts/npm-telegram-frozen-command.test.ts b/test/scripts/npm-telegram-frozen-command.test.ts new file mode 100644 index 000000000000..2f91ff01dfbf --- /dev/null +++ b/test/scripts/npm-telegram-frozen-command.test.ts @@ -0,0 +1,137 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, expect, it } from "vitest"; +import YAML from "yaml"; +import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; + +const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); +const scenarioPath = "qa/scenarios/channels/telegram-repeated-command-authorization.yaml"; +const trusted = readFileSync(path.join(repoRoot, scenarioPath), "utf8"); +const silent = " - waitForNoOutbound:\n quietMs: 3000\n"; +const explicit = + " - waitForOutbound:\n" + + " conversation: { id: telegram-command-room, kind: channel }\n" + + " textIncludes: You are not authorized to use this command.\n" + + " timeoutMs: 60000\n"; +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +function fixture(source: string) { + const root = tempDirs.make("telegram-frozen-command-"); + const target = path.join(root, "target"); + const file = path.join(target, scenarioPath); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, source); + execFileSync("git", ["init", "-q", target]); + execFileSync("git", ["-C", target, "add", "."]); + execFileSync("git", [ + "-C", + target, + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "-qm", + "target", + ]); + const sha = execFileSync("git", ["-C", target, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + const output = path.join(root, "github.env"); + return { + root, + sha, + resolve: (selectedSha = sha) => { + execFileSync( + process.execPath, + [ + path.join(repoRoot, "scripts/e2e/lib/npm-telegram-live/resolve-target-scenarios.mts"), + target, + ], + { + env: { ...process.env, OPENCLAW_SELECTED_SHA: selectedSha, GITHUB_ENV: output }, + stdio: "pipe", + }, + ); + return Object.fromEntries( + readFileSync(output, "utf8") + .trim() + .split("\n") + .map((line) => { + const equal = line.indexOf("="); + return [line.slice(0, equal), line.slice(equal + 1)]; + }), + ); + }, + }; +} + +function captureSuiteMounts(overlay?: string) { + const script = readFileSync( + path.join(repoRoot, "scripts/e2e/npm-telegram-live-docker.sh"), + "utf8", + ); + const mountCall = script.slice( + script.indexOf("command_scenario_mount_args=()"), + script.indexOf(" bash -s <<'EOF'", script.indexOf("command_scenario_mount_args=()")), + ); + return execFileSync( + "/bin/bash", + [ + "-eu", + "-c", + ` +docker_env=(-e FIXTURE=1) +prepublish_registry_mount_args=() +ROOT_DIR=/trusted +OUTPUT_DIR_HOST=/output +OUTPUT_DIR_CONTAINER=/app/output +harness_package_json=/trusted/package.json +npm_prefix_host=/npm +IMAGE_NAME=fixture +run_logged_print_heartbeat() { printf '%s\\n' "$@"; } +${mountCall} +`, + ], + { + encoding: "utf8", + env: { ...process.env, OPENCLAW_NPM_TELEGRAM_COMMAND_SCENARIO: overlay ?? "" }, + }, + ) + .trim() + .split("\n"); +} + +it("mounts a source-qualified trusted denial action without changing restored command proof", () => { + // Frozen YAML selects the contract but must never supply executable expressions. + const f = fixture( + trusted.replace(silent, explicit).replace("title:", "# target-only-untrusted-content\ntitle:"), + ); + const env = f.resolve(); + const overlay = env.OPENCLAW_NPM_TELEGRAM_COMMAND_SCENARIO; + assert.ok(overlay); + const actual = readFileSync(overlay, "utf8"); + expect(actual).toContain(`# Frozen package source ${f.sha}: explicit authorization denial.`); + expect(actual).not.toContain("target-only-untrusted-content"); + expect(YAML.parse(actual)).toEqual(YAML.parse(trusted.replace(silent, explicit))); + expect(env.OPENCLAW_NPM_TELEGRAM_OMIT_DEFAULT_SCENARIOS).not.toContain( + "telegram-repeated-command-authorization", + ); + const mounts = captureSuiteMounts(overlay); + const baseIndex = mounts.indexOf("/trusted/qa/scenarios:/app/qa/scenarios:ro"); + expect(baseIndex).toBeGreaterThan(0); + expect(mounts.indexOf(`${overlay}:/app/${scenarioPath}:ro`)).toBeGreaterThan(baseIndex); +}); + +it("keeps current silent denial on the normal trusted scenario mount", () => { + const env = fixture(trusted).resolve(); + expect(env.OPENCLAW_NPM_TELEGRAM_COMMAND_SCENARIO).toBeUndefined(); + expect(captureSuiteMounts().filter((arg) => arg.includes(scenarioPath))).toEqual([]); +}); + +it("refuses unknown source contracts and mismatched selected source identities", () => { + expect(() => fixture(trusted.replace(silent, "")).resolve()).toThrow(); + expect(() => fixture(trusted.replace(silent, explicit)).resolve("0".repeat(40))).toThrow(); + expect(() => captureSuiteMounts("/missing/frozen-scenario.yaml")).toThrow(); +});